diff --git a/graphify/llm.py b/graphify/llm.py index 46a4785d3..1765ee63f 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -400,6 +400,26 @@ def _no_window_kwargs() -> dict: return {} +def _claude_cli_bare_args() -> list[str]: + """``--bare`` for `claude -p`, opt-in via GRAPHIFY_CLAUDE_CLI_BARE=1. + + Each `claude -p` spawn (one per extraction chunk, plus one per community + label) boots a full Claude Code session with the user's global config, + including SessionStart/SessionEnd hooks and plugins. For an unattended + extraction pipeline this is pure overhead, and worse: a failing or + cancelled SessionEnd hook can flip the process exit code to nonzero, so a + chunk whose extraction actually succeeded gets recorded as a hook + failure instead (#2693). `--bare` skips auto-discovery of hooks, skills, + plugins, MCP servers, auto memory, and CLAUDE.md for that one spawn — it + does not touch the user's global config, and does not affect the + subscription/OAuth auth graphify's claude-cli backend relies on. + + Opt-in and off by default: existing users may rely on their hooks firing + for every session, including these ones. + """ + return ["--bare"] if os.environ.get("GRAPHIFY_CLAUDE_CLI_BARE", "").strip() == "1" else [] + + def _resolve_api_timeout(default: float = 600.0) -> float: """Honour GRAPHIFY_API_TIMEOUT env var override, else use default (seconds).""" raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip() @@ -1540,6 +1560,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo claude_cmd, "-p", "--output-format", "json", "--no-session-persistence", + *_claude_cli_bare_args(), *add_dir_args, ] # claude-cli defaults to Opus, which is overkill for the structured-JSON @@ -2618,7 +2639,10 @@ def _rec(inp, out) -> None: raise RuntimeError("Claude Code CLI not found on $PATH") elif shutil.which("claude") is None: raise RuntimeError("Claude Code CLI not found on $PATH") - cli_args = [claude_cmd, "-p", "--output-format", "json", "--no-session-persistence"] + cli_args = [ + claude_cmd, "-p", "--output-format", "json", "--no-session-persistence", + *_claude_cli_bare_args(), + ] if model is not None: cli_args.extend(["--model", mdl]) proc = subprocess.run( diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py index 4f5187ab4..c3dd21ddb 100644 --- a/tests/test_claude_cli_backend.py +++ b/tests/test_claude_cli_backend.py @@ -172,6 +172,31 @@ def test_call_llm_success_still_returns_result_text(): assert llm._call_llm("dummy", backend="claude-cli") == "a fine label" +def test_call_llm_bare_flag_opt_in(monkeypatch): + """#2693: the labeling-call spawn (_call_llm's own claude-cli branch, used + for community labels) honours GRAPHIFY_CLAUDE_CLI_BARE the same as the + extraction spawn (_call_claude_cli).""" + envelope = dict(_ENVELOPE, result="a fine label") + completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="") + monkeypatch.setenv("GRAPHIFY_CLAUDE_CLI_BARE", "1") + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed) as run: + llm._call_llm("dummy", backend="claude-cli") + argv = run.call_args.args[0] + assert "--bare" in argv + + +def test_call_llm_bare_flag_absent_by_default(monkeypatch): + envelope = dict(_ENVELOPE, result="a fine label") + completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="") + monkeypatch.delenv("GRAPHIFY_CLAUDE_CLI_BARE", raising=False) + with patch("shutil.which", return_value="/fake/bin/claude"), \ + patch("subprocess.run", return_value=completed) as run: + llm._call_llm("dummy", backend="claude-cli") + argv = run.call_args.args[0] + assert "--bare" not in argv + + def test_raises_on_garbage_envelope(): completed = MagicMock(returncode=0, stdout="not json", stderr="") with patch("shutil.which", return_value="/fake/bin/claude"), \ @@ -202,6 +227,49 @@ def test_no_session_persistence_flag_in_subprocess(fake_claude): assert "--no-session-persistence" in call_args +# ---------- GRAPHIFY_CLAUDE_CLI_BARE opt-in (#2693) ---------- +# Each `claude -p` spawn boots a full Claude Code session with the user's +# global config, including SessionStart/SessionEnd hooks. A failing/cancelled +# hook can flip the exit code to nonzero for a chunk whose extraction actually +# succeeded. --bare skips hook/skill/plugin/MCP/CLAUDE.md auto-discovery for +# that one spawn; it must stay off by default so existing hook setups keep +# firing. + + +def test_bare_flag_absent_by_default(fake_claude, monkeypatch): + monkeypatch.delenv("GRAPHIFY_CLAUDE_CLI_BARE", raising=False) + llm._call_claude_cli("dummy", max_tokens=8192) + argv = fake_claude.call_args.args[0] + assert "--bare" not in argv + + +def test_bare_flag_present_when_opted_in(fake_claude, monkeypatch): + monkeypatch.setenv("GRAPHIFY_CLAUDE_CLI_BARE", "1") + llm._call_claude_cli("dummy", max_tokens=8192) + argv = fake_claude.call_args.args[0] + assert "--bare" in argv + + +def test_bare_flag_absent_for_any_non_one_value(fake_claude, monkeypatch): + """Only the exact value "1" opts in — matches GRAPHIFY_CLAUDE_CLI_PARALLEL's + convention elsewhere in this module.""" + monkeypatch.setenv("GRAPHIFY_CLAUDE_CLI_BARE", "true") + llm._call_claude_cli("dummy", max_tokens=8192) + argv = fake_claude.call_args.args[0] + assert "--bare" not in argv + + +def test_bare_args_helper(): + import os as _os + _os.environ.pop("GRAPHIFY_CLAUDE_CLI_BARE", None) + assert llm._claude_cli_bare_args() == [] + _os.environ["GRAPHIFY_CLAUDE_CLI_BARE"] = "1" + try: + assert llm._claude_cli_bare_args() == ["--bare"] + finally: + _os.environ.pop("GRAPHIFY_CLAUDE_CLI_BARE", None) + + # ---------- extraction instructions delivered in the user turn ---------- # Newer Claude Code CLIs (>= ~2.1) do not honour a --system-prompt that asks # for raw JSON: they keep their coding-agent context and reply conversationally