From 4a1cd5dc16849e5bbc22ed229523bbc6f01b91a4 Mon Sep 17 00:00:00 2001 From: one-kash <26795040+one-kash@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:41:15 +0000 Subject: [PATCH 1/4] 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 https://github.com/github/spec-kit/issues/550 --- src/specify_cli/__init__.py | 14 ++++-- tests/test_check_tool.py | 95 +++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/test_check_tool.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d78609ad6..384c0e6b4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -345,6 +345,7 @@ def _build_ai_assistant_help() -> str: SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ @@ -605,13 +606,16 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: Returns: True if tool is found, False otherwise """ - # Special handling for Claude CLI after `claude migrate-installer` + # Special handling for Claude CLI local installs # See: https://github.com/github/spec-kit/issues/123 - # The migrate-installer command REMOVES the original executable from PATH - # and creates an alias at ~/.claude/local/claude instead - # This path should be prioritized over other claude executables in PATH + # See: https://github.com/github/spec-kit/issues/550 + # Claude Code can be installed in two local paths: + # 1. ~/.claude/local/claude (after `claude migrate-installer`) + # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) + # Neither path may be on the system PATH, so we check them explicitly. if tool == "claude": - if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file(): + if (CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file()) or \ + (CLAUDE_NPM_LOCAL_PATH.exists() and CLAUDE_NPM_LOCAL_PATH.is_file()): if tracker: tracker.complete(tool, "available") return True diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py new file mode 100644 index 000000000..daa919f0e --- /dev/null +++ b/tests/test_check_tool.py @@ -0,0 +1,95 @@ +"""Tests for check_tool() — Claude Code CLI detection across install methods. + +Covers issue https://github.com/github/spec-kit/issues/550: + `specify check` reports "Claude Code CLI (not found)" even when claude is + installed via npm-local (the default `claude` installer path). +""" + +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from specify_cli import check_tool + + +class TestCheckToolClaude: + """Claude CLI detection must work for all install methods.""" + + def test_detected_via_migrate_installer_path(self, tmp_path): + """claude migrate-installer puts binary at ~/.claude/local/claude.""" + fake_claude = tmp_path / "claude" + fake_claude.touch() + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_npm_local_path(self, tmp_path): + """npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + # Neither the migrate-installer path nor PATH has claude + fake_migrate = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_path(self): + """claude on PATH (global npm install) should still work.""" + fake_missing = Path("/nonexistent/claude") + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value="/usr/local/bin/claude"): + assert check_tool("claude") is True + + def test_not_found_when_nowhere(self): + """Should return False when claude is genuinely not installed.""" + fake_missing = Path("/nonexistent/claude") + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is False + + def test_tracker_updated_on_npm_local_detection(self, tmp_path): + """StepTracker should be marked 'available' for npm-local installs.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + fake_missing = Path("/nonexistent/claude") + tracker = MagicMock() + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + result = check_tool("claude", tracker=tracker) + + assert result is True + tracker.complete.assert_called_once_with("claude", "available") + + +class TestCheckToolOther: + """Non-Claude tools should be unaffected by the fix.""" + + def test_git_detected_via_path(self): + with patch("shutil.which", return_value="/usr/bin/git"): + assert check_tool("git") is True + + def test_missing_tool(self): + with patch("shutil.which", return_value=None): + assert check_tool("nonexistent-tool") is False + + def test_kiro_fallback(self): + """kiro-cli detection should try both kiro-cli and kiro.""" + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + with patch("shutil.which", side_effect=fake_which): + assert check_tool("kiro-cli") is True \ No newline at end of file From 3ac7f2a7472145b99cfca1adec94c030879856ed Mon Sep 17 00:00:00 2001 From: one-kash <26795040+one-kash@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:17:47 +0000 Subject: [PATCH 2/4] Address Copilot review feedback - Remove unused pytest import from test_check_tool.py - Use tmp_path instead of hardcoded /nonexistent/claude for hermetic tests - Simplify redundant exists() + is_file() to just is_file() AI-assisted: Changes applied with Claude Code. --- src/specify_cli/__init__.py | 3 +-- tests/test_check_tool.py | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 384c0e6b4..05898f58d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -614,8 +614,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) # Neither path may be on the system PATH, so we check them explicitly. if tool == "claude": - if (CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file()) or \ - (CLAUDE_NPM_LOCAL_PATH.exists() and CLAUDE_NPM_LOCAL_PATH.is_file()): + if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): if tracker: tracker.complete(tool, "available") return True diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index daa919f0e..78d882c7e 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -8,8 +8,6 @@ from pathlib import Path from unittest.mock import patch, MagicMock -import pytest - from specify_cli import check_tool @@ -39,18 +37,18 @@ def test_detected_via_npm_local_path(self, tmp_path): patch("shutil.which", return_value=None): assert check_tool("claude") is True - def test_detected_via_path(self): + def test_detected_via_path(self, tmp_path): """claude on PATH (global npm install) should still work.""" - fake_missing = Path("/nonexistent/claude") + fake_missing = tmp_path / "nonexistent" / "claude" with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ patch("shutil.which", return_value="/usr/local/bin/claude"): assert check_tool("claude") is True - def test_not_found_when_nowhere(self): + def test_not_found_when_nowhere(self, tmp_path): """Should return False when claude is genuinely not installed.""" - fake_missing = Path("/nonexistent/claude") + fake_missing = tmp_path / "nonexistent" / "claude" with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ @@ -63,7 +61,7 @@ def test_tracker_updated_on_npm_local_detection(self, tmp_path): fake_npm_claude.parent.mkdir(parents=True) fake_npm_claude.touch() - fake_missing = Path("/nonexistent/claude") + fake_missing = tmp_path / "nonexistent" / "claude" tracker = MagicMock() with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ From 1e015a9d598151d49026da34bfa7ce9763745a4a Mon Sep 17 00:00:00 2001 From: Kash <26795040+one-kash@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:30:58 -0500 Subject: [PATCH 3/4] Update tests/test_check_tool.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_check_tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 78d882c7e..6f474d602 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -19,7 +19,11 @@ def test_detected_via_migrate_installer_path(self, tmp_path): fake_claude = tmp_path / "claude" fake_claude.touch() + # Ensure npm-local path is missing so we only exercise migrate-installer path + fake_missing = tmp_path / "nonexistent" / "claude" + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ patch("shutil.which", return_value=None): assert check_tool("claude") is True From 05317977cdd36d77a225a975fa87e48dcc105049 Mon Sep 17 00:00:00 2001 From: Kash <26795040+one-kash@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:31:09 -0500 Subject: [PATCH 4/4] Update tests/test_check_tool.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_check_tool.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 6f474d602..0eb267ba2 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -5,7 +5,6 @@ installed via npm-local (the default `claude` installer path). """ -from pathlib import Path from unittest.mock import patch, MagicMock from specify_cli import check_tool