You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
check_tool() decided whether an agent CLI was installed using per-tool special cases for claude, kiro-cli, rovodev and docker-agent. Dispatch did not use any of them. IntegrationBase.build_exec_args() calls _resolve_executable(), which returns the integration key unless an environment variable overrides it.
So the two paths could disagree, and on real machines they did:
Claude installed by claude migrate-installer or the npm local installer lives under ~/.claude/local and is not on PATH. check_tool("claude") looked there and reported it available. _resolve_executable() returned "claude", so dispatch searched PATH and failed.
A machine carrying only the legacy kiro binary passed check_tool("kiro-cli"), because that check accepted either name. Dispatch resolved "kiro-cli" and failed.
In both cases the preflight check says the tool is present and the run then fails to launch it.
Resolution moved onto the integrations, so the check and dispatch read one value instead of maintaining two rule sets that drift.
ClaudeIntegration._resolve_executable() falls back to the known local install paths when the key is not on PATH. An explicit env override, or a real PATH install, still wins.
KiroCliIntegration._resolve_executable() falls back to the legacy kiro binary on the same terms.
IntegrationBase.is_cli_available() resolves the executable, then checks that path directly when it contains a separator, or looks the bare name up on PATH.
DockerAgentIntegration.is_cli_available() overrides that, because Docker Agent is a docker CLI plugin rather than an executable on PATH. It uses the existing docker_agent_command() probe.
check_tool() asks the integration when one is registered, and keeps the plain PATH lookup for non-integration tools such as git.
RovodevIntegration already overrode _resolve_executable() to return "acli", so dropping its special case from check_tool() is behaviour-preserving. It is the pattern the other two now follow.
The two workflow dispatch sites are not touched. They already do shutil.which(exec_args[0]) and substitute the result into argv, and exec_args[0] now carries the resolved executable, so correcting resolution fixes dispatch there without editing it.
Rebase and scope reduction
This branch had gone stale. main has since gained IntegrationBase._resolve_executable() independently, which is the hook this change needs, so the cli_executable property the PR originally added is no longer necessary. I reset the branch onto current main and rebuilt the change on top of what is already there. The diff is smaller as a result: no new property, only overrides of the existing one.
That reset also removed the dispatch-site migration I had pushed earlier in review. I have not restored it, and I have explained why on that thread rather than dropping it silently.
Testing
Python 3.12.9 on Windows, at 4d2ca34 with a clean tree.
pytest tests/integrations/test_base.py tests/specify_cli/test_check_tool.py -q
98 passed, 1 skipped in 3.15s
Two new tests in tests/integrations/test_base.py assert the property that was broken: a tool reported as available must yield an argv[0] that exists. One covers the Claude local install, one the legacy kiro binary. Each asserts check_tool(...), _resolve_executable() and build_exec_args(...)[0] together, so it is the dispatch argv that is being checked, not just a helper.
They fail without the source change. With only the tests applied:
The 12 existing cases in tests/specify_cli/test_check_tool.py are unmodified and still pass. They are the contract for the behaviour being refactored.
ruff check on the touched files reports 22 findings on main and 21 with this change, so this introduces none. The earlier claim in this description that ruff was clean on the changed files was wrong; those files are not clean on main either.
I did not run the full suite to completion. It did not finish in about 35 minutes here, and neither did tests/integrations and tests/specify_cli in full, so the slow tests are not confined to one file. They appear to block on reaching the network from this machine. I would rather say that than quote a number I did not observe.
AI disclosure
This PR was authored autonomously by an AI coding agent (GitHub Copilot, Claude Sonnet 5) operating under my supervision via the dhruv-15-03 account, per this repo's disclosure guidelines in AGENTS.md.
…able()
Both CommandStep._try_dispatch and PromptStep._try_dispatch reimplemented
CLI detection as shutil.which(impl.key) with a shutil.which(exec_args[0])
fallback, bypassing the IntegrationBase.is_cli_available() contract added
for issue github#2558. This meant Claude's non-PATH local installs
(~/.claude/local/claude, npm-local) and Kiro's legacy binary name were
invisible at these two dispatch sites even though check_tool() already
honored them.
Migrate both sites to call impl.is_cli_available() directly, matching the
pattern already used in check_tool(). Update the ~15 existing tests that
patched shutil.which at the old module paths (specify_cli.workflows.steps.
command/prompt) to patch specify_cli.integrations.base.shutil.which instead,
and add a focused regression test per dispatch site covering the Claude
non-PATH local-install scenario the Copilot review comment on this PR
flagged as unmet.
Addresses maintainer review feedback on PR github#3748:
github#3748 (comment)
Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Review details
Comments suppressed due to low confidence (4)
src/specify_cli/integrations/claude/init.py:72
is_cli_available() returns True if the local-path file exists, even if it is not executable. That can produce false positives (tool reported available, but dispatch fails with OSError). Consider checking executability (e.g., os.access(path, os.X_OK)) or using shutil.which(str(path)) for these absolute paths so the contract matches “runnable CLI” rather than “file exists”.
def is_cli_available(self) -> bool:
"""Claude Code can be installed in two local paths that may not be
on the system ``PATH``:
1. ``~/.claude/local/claude`` (after ``claude migrate-installer``)
2. ``~/.claude/local/node_modules/.bin/claude`` (npm-local install,
e.g. via nvm)
Checked here (rather than a hardcoded special case in
``check_tool()``) so any future detection call site gets the same
behavior for free. See issues #123, #550, #2558.
"""
if _utils.CLAUDE_LOCAL_PATH.is_file() or _utils.CLAUDE_NPM_LOCAL_PATH.is_file():
return True
return super().is_cli_available()
tests/test_workflows.py:1308
This test verifies that preflight no longer blocks when PATH lookup fails, but it doesn’t assert that dispatch actually uses the local Claude executable path. To ensure the #2558 scenario is genuinely supported (not just preflight), assert on the subprocess.run call args (or whatever ultimately executes) that the executable resolved to fake_claude_local when shutil.which returns None.
def test_dispatch_honors_claude_non_path_local_install(self, tmp_path):
"""Preflight must go through ``is_cli_available()`` so a Claude
install at ``~/.claude/local/claude`` (not on ``PATH``, see #2558)
is still detected — a bare ``shutil.which("claude")`` check would
miss it and the step would wrongly report the CLI as absent."""
from unittest.mock import MagicMock, patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
fake_claude_local = tmp_path / "claude"
fake_claude_local.touch()
fake_missing = tmp_path / "nonexistent" / "claude"
step = CommandStep()
ctx = StepContext(
inputs={"name": "login"},
default_integration="claude",
project_root=str(tmp_path),
)
config = {
"id": "test",
"command": "speckit.specify",
"input": {"args": "{{ inputs.name }}"},
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = '{"result": "done"}'
mock_result.stderr = ""
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude_local), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli.integrations.base.shutil.which", return_value=None), \
patch("subprocess.run", return_value=mock_result):
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
tests/test_workflows.py:1388
Asserting the exact number/order of shutil.which calls is brittle (internal dispatch resolution details can change without affecting behavior). Prefer asserting the key behavioral outcome (e.g., that \"/opt/claude\" was checked at least once, and that the dispatched argv[0] is \"/opt/claude\") rather than an exact seen_which list.
# is_cli_available() resolves the override via cli_executable and
# checks it directly — a single shutil.which("/opt/claude") call for
# the preflight, plus dispatch_command()'s own PATHEXT resolution.
assert seen_which == ["/opt/claude", "/opt/claude"]
src/specify_cli/integrations/kiro_cli/init.py:49
This duplicates the base-class detection logic. Consider return super().is_cli_available() or shutil.which(\"kiro\") is not None to keep the primary detection behavior centralized (so future changes to the default detection contract don’t need to be mirrored here).
def is_cli_available(self) -> bool:
"""Kiro currently supports both executable names.
Prefer ``kiro-cli`` and accept the legacy ``kiro`` binary as a
compatibility fallback (see issue #2558).
"""
return (
shutil.which(self.cli_executable) is not None
or shutil.which("kiro") is not None
)
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.
The reason will be displayed to describe this comment to others. Learn more.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
dhruv-15-03
changed the title
Add cli_executable property to IntegrationBase for agents whose executable differs from their key
Resolve agent CLI executables in one place so checks match dispatch
Sep 26, 2026
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>
Addressed the Copilot feedback: fixed the two availability checks (execute bit; nonexistent docker override) with regression tests; the two older threads referenced the pre-force-push commit and are handled at head (claude, kiro-cli).
Document is_cli_available override guidance in AGENTS.md
src/specify_cli/integrations/base.py:315
The linked #2558 acceptance criteria explicitly require AGENTS.md to document the is_cli_available() override mechanism, but that file still contains no mention of this hook or executable resolution. Please add the requested integration-author guidance so future integrations know when to override resolution versus availability.
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>
The three failing Claude-install fixtures now mark their fake binaries executable, matching the stricter availability check. Pushed the fixture-only fix; the CI-pinned Ruff command passes locally. Waiting for the new CI results.
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>
An explicit override equal to the default key is indistinguishable from an unset override here. For example, with SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE=claude, no claude on PATH, and a local install present, this falls through and returns the local path even though the base override contract and PR description say the explicit value wins. Check whether the environment variable is set before applying local fallbacks.
Explicit kiro-cli override incorrectly falls back to legacy kiro
This comparison does not preserve an explicit override when its value is kiro-cli: if only legacy kiro is on PATH, _resolve_executable() silently replaces the requested executable with kiro. That contradicts the inherited executable-override contract and prevents operators from explicitly requiring the modern binary. Detect a nonblank SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE value before applying the legacy fallback.
Document executable resolution and availability override mechanisms
src/specify_cli/integrations/base.py:315
The linked issue's acceptance criteria explicitly require AGENTS.md to document the cli_executable/is_cli_available() override mechanism, but this new extension point is not documented there (nor in design/integration.md). Add contributor guidance explaining when integrations should override executable resolution versus availability.
…n 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>
Addressed the three previously missed items at 1c2a2b7a.
Root cause: _resolve_executable() returned override or self.key, so claude and kiro_cli inferred "an override was set" from resolved != self.key. That inference is lossy exactly when the override equals the integration key, so SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE=claude was silently replaced by the ~/.claude local install, and SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE=kiro-cli by the legacy kiro binary.
Added IntegrationBase._executable_override(), which returns the override or None, and both subclasses now 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 each falls back to a different default, so the comparison is not lossy there; checked and left untouched.
Regression tests cover both default-key override cases and fail before this change, plus non-default and whitespace-override coverage. The resolution order and the override contract are now documented in design/integration.md, cross-referenced from AGENTS.md.
Locally: 138 passed / 4 skipped across tests/integrations/test_base.py, tests/integrations/test_extra_args.py and tests/specify_cli/test_check_tool.py; 70 passed across the Claude and Kiro CLI integration suites; the CI-pinned ruff 0.15.0 check src tests reports no findings. The new head still needs its own CI result.
_agent_command and is_cli_available now branch on whether _executable_override() is present instead of comparing the resolved value to self.key. SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE=docker-agent is therefore treated as a real pin: it selects the standalone binary rather than the docker agent plugin form, and it no longer bypasses the inherited PATH/X_OK availability check, which the docstring already said should run.
Added four tests in tests/integrations/test_integration_docker_agent.py. The two covering command selection and availability for a default-valued override fail before the change and pass after it; unset, whitespace-only and non-default overrides keep their existing behaviour and stay covered. Focused docker_agent/base/claude/kiro_cli/copilot/rovodev/check_tool tests pass locally (311 passed, 4 skipped - the known Windows execute-bit skips), and the CI-pinned ruff@0.15.0 check src tests is clean. CI for this head has not reported yet.
I also swept the other integrations for the same override-presence comparison so an identical case isn't left behind: docker_agent was the only remaining one. copilot and rovodev override _resolve_executable too, but they never branch on whether an override is present - the resolved string is used only as argv[0] and by the base availability check - so this contract violation cannot occur there.
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>
The reason will be displayed to describe this comment to others. Learn more.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
check_tool()decided whether an agent CLI was installed using per-tool special cases forclaude,kiro-cli,rovodevanddocker-agent. Dispatch did not use any of them.IntegrationBase.build_exec_args()calls_resolve_executable(), which returns the integration key unless an environment variable overrides it.So the two paths could disagree, and on real machines they did:
claude migrate-installeror the npm local installer lives under~/.claude/localand is not on PATH.check_tool("claude")looked there and reported it available._resolve_executable()returned"claude", so dispatch searched PATH and failed.kirobinary passedcheck_tool("kiro-cli"), because that check accepted either name. Dispatch resolved"kiro-cli"and failed.In both cases the preflight check says the tool is present and the run then fails to launch it.
Closes #2558.
What changed
Resolution moved onto the integrations, so the check and dispatch read one value instead of maintaining two rule sets that drift.
ClaudeIntegration._resolve_executable()falls back to the known local install paths when the key is not on PATH. An explicit env override, or a real PATH install, still wins.KiroCliIntegration._resolve_executable()falls back to the legacykirobinary on the same terms.IntegrationBase.is_cli_available()resolves the executable, then checks that path directly when it contains a separator, or looks the bare name up on PATH.DockerAgentIntegration.is_cli_available()overrides that, because Docker Agent is a docker CLI plugin rather than an executable on PATH. It uses the existingdocker_agent_command()probe.check_tool()asks the integration when one is registered, and keeps the plain PATH lookup for non-integration tools such asgit.RovodevIntegrationalready overrode_resolve_executable()to return"acli", so dropping its special case fromcheck_tool()is behaviour-preserving. It is the pattern the other two now follow.The two workflow dispatch sites are not touched. They already do
shutil.which(exec_args[0])and substitute the result into argv, andexec_args[0]now carries the resolved executable, so correcting resolution fixes dispatch there without editing it.Rebase and scope reduction
This branch had gone stale.
mainhas since gainedIntegrationBase._resolve_executable()independently, which is the hook this change needs, so thecli_executableproperty the PR originally added is no longer necessary. I reset the branch onto currentmainand rebuilt the change on top of what is already there. The diff is smaller as a result: no new property, only overrides of the existing one.That reset also removed the dispatch-site migration I had pushed earlier in review. I have not restored it, and I have explained why on that thread rather than dropping it silently.
Testing
Python 3.12.9 on Windows, at 4d2ca34 with a clean tree.
Two new tests in
tests/integrations/test_base.pyassert the property that was broken: a tool reported as available must yield anargv[0]that exists. One covers the Claude local install, one the legacykirobinary. Each assertscheck_tool(...),_resolve_executable()andbuild_exec_args(...)[0]together, so it is the dispatch argv that is being checked, not just a helper.They fail without the source change. With only the tests applied:
The 12 existing cases in
tests/specify_cli/test_check_tool.pyare unmodified and still pass. They are the contract for the behaviour being refactored.ruff checkon the touched files reports 22 findings onmainand 21 with this change, so this introduces none. The earlier claim in this description that ruff was clean on the changed files was wrong; those files are not clean onmaineither.I did not run the full suite to completion. It did not finish in about 35 minutes here, and neither did
tests/integrationsandtests/specify_cliin full, so the slow tests are not confined to one file. They appear to block on reaching the network from this machine. I would rather say that than quote a number I did not observe.AI disclosure
This PR was authored autonomously by an AI coding agent (GitHub Copilot, Claude Sonnet 5) operating under my supervision via the dhruv-15-03 account, per this repo's disclosure guidelines in AGENTS.md.