diff --git a/graphify/llm.py b/graphify/llm.py index 7c355102..2f56b7a7 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -321,6 +321,18 @@ def _bedrock_inference_config(max_tokens: int, model: str = "") -> dict: return cfg +def _no_window_kwargs() -> dict: + """subprocess kwargs that suppress the console window claude.cmd would + otherwise pop on Windows. A labeling/extraction run spawns one `claude -p` + per batch — with Windows Terminal as the default terminal each spawn + becomes a visible window that appears and vanishes for the duration of the + model call. CREATE_NO_WINDOW keeps the children invisible; no-op elsewhere.""" + import subprocess + if sys.platform == "win32": + return {"creationflags": subprocess.CREATE_NO_WINDOW} + return {} + + 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() @@ -1095,6 +1107,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 timeout=_resolve_api_timeout(), check=False, + **_no_window_kwargs(), ) if proc.returncode != 0: raise RuntimeError( @@ -1743,17 +1756,28 @@ 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: + import platform, shutil, subprocess + # Mirror the extraction-path resolution: on Windows the npm shim is + # claude.cmd, which CreateProcess can't resolve from a bare "claude" + # (PATHEXT doesn't apply), so pass the resolved .cmd path explicitly. + claude_cmd = "claude" + if platform.system() == "Windows": + cmd_path = shutil.which("claude.cmd") + if cmd_path: + claude_cmd = cmd_path + elif shutil.which("claude") is 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") proc = subprocess.run( - ["claude", "-p", "--output-format", "json", "--no-session-persistence"], + [claude_cmd, "-p", "--output-format", "json", "--no-session-persistence"], input=prompt, capture_output=True, text=True, encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 timeout=_resolve_api_timeout(), check=False, + **_no_window_kwargs(), ) if proc.returncode != 0: raise RuntimeError(f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}") diff --git a/tests/test_claude_cli_backend.py b/tests/test_claude_cli_backend.py index 719e75b1..29bebf8a 100644 --- a/tests/test_claude_cli_backend.py +++ b/tests/test_claude_cli_backend.py @@ -228,3 +228,30 @@ def test_call_llm_claude_cli_branch_honours_timeout(monkeypatch, fake_claude): monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "30") llm._call_llm(prompt="x", backend="claude-cli", max_tokens=10) assert fake_claude.call_args.kwargs["timeout"] == 30.0 + + +def test_simple_completion_resolves_cmd_shim_on_windows(monkeypatch): + """The label/_simple_completion path must spawn the resolved claude.cmd on + Windows; a bare "claude" fails CreateProcess (WinError 2) under npm installs.""" + import json as _json + from unittest.mock import patch, MagicMock + + captured = {} + + def fake_run(args, **kwargs): + captured["argv0"] = args[0] + proc = MagicMock() + proc.returncode = 0 + proc.stdout = _json.dumps({"result": "ok"}) + return proc + + def fake_which(name): + return r"C:\npm\claude.cmd" if name == "claude.cmd" else r"C:\npm\claude" + + with patch("platform.system", return_value="Windows"), \ + patch("shutil.which", side_effect=fake_which), \ + patch("subprocess.run", side_effect=fake_run): + out = llm._call_llm("hi", backend="claude-cli") + + assert out == "ok" + assert captured["argv0"] == r"C:\npm\claude.cmd"