Summary
On Windows, claude-cli resolution prefers shutil.which("claude.cmd") unconditionally, with no fallback and no validation. When npm's claude.cmd is present but its bundled binary is not, every call fails and graphify label degrades silently to Community N placeholders while exiting 0 - even though a working native claude.exe is on PATH.
This is the mirror image of #1288 / #1268 / #1072. Those fixed "bare claude cannot be executed" by explicitly resolving claude.cmd, under the environment noted in #1288: "Claude Code CLI installed via npm (claude.cmd on PATH, no claude.exe)". With the native installer that assumption inverts - claude.exe is the real CLI and any claude.cmd on PATH may be a stale or broken npm leftover.
Environment
- graphify 0.9.36 / graphifyy 0.9.37
- Windows 11 x64, Python 3.14
- Claude Code installed natively (
~/.local/bin/claude.exe, works), plus a broken npm global install
What happens
[graphify label] batch 1/7 (100 communities) failed: claude -p exited 1: This version of
%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe is not compatible with
the version of Windows you're running.
... x7 ...
[graphify label] warning: community labeling failed (...); using Community N placeholders.
Done - 690 communities. GRAPH_REPORT.md and graph.json updated.
Exit code 0. graph.json and GRAPH_REPORT.md are rewritten anyway - label re-clusters regardless - so a run that named nothing produces a large diff indistinguishable from a successful one. On a tracked graphify-out/, that is ~61k inserted / ~61k deleted lines of pure churn for zero naming benefit.
Root cause
llm.py (both the _call_claude_cli path ~L1449-1463 and the _call_llm path ~L2572-2584):
claude_cmd = "claude"
if platform.system() == "Windows":
cmd_path = shutil.which("claude.cmd")
if cmd_path:
claude_cmd = cmd_path # taken unconditionally
elif shutil.which("claude") is None:
raise RuntimeError("Claude Code CLI not found on $PATH")
claude.cmd wins even when it is broken and a working claude.exe sits earlier on PATH.
Why npm's can be broken: the package installs a ~500-byte placeholder at bin/claude.exe whenever postinstall did not fetch the platform-native optional dependency (--ignore-scripts, --omit=optional, some pnpm configs). It is a shell script, not a PE:
echo "Error: claude native binary not installed." >&2
echo "Either postinstall did not run (--ignore-scripts, some pnpm configs)" >&2
echo "or the platform-native optional dependency was not downloaded (--omit=optional)." >&2
Windows refuses to execute a text file as .exe, hence OS error 216.
Two suggested fixes
1. Validate, or fall back. Prefer claude.cmd, but do not trust it blindly:
candidates = []
if platform.system() == "Windows":
candidates += [shutil.which("claude.cmd"), shutil.which("claude.exe")]
candidates.append(shutil.which("claude"))
for cand in filter(None, candidates):
try:
probe = subprocess.run([cand, "--version"], capture_output=True,
text=True, timeout=30, **_no_window_kwargs())
if probe.returncode == 0:
claude_cmd = cand
break
except OSError:
continue
else:
raise RuntimeError("Claude Code CLI not found or not executable on $PATH")
A one-time --version probe is cheap next to a labeling run, and it fixes every variant of this class - broken shim, stale shim, .ps1-only, wrong-arch binary.
2. Do not exit 0 after labeling failed. Even with resolution fixed, "every batch failed" should not look like success. #1288 flagged this too. Either exit non-zero when all batches fail, or skip rewriting graph.json when zero labels were produced, so the placeholder fallback cannot masquerade as a completed run. The second is arguably more valuable: it prevents a no-op run from producing a huge, misleading diff.
Workaround for anyone hitting this
Put a claude.cmd earlier on PATH than %APPDATA%\npm that forwards to the working binary:
@echo off
"%~dp0claude.exe" %*
exit /b %ERRORLEVEL%
Or remove the broken package with npm uninstall -g @anthropic-ai/claude-code. Note it can come back - ours was silently reinstalled between runs, which is why the shim is the durable option.
Summary
On Windows,
claude-cliresolution prefersshutil.which("claude.cmd")unconditionally, with no fallback and no validation. When npm'sclaude.cmdis present but its bundled binary is not, every call fails andgraphify labeldegrades silently toCommunity Nplaceholders while exiting 0 - even though a working nativeclaude.exeis on PATH.This is the mirror image of #1288 / #1268 / #1072. Those fixed "bare
claudecannot be executed" by explicitly resolvingclaude.cmd, under the environment noted in #1288: "Claude Code CLI installed via npm (claude.cmdon PATH, noclaude.exe)". With the native installer that assumption inverts -claude.exeis the real CLI and anyclaude.cmdon PATH may be a stale or broken npm leftover.Environment
~/.local/bin/claude.exe, works), plus a broken npm global installWhat happens
Exit code 0.
graph.jsonandGRAPH_REPORT.mdare rewritten anyway -labelre-clusters regardless - so a run that named nothing produces a large diff indistinguishable from a successful one. On a trackedgraphify-out/, that is ~61k inserted / ~61k deleted lines of pure churn for zero naming benefit.Root cause
llm.py(both the_call_claude_clipath ~L1449-1463 and the_call_llmpath ~L2572-2584):claude.cmdwins even when it is broken and a workingclaude.exesits earlier on PATH.Why npm's can be broken: the package installs a ~500-byte placeholder at
bin/claude.exewhenever postinstall did not fetch the platform-native optional dependency (--ignore-scripts,--omit=optional, some pnpm configs). It is a shell script, not a PE:Windows refuses to execute a text file as
.exe, hence OS error 216.Two suggested fixes
1. Validate, or fall back. Prefer
claude.cmd, but do not trust it blindly:A one-time
--versionprobe is cheap next to a labeling run, and it fixes every variant of this class - broken shim, stale shim,.ps1-only, wrong-arch binary.2. Do not exit 0 after labeling failed. Even with resolution fixed, "every batch failed" should not look like success. #1288 flagged this too. Either exit non-zero when all batches fail, or skip rewriting
graph.jsonwhen zero labels were produced, so the placeholder fallback cannot masquerade as a completed run. The second is arguably more valuable: it prevents a no-op run from producing a huge, misleading diff.Workaround for anyone hitting this
Put a
claude.cmdearlier on PATH than%APPDATA%\npmthat forwards to the working binary:Or remove the broken package with
npm uninstall -g @anthropic-ai/claude-code. Note it can come back - ours was silently reinstalled between runs, which is why the shim is the durable option.