Merge pull request #1266 from PowPickles/fix/claude-cli-windows-spawn

fix: claude-cli backend works headlessly on Windows npm installs
This commit is contained in:
Safi
2026-06-11 23:22:41 +01:00
committed by GitHub
2 changed files with 54 additions and 3 deletions
+27 -3
View File
@@ -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]}")
+27
View File
@@ -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"