From 96585badd0c6b7af5637b1fa4eb965b92373f586 Mon Sep 17 00:00:00 2001 From: Skyler Southern <274284809+PowPickles@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:58:39 -0400 Subject: [PATCH] fix: claude-cli backend works headlessly on Windows npm installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Windows fixes for the claude-cli backend: 1. _call_llm (the label/community-naming path) spawned a bare ["claude", ...], which CreateProcess cannot resolve to the npm claude.cmd shim (PATHEXT does not apply) — every labeling batch failed with WinError 2. Mirror the extraction path's resolution: prefer shutil.which("claude.cmd") and pass the resolved path. Regression test included. 2. Both claude -p spawn sites now pass CREATE_NO_WINDOW on Windows. Without it, each per-batch claude.cmd spawn allocates a console — with Windows Terminal as the default terminal, a labeling run pops one visible window per batch on the user's desktop for the duration of the model call. Co-Authored-By: Claude Fable 5 --- graphify/llm.py | 30 +++++++++++++++++++++++++++--- tests/test_claude_cli_backend.py | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/graphify/llm.py b/graphify/llm.py index d29857a5..fc5eec66 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -322,6 +322,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() @@ -1064,6 +1076,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( @@ -1718,17 +1731,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"