diff --git a/graphify/__main__.py b/graphify/__main__.py index 4af7399..44599bc 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -2447,6 +2447,16 @@ def main() -> None: or os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_ACCESS_KEY_ID") ) + elif backend == "claude-cli": + import shutil as _shutil + allow_no_key = _shutil.which("claude") is not None + if not allow_no_key: + print( + "error: backend 'claude-cli' requires the `claude` CLI on $PATH " + "(install Claude Code and run `claude` once to authenticate).", + file=sys.stderr, + ) + sys.exit(1) if not allow_no_key: print( f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", diff --git a/graphify/llm.py b/graphify/llm.py index c193269..585c2c0 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -94,6 +94,16 @@ BACKENDS: dict[str, dict] = { "temperature": 0, "max_tokens": 16384, }, + "claude-cli": { + # Routes through the locally-installed `claude` CLI (Claude Code) using + # `-p --output-format json`. Authenticates via the user's existing + # Pro/Max subscription instead of a separate ANTHROPIC_API_KEY — costs + # are billed to the plan, not pay-as-you-go API credit. + "default_model": "claude-code-plan", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + }, } @@ -397,6 +407,71 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = return result +def _call_claude_cli(user_message: str, max_tokens: int = 8192) -> dict: + """Call Claude via the locally-installed Claude Code CLI (`claude -p`). + + Routes through the user's Claude Code subscription auth instead of a separate + ANTHROPIC_API_KEY. Useful for Pro/Max subscribers who don't want to provision + a pay-as-you-go API key just to run graphify's semantic pass. + """ + import shutil + import subprocess + + if shutil.which("claude") is None: + raise RuntimeError( + "Claude Code CLI not found on $PATH. Install from " + "https://claude.ai/code and run `claude` once to authenticate." + ) + + proc = subprocess.run( + [ + "claude", "-p", + "--output-format", "json", + "--no-session-persistence", + "--append-system-prompt", _EXTRACTION_SYSTEM, + ], + input=user_message, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}" + ) + + try: + envelope = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"claude -p produced unparseable JSON envelope: {exc}; " + f"first 500 chars of stdout: {proc.stdout[:500]!r}" + ) from exc + + raw_content = envelope.get("result", "") + result = _parse_llm_json(raw_content or "{}") + usage = envelope.get("usage") or {} + result["input_tokens"] = ( + int(usage.get("input_tokens", 0) or 0) + + int(usage.get("cache_read_input_tokens", 0) or 0) + + int(usage.get("cache_creation_input_tokens", 0) or 0) + ) + result["output_tokens"] = int(usage.get("output_tokens", 0) or 0) + model_usage = envelope.get("modelUsage") or {} + result["model"] = next(iter(model_usage), "claude-code-plan") + stop_reason = envelope.get("stop_reason", "") + result["finish_reason"] = "length" if stop_reason == "max_tokens" else "stop" + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + "[graphify] claude-cli returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192) -> dict: """Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain.""" try: @@ -471,7 +546,7 @@ def extract_files_direct( file=sys.stderr, ) key = "ollama" - if not key and backend != "bedrock": + if not key and backend not in ("bedrock", "claude-cli"): raise ValueError( f"No API key for backend '{backend}'. " f"Set {_format_backend_env_keys(backend)} or pass api_key=." @@ -482,6 +557,8 @@ def extract_files_direct( if backend == "claude": return _call_claude(key, mdl, user_msg, max_tokens=max_out) + if backend == "claude-cli": + return _call_claude_cli(user_msg, max_tokens=max_out) if backend == "bedrock": return _call_bedrock(mdl, user_msg, max_tokens=max_out) return _call_openai_compat( @@ -794,6 +871,10 @@ def extract_corpus_parallel( # responses after 3-4 chunks (#798). Force serial unless the user opts in. if backend == "ollama" and os.environ.get("GRAPHIFY_OLLAMA_PARALLEL", "").strip() != "1": max_concurrency = 1 + # claude-cli shells out to a Claude Code session; parallel subprocesses conflict + # over session state. Force serial unless the user explicitly opts in. + if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 workers = max(1, min(max_concurrency, total)) if workers == 1: # Avoid thread pool overhead for single-worker runs (and keep @@ -852,7 +933,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: ollama_url = os.environ.get("OLLAMA_BASE_URL", cfg.get("base_url", "")) _validate_ollama_base_url(ollama_url) key = "ollama" - if not key and backend != "bedrock": + if not key and backend not in ("bedrock", "claude-cli"): raise ValueError( f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}." ) @@ -871,6 +952,26 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: ) return resp.content[0].text if resp.content else "" + if backend == "claude-cli": + import shutil, subprocess + if shutil.which("claude") is None: + raise RuntimeError("Claude Code CLI not found on $PATH") + proc = subprocess.run( + ["claude", "-p", "--output-format", "json", "--no-session-persistence"], + input=prompt, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}") + try: + envelope = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"claude -p produced unparseable JSON envelope: {exc}") from exc + return envelope.get("result", "") + if backend == "bedrock": try: import boto3 diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py new file mode 100644 index 0000000..c368290 --- /dev/null +++ b/tests/test_claude_cli_backend.py @@ -0,0 +1,117 @@ +"""Tests for the `claude-cli` backend (#855/#856). + +Mocks subprocess.run + shutil.which so the suite runs on CI without +the `claude` binary or a live network call. +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from graphify import llm + +_ENVELOPE = { + "type": "result", + "subtype": "success", + "is_error": False, + "result": json.dumps({ + "nodes": [ + {"id": "foo_module", "label": "Foo", "file_type": "document", "source_file": "foo.md"}, + {"id": "foo_greet", "label": "greet", "file_type": "code", "source_file": "foo.md"}, + ], + "edges": [ + {"source": "foo_module", "target": "foo_greet", + "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0}, + ], + "hyperedges": [], + "input_tokens": 0, + "output_tokens": 0, + }), + "stop_reason": "end_turn", + "usage": { + "input_tokens": 6, + "output_tokens": 11, + "cache_read_input_tokens": 17837, + "cache_creation_input_tokens": 30800, + }, + "modelUsage": {"claude-opus-4-7[1m]": {"inputTokens": 6, "outputTokens": 11}}, +} + + +@pytest.fixture +def fake_claude(monkeypatch): + completed = MagicMock(returncode=0, stdout=json.dumps(_ENVELOPE), stderr="") + monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False) + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed) as run: + yield run + + +def test_returns_parsed_nodes_and_edges(fake_claude): + result = llm._call_claude_cli("dummy", max_tokens=8192) + assert len(result["nodes"]) == 2 + assert len(result["edges"]) == 1 + + +def test_token_accounting_includes_cache(fake_claude): + result = llm._call_claude_cli("dummy", max_tokens=8192) + assert result["input_tokens"] == 6 + 17837 + 30800 + assert result["output_tokens"] == 11 + assert result["model"] == "claude-opus-4-7[1m]" + assert result["finish_reason"] == "stop" + + +def test_finish_reason_length_on_max_tokens(monkeypatch): + envelope = dict(_ENVELOPE, stop_reason="max_tokens") + completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="") + monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False) + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed): + result = llm._call_claude_cli("dummy", max_tokens=8192) + assert result["finish_reason"] == "length" + + +def test_raises_when_cli_missing(): + with patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="Claude Code CLI not found"): + llm._call_claude_cli("dummy", max_tokens=8192) + + +def test_raises_on_nonzero_exit(): + completed = MagicMock(returncode=2, stdout="", stderr="auth failed") + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="exited 2"): + llm._call_claude_cli("dummy", max_tokens=8192) + + +def test_raises_on_garbage_envelope(): + completed = MagicMock(returncode=0, stdout="not json", stderr="") + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="unparseable JSON envelope"): + llm._call_claude_cli("dummy", max_tokens=8192) + + +def test_extract_files_direct_dispatches_to_claude_cli(tmp_path, fake_claude): + f = tmp_path / "foo.md" + f.write_text("# Foo\n\nThe greet() helper formats a name.\n") + result = llm.extract_files_direct(files=[f], backend="claude-cli", root=tmp_path) + assert fake_claude.called + assert len(result["nodes"]) == 2 + + +def test_backend_registered_with_zero_cost(): + assert "claude-cli" in llm.BACKENDS + pricing = llm.BACKENDS["claude-cli"]["pricing"] + assert pricing["input"] == 0.0 + assert pricing["output"] == 0.0 + assert llm.estimate_cost("claude-cli", 1_000_000, 1_000_000) == 0.0 + + +def test_no_session_persistence_flag_in_subprocess(fake_claude): + llm._call_claude_cli("dummy", max_tokens=8192) + call_args = fake_claude.call_args[0][0] + assert "--no-session-persistence" in call_args