Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
68 changes: 68 additions & 0 deletions tests/test_claude_cli_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"), \
Expand Down Expand Up @@ -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
Expand Down