Skip to content

Commit 4a1cd5d

Browse files
committed
Fix Claude Code CLI detection for npm-local installs
`specify check` reports "Claude Code CLI (not found)" for users who installed Claude Code via npm-local (the default installer path, common with nvm). The binary lives at ~/.claude/local/node_modules/.bin/claude which was not checked. Add CLAUDE_NPM_LOCAL_PATH as a second well-known location alongside the existing migrate-installer path. Fixes #550
1 parent 2c2fea8 commit 4a1cd5d

2 files changed

Lines changed: 104 additions & 5 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ def _build_ai_assistant_help() -> str:
345345
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
346346

347347
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
348+
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
348349

349350
BANNER = """
350351
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
@@ -605,13 +606,16 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool:
605606
Returns:
606607
True if tool is found, False otherwise
607608
"""
608-
# Special handling for Claude CLI after `claude migrate-installer`
609+
# Special handling for Claude CLI local installs
609610
# See: https://github.com/github/spec-kit/issues/123
610-
# The migrate-installer command REMOVES the original executable from PATH
611-
# and creates an alias at ~/.claude/local/claude instead
612-
# This path should be prioritized over other claude executables in PATH
611+
# See: https://github.com/github/spec-kit/issues/550
612+
# Claude Code can be installed in two local paths:
613+
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
614+
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
615+
# Neither path may be on the system PATH, so we check them explicitly.
613616
if tool == "claude":
614-
if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():
617+
if (CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file()) or \
618+
(CLAUDE_NPM_LOCAL_PATH.exists() and CLAUDE_NPM_LOCAL_PATH.is_file()):
615619
if tracker:
616620
tracker.complete(tool, "available")
617621
return True

tests/test_check_tool.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for check_tool() — Claude Code CLI detection across install methods.
2+
3+
Covers issue https://github.com/github/spec-kit/issues/550:
4+
`specify check` reports "Claude Code CLI (not found)" even when claude is
5+
installed via npm-local (the default `claude` installer path).
6+
"""
7+
8+
from pathlib import Path
9+
from unittest.mock import patch, MagicMock
10+
11+
import pytest
12+
13+
from specify_cli import check_tool
14+
15+
16+
class TestCheckToolClaude:
17+
"""Claude CLI detection must work for all install methods."""
18+
19+
def test_detected_via_migrate_installer_path(self, tmp_path):
20+
"""claude migrate-installer puts binary at ~/.claude/local/claude."""
21+
fake_claude = tmp_path / "claude"
22+
fake_claude.touch()
23+
24+
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
25+
patch("shutil.which", return_value=None):
26+
assert check_tool("claude") is True
27+
28+
def test_detected_via_npm_local_path(self, tmp_path):
29+
"""npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude."""
30+
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
31+
fake_npm_claude.parent.mkdir(parents=True)
32+
fake_npm_claude.touch()
33+
34+
# Neither the migrate-installer path nor PATH has claude
35+
fake_migrate = tmp_path / "nonexistent" / "claude"
36+
37+
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
38+
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
39+
patch("shutil.which", return_value=None):
40+
assert check_tool("claude") is True
41+
42+
def test_detected_via_path(self):
43+
"""claude on PATH (global npm install) should still work."""
44+
fake_missing = Path("/nonexistent/claude")
45+
46+
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
47+
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
48+
patch("shutil.which", return_value="/usr/local/bin/claude"):
49+
assert check_tool("claude") is True
50+
51+
def test_not_found_when_nowhere(self):
52+
"""Should return False when claude is genuinely not installed."""
53+
fake_missing = Path("/nonexistent/claude")
54+
55+
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
56+
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
57+
patch("shutil.which", return_value=None):
58+
assert check_tool("claude") is False
59+
60+
def test_tracker_updated_on_npm_local_detection(self, tmp_path):
61+
"""StepTracker should be marked 'available' for npm-local installs."""
62+
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
63+
fake_npm_claude.parent.mkdir(parents=True)
64+
fake_npm_claude.touch()
65+
66+
fake_missing = Path("/nonexistent/claude")
67+
tracker = MagicMock()
68+
69+
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
70+
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
71+
patch("shutil.which", return_value=None):
72+
result = check_tool("claude", tracker=tracker)
73+
74+
assert result is True
75+
tracker.complete.assert_called_once_with("claude", "available")
76+
77+
78+
class TestCheckToolOther:
79+
"""Non-Claude tools should be unaffected by the fix."""
80+
81+
def test_git_detected_via_path(self):
82+
with patch("shutil.which", return_value="/usr/bin/git"):
83+
assert check_tool("git") is True
84+
85+
def test_missing_tool(self):
86+
with patch("shutil.which", return_value=None):
87+
assert check_tool("nonexistent-tool") is False
88+
89+
def test_kiro_fallback(self):
90+
"""kiro-cli detection should try both kiro-cli and kiro."""
91+
def fake_which(name):
92+
return "/usr/bin/kiro" if name == "kiro" else None
93+
94+
with patch("shutil.which", side_effect=fake_which):
95+
assert check_tool("kiro-cli") is True

0 commit comments

Comments
 (0)