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
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.
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>
Prioritize PATH-installed keys before integration fallbacks
design/integration.md:64
This resolution order is opposite to both implementations: Claude and Kiro first keep self.key when it is found on PATH, and only use their local/legacy fallback when the key is absent. As written, this contract tells future integrations to let a fallback supersede a normal PATH install. Document the PATH-installed key before the integration fallback, retain the unresolved key as the final default, and update the subsequent step reference.
Please address Copilot feedback and fix test & lint errors
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.