From 4d2ca3461ad6ea89102b479d19a04535bbdd0cb4 Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Sat, 26 Sep 2026 20:38:23 +0530 Subject: [PATCH 1/6] Resolve agent CLI executables in one place so checks match dispatch check_tool had per-tool special cases for claude, kiro-cli, rovodev and docker-agent. Dispatch did not use them: build_exec_args calls _resolve_executable(), which returns the integration key unless an environment variable overrides it. So a Claude install under ~/.claude/local, or a machine carrying only the legacy kiro binary, passed the preflight check and then failed to launch. The per-tool knowledge now lives on the integrations. ClaudeIntegration and KiroCliIntegration override _resolve_executable(), so the check and the argv dispatch builds come from the same call. IntegrationBase grows is_cli_available(), which resolves the executable and then checks the path directly when it contains a separator, or looks the bare name up on PATH. DockerAgentIntegration overrides that, because it is a docker CLI plugin rather than an executable on PATH. check_tool asks the integration when one is registered and keeps the plain PATH lookup for non-integration tools such as git. Fixing resolution rather than the boolean also repairs dispatch without editing the workflow steps: they already substitute shutil.which(exec_args[0]) into argv, and exec_args[0] is now the resolved path. Two tests assert that a tool reported as available yields an argv[0] that exists, one for the Claude local install and one for the legacy kiro binary. Both fail without the source change. --- src/specify_cli/_utils.py | 32 ++++--------- src/specify_cli/integrations/base.py | 15 ++++++ .../integrations/claude/__init__.py | 19 ++++++++ .../integrations/docker_agent/__init__.py | 10 ++++ .../integrations/kiro_cli/__init__.py | 16 +++++++ tests/integrations/test_base.py | 48 +++++++++++++++++++ 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index 300ca4ff58..faa3cf908a 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -156,28 +156,16 @@ def check_tool(tool: str, tracker=None) -> bool: Returns: True if tool is found, False otherwise """ - # Special handling for Claude CLI local installs - # See: https://github.com/github/spec-kit/issues/123 - # 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.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): - if tracker: - tracker.complete(tool, "available") - return True - - # Per-integration executable resolution. - if tool == "kiro-cli": - # Kiro currently supports both executable names. Prefer kiro-cli and - # accept kiro as a compatibility fallback. - found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None - elif tool == "rovodev": - found = shutil.which("acli") is not None - elif tool == "docker-agent": - found = docker_agent_command() is not None + # A registered integration owns how its CLI is located, so preflight asks + # the same object dispatch will use instead of repeating per-tool rules + # here. Imported inside the function because the integrations package + # imports this module at import time. Plain tools such as git are not + # integrations and stay a straight PATH lookup. + from .integrations import get_integration + + integration = get_integration(tool) + if integration is not None: + found = integration.is_cli_available() else: found = shutil.which(tool) is not None diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e698b9e289..f5c29b3eef 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -312,6 +312,21 @@ def _resolve_executable(self) -> str: override = os.environ.get(env_name, "").strip() return override if override else self.key + def is_cli_available(self) -> bool: + """Report whether this integration's CLI can actually be launched. + + Resolves the same executable :meth:`dispatch_command` will run, so a + preflight check cannot report a tool as present under a name that + dispatch then fails to find. A resolved value containing a path + separator names an explicit location and is checked directly; a bare + name is looked up on PATH. + """ + executable = self._resolve_executable() + separators = [os.sep, os.altsep] if os.altsep else [os.sep] + if any(sep in executable for sep in separators): + return Path(executable).is_file() + return shutil.which(executable) is not None + def _apply_extra_args_env_var(self, args: list[str]) -> None: """Append `SPECKIT_INTEGRATION__EXTRA_ARGS` env-var value to *args*. diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 9a14cf50b0..651939c823 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations +import shutil from typing import Any from ..base import SkillsIntegration +from ... import _utils from ..._utils import dump_frontmatter # Mapping of command template stem → argument-hint text shown inline @@ -65,6 +67,23 @@ class ClaudeIntegration(SkillsIntegration): events_config_file = ".claude/settings.json" events_format = "json-nested" + def _resolve_executable(self) -> str: + """Resolve the Claude CLI, including installs that are not on PATH. + + ``claude migrate-installer`` and the npm-local installer place the + binary under ``~/.claude/local`` without adding it to PATH. Returning + that absolute path keeps availability checks and dispatch in agreement + (issues #123 and #550). An operator override or a PATH install still + wins where present. + """ + resolved = super()._resolve_executable() + if resolved != self.key or shutil.which(resolved): + return resolved + for candidate in (_utils.CLAUDE_LOCAL_PATH, _utils.CLAUDE_NPM_LOCAL_PATH): + if candidate.is_file(): + return str(candidate) + return resolved + @staticmethod def inject_argument_hint(content: str, hint: str) -> str: """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py index 939e35555b..a03e2bc799 100644 --- a/src/specify_cli/integrations/docker_agent/__init__.py +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -65,6 +65,16 @@ def _agent_command(self) -> list[str]: return [executable, "run"] return command + def is_cli_available(self) -> bool: + """Detect the standalone binary or the ``docker agent`` CLI plugin. + + Docker Agent is not a single executable on PATH, so the inherited + PATH lookup cannot answer this; the shared probe is authoritative. + """ + executable = self._resolve_executable() + probe = docker_agent_command(None if executable == self.key else executable) + return probe is not None + @classmethod def options(cls) -> list[IntegrationOption]: opts = super().options() diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4c90d030a1..95314d943e 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -1,5 +1,7 @@ """Kiro CLI integration.""" +import shutil + from ..base import MarkdownIntegration @@ -34,3 +36,17 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } + + def _resolve_executable(self) -> str: + """Resolve the Kiro CLI, accepting the legacy ``kiro`` executable. + + Kiro ships under both names and availability checks have long accepted + either, so dispatch has to resolve the same way. Otherwise a machine + with only the legacy binary passes preflight and then fails to launch. + """ + resolved = super()._resolve_executable() + if resolved != self.key: + return resolved + if shutil.which(resolved) is None and shutil.which("kiro"): + return "kiro" + return resolved diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 6383cead59..9f40ab6fac 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -2,11 +2,16 @@ import inspect import shlex +import shutil import sys +from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch import pytest +from specify_cli._utils import check_tool +from specify_cli.integrations import get_integration from specify_cli.integrations.base import ( IntegrationBase, IntegrationOption, @@ -766,3 +771,46 @@ def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path): assert sh_file.stat().st_mode & 0o111 # Negative: a non-script file is not made executable. assert not (txt_file.stat().st_mode & 0o111) + +class TestCliAvailabilityMatchesDispatch: + """A tool reported as available must be launchable under the same name. + + ``check_tool`` used to special-case a handful of CLIs while dispatch + resolved the executable separately. A Claude install that is not on PATH, + or a machine carrying only the legacy ``kiro`` binary, therefore passed + preflight and then failed with FileNotFoundError when the command ran. + """ + + def test_claude_local_install_resolves_to_the_path_preflight_accepted(self, tmp_path): + local_claude = tmp_path / "claude" + local_claude.write_text("#!/bin/sh\n") + + with ( + patch("shutil.which", return_value=None), + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", local_claude), + ): + integration = get_integration("claude") + resolved = integration._resolve_executable() + exec_args = integration.build_exec_args("hello") + + assert check_tool("claude") is True + assert resolved == str(local_claude) + # Dispatch runs this value; it has to exist, not just be a name. + assert Path(resolved).is_file() + # argv[0] is what actually reaches subprocess.run. + assert exec_args[0] == str(local_claude) + + def test_kiro_legacy_binary_resolves_to_the_name_preflight_accepted(self): + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + with patch("shutil.which", side_effect=fake_which): + integration = get_integration("kiro-cli") + resolved = integration._resolve_executable() + exec_args = integration.build_exec_args("hello") + + assert check_tool("kiro-cli") is True + assert resolved == "kiro" + assert shutil.which(resolved) is not None + # argv[0] is what actually reaches subprocess.run. + assert exec_args[0] == "kiro" \ No newline at end of file From ef09d756015f44a88ce65c14d1aa3c00c4f29674 Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Mon, 28 Sep 2026 21:46:54 +0530 Subject: [PATCH 2/6] fix(integrations): require explicit executable overrides to be runnable Two availability checks could report a tool as present and then fail at dispatch -- the preflight/dispatch mismatch this branch exists to remove. IntegrationBase.is_cli_available treated an explicit path as available on existence alone, so a present-but-non-executable file passed preflight and then failed at launch. The PATH branch already gets that test from shutil.which; apply it to the explicit-path branch too. DockerAgentIntegration.is_cli_available delegated to docker_agent_command, which shapes argv for a custom binary without probing it, so a nonexistent override reported available. Run the inherited check first when an override is set; the bare key still goes through the shared probe. Adds a regression test for each, and makes an existing claude test create an executable stub so it still reflects an installed CLI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 8 ++++- .../integrations/docker_agent/__init__.py | 15 +++++++-- tests/integrations/test_base.py | 30 ++++++++++++++++- .../test_integration_docker_agent.py | 32 +++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f5c29b3eef..ad7c7a130b 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -320,11 +320,17 @@ def is_cli_available(self) -> bool: dispatch then fails to find. A resolved value containing a path separator names an explicit location and is checked directly; a bare name is looked up on PATH. + + An explicit path must also carry the execute bit. Dispatch launches it + through :mod:`subprocess`, so a present-but-non-executable file would + pass preflight and then fail at launch — the same mismatch this method + exists to prevent. ``shutil.which`` already applies that test on the + PATH branch. """ executable = self._resolve_executable() separators = [os.sep, os.altsep] if os.altsep else [os.sep] if any(sep in executable for sep in separators): - return Path(executable).is_file() + return Path(executable).is_file() and os.access(executable, os.X_OK) return shutil.which(executable) is not None def _apply_extra_args_env_var(self, args: list[str]) -> None: diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py index a03e2bc799..7f070a8df2 100644 --- a/src/specify_cli/integrations/docker_agent/__init__.py +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -69,11 +69,20 @@ def is_cli_available(self) -> bool: """Detect the standalone binary or the ``docker agent`` CLI plugin. Docker Agent is not a single executable on PATH, so the inherited - PATH lookup cannot answer this; the shared probe is authoritative. + PATH lookup cannot answer this on its own; the shared probe decides + which command form is available. + + An explicit executable override is different. The probe deliberately + does not launch a custom binary, so it shapes argv for one without + establishing that it exists. The inherited check runs first in that + case, keeping preflight and dispatch in agreement. """ executable = self._resolve_executable() - probe = docker_agent_command(None if executable == self.key else executable) - return probe is not None + if executable == self.key: + return docker_agent_command(None) is not None + if not super().is_cli_available(): + return False + return docker_agent_command(executable) is not None @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 9f40ab6fac..62e233dbc1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -784,6 +784,8 @@ class TestCliAvailabilityMatchesDispatch: def test_claude_local_install_resolves_to_the_path_preflight_accepted(self, tmp_path): local_claude = tmp_path / "claude" local_claude.write_text("#!/bin/sh\n") + # An installed CLI is executable; availability now requires it. + local_claude.chmod(0o755) with ( patch("shutil.which", return_value=None), @@ -813,4 +815,30 @@ def fake_which(name): assert resolved == "kiro" assert shutil.which(resolved) is not None # argv[0] is what actually reaches subprocess.run. - assert exec_args[0] == "kiro" \ No newline at end of file + assert exec_args[0] == "kiro" + + @pytest.mark.skipif( + sys.platform == "win32", + reason="Windows has no POSIX execute bit; os.access(X_OK) is always true", + ) + def test_explicit_path_without_execute_bit_is_not_available( + self, monkeypatch, tmp_path + ): + # An explicit path that exists but cannot be executed passed preflight + # and then failed when dispatch launched it. Existence alone is not + # the question the caller is asking. + stub = tmp_path / "claude" + stub.write_text("#!/bin/sh\n") + stub.chmod(0o644) + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", str(stub)) + integration = get_integration("claude") + + # Dispatch would run this exact path, so preflight must judge it. + assert integration._resolve_executable() == str(stub) + assert integration.is_cli_available() is False + assert check_tool("claude") is False + + # Same path, now launchable: the only thing that changed is the bit. + stub.chmod(0o755) + assert integration.is_cli_available() is True + assert check_tool("claude") is True diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py index fa2207927f..9ac9ebc6bf 100644 --- a/tests/integrations/test_integration_docker_agent.py +++ b/tests/integrations/test_integration_docker_agent.py @@ -2,6 +2,7 @@ import pytest +from specify_cli._utils import docker_agent_command from specify_cli.integrations.docker_agent import DockerAgentIntegration from .test_integration_base_skills import SkillsIntegrationTests @@ -276,3 +277,34 @@ def test_docker_executable_override_uses_agent_subcommand(monkeypatch): args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) assert args == ["/opt/docker", "agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_nonexistent_executable_override_is_not_available(monkeypatch, tmp_path): + missing = tmp_path / "docker-agent" + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", str(missing)) + + # The probe never launches a custom binary, so on its own it reports a + # command form for a path that does not exist. Availability cannot be + # taken from it alone for an override. + assert docker_agent_command(str(missing)) is not None + assert DockerAgentIntegration().is_cli_available() is False + + +def test_existing_executable_override_is_available(monkeypatch, tmp_path): + present = tmp_path / "docker-agent" + present.write_text("#!/bin/sh\n") + present.chmod(0o755) + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", str(present)) + + assert DockerAgentIntegration().is_cli_available() is True + + +def test_default_key_availability_still_uses_the_shared_probe(monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", raising=False) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None, + ) + + # No override: the PATH-based probe stays authoritative. + assert DockerAgentIntegration().is_cli_available() is True From 6ec60dcaf2bfa9c2ff4c72728beea6d3518905d0 Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Tue, 29 Sep 2026 23:14:07 +0530 Subject: [PATCH 3/6] test: mark Claude install fixtures as executable The availability check tests the execute bit, so a fixture created with touch() alone no longer models an installed CLI on POSIX and the three positive Claude cases failed on Linux CI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/test_check_tool.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/specify_cli/test_check_tool.py b/tests/specify_cli/test_check_tool.py index 0d82e84564..9cd65d30be 100644 --- a/tests/specify_cli/test_check_tool.py +++ b/tests/specify_cli/test_check_tool.py @@ -17,6 +17,8 @@ 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() + # Availability checks the execute bit, so model a real (executable) install. + fake_claude.chmod(0o755) # Ensure npm-local path is missing so we only exercise migrate-installer path fake_missing = tmp_path / "nonexistent" / "claude" @@ -33,6 +35,8 @@ def test_detected_via_npm_local_path(self, tmp_path): fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" fake_npm_claude.parent.mkdir(parents=True) fake_npm_claude.touch() + # Availability checks the execute bit, so model a real (executable) install. + fake_npm_claude.chmod(0o755) # Neither the migrate-installer path nor PATH has claude fake_migrate = tmp_path / "nonexistent" / "claude" @@ -71,6 +75,8 @@ def test_tracker_updated_on_npm_local_detection(self, tmp_path): fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" fake_npm_claude.parent.mkdir(parents=True) fake_npm_claude.touch() + # Availability checks the execute bit, so model a real (executable) install. + fake_npm_claude.chmod(0o755) fake_missing = tmp_path / "nonexistent" / "claude" tracker = MagicMock() From bea2e1aea0b169ca13666d64d3fbddaab48eabaf Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Wed, 30 Sep 2026 06:15:04 +0530 Subject: [PATCH 4/6] fix: skip non-executable Claude install candidates The fallback loop returned the first candidate that existed, so a stale non-executable file left by one installer masked a working install later in the list: availability then rejected it on the execute bit and reported Claude as missing. Skip candidates that are not executable, matching the availability check. Adds a regression test for that ordering case and one for both candidates being unusable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../integrations/claude/__init__.py | 7 ++- tests/specify_cli/test_check_tool.py | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 651939c823..50f6b4f061 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import shutil from typing import Any @@ -74,13 +75,15 @@ def _resolve_executable(self) -> str: binary under ``~/.claude/local`` without adding it to PATH. Returning that absolute path keeps availability checks and dispatch in agreement (issues #123 and #550). An operator override or a PATH install still - wins where present. + wins where present. A candidate that exists but is not executable is + skipped, so a stale file left by one installer cannot mask a working + install found later in the list. """ resolved = super()._resolve_executable() if resolved != self.key or shutil.which(resolved): return resolved for candidate in (_utils.CLAUDE_LOCAL_PATH, _utils.CLAUDE_NPM_LOCAL_PATH): - if candidate.is_file(): + if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) return resolved diff --git a/tests/specify_cli/test_check_tool.py b/tests/specify_cli/test_check_tool.py index 9cd65d30be..c7a4037808 100644 --- a/tests/specify_cli/test_check_tool.py +++ b/tests/specify_cli/test_check_tool.py @@ -5,9 +5,13 @@ installed via npm-local (the default `claude` installer path). """ +import sys from unittest.mock import patch, MagicMock +import pytest + from specify_cli import check_tool +from specify_cli.integrations.claude import ClaudeIntegration class TestCheckToolClaude: @@ -48,6 +52,61 @@ def test_detected_via_npm_local_path(self, tmp_path): patch("shutil.which", return_value=None): assert check_tool("claude") is True + @pytest.mark.skipif( + sys.platform == "win32", + reason="Windows has no POSIX execute bit; os.access(X_OK) is always true", + ) + def test_non_executable_local_path_does_not_mask_npm_local(self, tmp_path): + """A stale, non-executable migrate-installer file must not hide an npm-local install. + + Both candidates exist, so picking on existence alone returns the first + one; availability then rejects it on the execute bit and Claude is + reported missing even though the second candidate is launchable. + """ + stale_local = tmp_path / "local" / "claude" + stale_local.parent.mkdir(parents=True) + stale_local.write_text("#!/bin/sh\n") + stale_local.chmod(0o644) + + npm_local = tmp_path / "node_modules" / ".bin" / "claude" + npm_local.parent.mkdir(parents=True) + npm_local.write_text("#!/bin/sh\n") + npm_local.chmod(0o755) + + with patch("specify_cli.CLAUDE_LOCAL_PATH", stale_local), \ + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", stale_local), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", npm_local), \ + patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", npm_local), \ + patch("shutil.which", return_value=None): + integration = ClaudeIntegration() + # Dispatch runs this value, so it has to be the launchable candidate. + assert integration._resolve_executable() == str(npm_local) + assert integration.is_cli_available() is True + assert check_tool("claude") is True + + @pytest.mark.skipif( + sys.platform == "win32", + reason="Windows has no POSIX execute bit; os.access(X_OK) is always true", + ) + def test_not_found_when_candidates_exist_but_none_are_executable(self, tmp_path): + """Skipping a non-executable candidate must not invent an install.""" + stale_local = tmp_path / "local" / "claude" + stale_local.parent.mkdir(parents=True) + stale_local.write_text("#!/bin/sh\n") + stale_local.chmod(0o644) + + stale_npm = tmp_path / "node_modules" / ".bin" / "claude" + stale_npm.parent.mkdir(parents=True) + stale_npm.write_text("#!/bin/sh\n") + stale_npm.chmod(0o644) + + with patch("specify_cli.CLAUDE_LOCAL_PATH", stale_local), \ + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", stale_local), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", stale_npm), \ + patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", stale_npm), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is False + def test_detected_via_path(self, tmp_path): """claude on PATH (global npm install) should still work.""" fake_missing = tmp_path / "nonexistent" / "claude" From 1c2a2b7a0f44b9144dbc4d31378203f5e8c4c185 Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Wed, 30 Sep 2026 20:05:07 +0530 Subject: [PATCH 5/6] fix: honor an explicit executable override that equals the integration key `_resolve_executable()` collapsed "an operator pinned a binary" and "no override is set" into a single string, so the Claude and Kiro CLI integrations inferred "was an override set?" from `resolved != self.key`. That inference is lossy exactly when the override equals the default key: `SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE=claude` was silently replaced by a `~/.claude` local install, and `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE=kiro-cli` by the legacy `kiro` binary, so both ran something the operator did not ask for. Add `IntegrationBase._executable_override()`, which returns the override or `None`, and have both subclasses ask it instead of comparing strings. Whitespace-only values still count as unset, and PATH, default and no-override fallbacks are unchanged, as is the executable-candidate ordering check. `copilot` and `rovodev` also read the override but fall back to a different default, so the comparison is not lossy there and they are untouched. Adds regression tests for both default-key override cases, which fail before this change, plus non-default and whitespace-override coverage. Documents the resolution order and the override contract in design/integration.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 5 ++ design/integration.md | 23 ++++++ src/specify_cli/integrations/base.py | 20 +++-- .../integrations/claude/__init__.py | 2 +- .../integrations/kiro_cli/__init__.py | 2 +- tests/integrations/test_base.py | 80 +++++++++++++++++++ 6 files changed, 125 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2daaa2ea59..71ac34e030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,11 @@ Before adding or changing AI agent integrations, read [Agent Integration Design](design/integration.md). It covers delivery routes, output formats, registration, and install/uninstall ownership. +When an integration resolves its own executable, an explicit +`SPECKIT_INTEGRATION__EXECUTABLE` override always wins over any fallback, +and `is_cli_available()` must resolve exactly what dispatch will run. See +[Executable resolution and availability](design/integration.md#executable-resolution-and-availability). + ## Adding or Updating Workflow Steps Before adding or changing workflow step types, read diff --git a/design/integration.md b/design/integration.md index 2bcb516022..00cb475603 100644 --- a/design/integration.md +++ b/design/integration.md @@ -44,6 +44,29 @@ Agent-specific native events can be declared on the integration. Set `multi_install_safe = True` only for a static, non-overlapping agent root and command directory; shared dynamic paths are not safe by default. +### Executable resolution and availability + +`_resolve_executable()` returns the executable `dispatch_command()` will +launch, and `is_cli_available()` resolves the same value: preflight must never +report a tool as present under a name dispatch cannot find. Resolution order +is: + +1. An explicit operator override read from + `SPECKIT_INTEGRATION__EXECUTABLE`, where hyphens in the key become + underscores (`kiro-cli` reads `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE`). + A whitespace-only value counts as unset. +2. Any integration-specific fallback, such as a known install location that is + not on `PATH`. +3. `self.key`. + +An override always wins, **including when its value equals the integration +key**. A subclass that adds step 2 must ask `_executable_override()` whether an +override is in effect rather than comparing the resolved string against +`self.key`; that comparison cannot tell a deliberate pin from a plain fallback, +so it silently redirects the operator to a different binary. Fallback +candidates must also be executable (`os.access(path, os.X_OK)`), so a stale +non-executable file cannot mask a working install later in the list. + ## Output flavors Choose the smallest base class that matches the agent's native format. The diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index ad7c7a130b..4a041007ed 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -290,6 +290,20 @@ def validate_runtime_config( f"'integration_options' ({option_names})." ) + def _executable_override(self) -> str | None: + """Return the operator's explicit executable override, if any. + + ``None`` means no override is in effect; a whitespace-only value is + treated as unset, matching :meth:`_resolve_executable`. Subclasses + that add their own fallbacks need to tell "an operator pinned a + binary" apart from "we fell back to the key", which the resolved + string alone cannot express when the override equals the key. + """ + env_name = ( + f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE" + ) + return os.environ.get(env_name, "").strip() or None + def _resolve_executable(self) -> str: """Return the executable for this integration's CLI tool. @@ -306,11 +320,7 @@ def _resolve_executable(self) -> str: See issue #2596. """ - env_name = ( - f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE" - ) - override = os.environ.get(env_name, "").strip() - return override if override else self.key + return self._executable_override() or self.key def is_cli_available(self) -> bool: """Report whether this integration's CLI can actually be launched. diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 50f6b4f061..08fc7545d8 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -80,7 +80,7 @@ def _resolve_executable(self) -> str: install found later in the list. """ resolved = super()._resolve_executable() - if resolved != self.key or shutil.which(resolved): + if self._executable_override() is not None or shutil.which(resolved): return resolved for candidate in (_utils.CLAUDE_LOCAL_PATH, _utils.CLAUDE_NPM_LOCAL_PATH): if candidate.is_file() and os.access(candidate, os.X_OK): diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 95314d943e..338c10c16e 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -45,7 +45,7 @@ def _resolve_executable(self) -> str: with only the legacy binary passes preflight and then fails to launch. """ resolved = super()._resolve_executable() - if resolved != self.key: + if self._executable_override() is not None: return resolved if shutil.which(resolved) is None and shutil.which("kiro"): return "kiro" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 62e233dbc1..4e7922caac 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -842,3 +842,83 @@ def test_explicit_path_without_execute_bit_is_not_available( stub.chmod(0o755) assert integration.is_cli_available() is True assert check_tool("claude") is True + + def test_claude_override_equal_to_key_is_not_replaced_by_local_install( + self, monkeypatch, tmp_path + ): + # An operator who pins the plain name is still an operator. The + # fallback inferred "no override was set" from the resolved value + # matching the key, so this pin was silently swapped for a local + # install the operator did not ask for. + local_claude = tmp_path / "claude" + local_claude.write_text("#!/bin/sh\n") + local_claude.chmod(0o755) + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "claude") + + with ( + patch("shutil.which", return_value=None), + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", local_claude), + ): + integration = get_integration("claude") + + assert integration._resolve_executable() == "claude" + assert integration.build_exec_args("hello")[0] == "claude" + # Nothing named claude is on PATH, so the pin is unavailable and + # preflight has to say so rather than report the local install. + assert integration.is_cli_available() is False + assert check_tool("claude") is False + + def test_kiro_override_equal_to_key_is_not_replaced_by_legacy_binary( + self, monkeypatch + ): + # Same lossy inference on the Kiro side: pinning "kiro-cli" fell + # through to the legacy "kiro" binary. + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "kiro-cli") + with patch("shutil.which", side_effect=fake_which): + integration = get_integration("kiro-cli") + + assert integration._resolve_executable() == "kiro-cli" + assert integration.build_exec_args("hello")[0] == "kiro-cli" + assert integration.is_cli_available() is False + assert check_tool("kiro-cli") is False + + def test_claude_non_default_override_still_wins_over_local_install( + self, monkeypatch, tmp_path + ): + local_claude = tmp_path / "claude" + local_claude.write_text("#!/bin/sh\n") + local_claude.chmod(0o755) + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude") + + with ( + patch("shutil.which", return_value=None), + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", local_claude), + ): + assert get_integration("claude")._resolve_executable() == "/opt/claude" + + def test_claude_whitespace_override_still_falls_back_to_local_install( + self, monkeypatch, tmp_path + ): + # Whitespace-only reads as unset everywhere else, so the fallback + # must still run. + local_claude = tmp_path / "claude" + local_claude.write_text("#!/bin/sh\n") + local_claude.chmod(0o755) + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", " ") + + with ( + patch("shutil.which", return_value=None), + patch("specify_cli._utils.CLAUDE_LOCAL_PATH", local_claude), + ): + assert get_integration("claude")._resolve_executable() == str(local_claude) + + def test_kiro_whitespace_override_still_accepts_legacy_binary(self, monkeypatch): + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", " ") + with patch("shutil.which", side_effect=fake_which): + assert get_integration("kiro-cli")._resolve_executable() == "kiro" From 50bb637ec13d93c5eb81d1ef77d09eebedf065f9 Mon Sep 17 00:00:00 2001 From: dhruv-15-03 Date: Wed, 30 Sep 2026 23:33:26 +0530 Subject: [PATCH 6/6] fix: respect an explicit docker-agent override equal to the default key DockerAgentIntegration inferred "no override present" from `executable == self.key`, so setting SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE=docker-agent was indistinguishable from setting nothing: the pin was discarded in favour of the `docker agent` plugin form, and is_cli_available() skipped the inherited PATH/X_OK probe entirely, contradicting its own docstring. Both call sites now branch on whether _executable_override() is present rather than on the resolved value, matching the claude and kiro_cli integrations. Unset, whitespace-only and non-default overrides keep their existing behaviour. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../integrations/docker_agent/__init__.py | 8 ++- .../test_integration_docker_agent.py | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py index 7f070a8df2..c7fe373c1d 100644 --- a/src/specify_cli/integrations/docker_agent/__init__.py +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -54,10 +54,12 @@ def _agent_command(self) -> list[str]: """Return the available Docker Agent command form.""" # The shared executable override supports both a standalone - # ``docker-agent`` binary and the Docker CLI plugin form. + # ``docker-agent`` binary and the Docker CLI plugin form. Whether an + # override is in effect decides which of those applies; its value does + # not, because an operator may legitimately pin the default name. executable = self._resolve_executable() command = docker_agent_command( - None if executable == self.key else executable + executable if self._executable_override() is not None else None ) if command is None: # Preserve the normal executable-shaped argv for dispatch callers; @@ -78,7 +80,7 @@ def is_cli_available(self) -> bool: case, keeping preflight and dispatch in agreement. """ executable = self._resolve_executable() - if executable == self.key: + if self._executable_override() is None: return docker_agent_command(None) is not None if not super().is_cli_available(): return False diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py index 9ac9ebc6bf..90e258583b 100644 --- a/tests/integrations/test_integration_docker_agent.py +++ b/tests/integrations/test_integration_docker_agent.py @@ -308,3 +308,68 @@ def test_default_key_availability_still_uses_the_shared_probe(monkeypatch): # No override: the PATH-based probe stays authoritative. assert DockerAgentIntegration().is_cli_available() is True + + +def test_override_equal_to_the_key_selects_the_pinned_standalone_binary(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "docker-agent") + # Only the Docker CLI plugin form is discoverable on PATH, which is what + # an unset override would fall back to. + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + # The operator pinned the standalone binary. That it happens to spell the + # integration key does not make it an absent override, so the plugin form + # is not substituted for it. + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_override_equal_to_the_key_keeps_the_inherited_availability_check(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "docker-agent") + # The pinned binary is not installed; only the plugin form is. + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + # An override is in effect, so the inherited PATH/executable check applies + # and the missing binary makes the integration unavailable — preflight and + # dispatch have to agree on the pinned name. + assert DockerAgentIntegration().is_cli_available() is False + + +def test_override_equal_to_the_key_is_available_when_installed(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "docker-agent") + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None, + ) + + assert DockerAgentIntegration().is_cli_available() is True + + +def test_whitespace_only_override_keeps_the_plugin_fallback(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", " ") + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + # Whitespace is treated as unset, so the plugin fallback stays in play. + assert DockerAgentIntegration().is_cli_available() is True