Skip to content

Resolve agent CLI executables in one place so checks match dispatch - #3748

Open
dhruv-15-03 wants to merge 6 commits into
github:mainfrom
dhruv-15-03:feat/2558-cli-executable-detection
Open

dhruv-15-03 wants to merge 6 commits into
github:mainfrom
dhruv-15-03:feat/2558-cli-executable-detection

Conversation

@dhruv-15-03

@dhruv-15-03 dhruv-15-03 commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

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.

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 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:

git stash push -- src
pytest tests/integrations/test_base.py -k TestCliAvailabilityMatchesDispatch -q
2 failed    assert resolved == str(local_claude)
            assert resolved == "kiro"
git stash pop
2 passed

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.

@dhruv-15-03
dhruv-15-03 requested a review from mnriem as a code owner July 26, 2026 07:02
Copilot AI balanced review requested due to automatic review settings July 26, 2026 07:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment thread src/specify_cli/integrations/base.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

dhruv-15-03 added a commit to dhruv-15-03/spec-kit that referenced this pull request Jul 30, 2026
…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>
Copilot AI review requested due to automatic review settings July 30, 2026 16:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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
        )
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Low

@mnriem

mnriem commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Please resolve conflicts

Copilot AI review requested due to automatic review settings August 18, 2026 17:42
@dhruv-15-03
dhruv-15-03 force-pushed the feat/2558-cli-executable-detection branch from 153ec7a to ddf2067 Compare August 18, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 9/9 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/specify_cli/integrations/claude/__init__.py Outdated
Comment thread src/specify_cli/integrations/kiro_cli/__init__.py Outdated
Comment thread src/specify_cli/workflows/steps/prompt/__init__.py Outdated
@mnriem

mnriem commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

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.
Copilot AI review requested due to automatic review settings September 26, 2026 15:08
@dhruv-15-03
dhruv-15-03 force-pushed the feat/2558-cli-executable-detection branch from ddf2067 to 4d2ca34 Compare September 26, 2026 15:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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 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
@mnriem
mnriem requested a balanced review from Copilot September 28, 2026 12:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/specify_cli/integrations/base.py
Comment thread src/specify_cli/integrations/docker_agent/__init__.py Outdated
@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and fix test & lint errors

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>
Copilot AI balanced review requested due to automatic review settings September 29, 2026 17:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Explicit default-name overrides are not preserved, and the linked documentation criterion remains unmet.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Explicit default executable override incorrectly falls back to local path

src/​specify_cli/​integrations/​claude/​__init__.py:84

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.

Medium severity Explicit kiro-cli override incorrectly falls back to legacy kiro

src/​specify_cli/​integrations/​kiro_cli/​__init__.py:51

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.

Low severity 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.

@mnriem

mnriem commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Please address the previously missed Copilot feedback

…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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 14:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Docker Agent still ignores an explicit override when its value equals the integration key.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Comment thread src/specify_cli/integrations/docker_agent/__init__.py Outdated
@mnriem

mnriem commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 18:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Path-valued overrides can be incorrectly rejected on Windows, and the documented resolution order contradicts the implementation.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The documented resolution precedence contradicts the new Claude and Kiro implementations.

Review effort: Balanced
Findings: 1 Low severity

Open (1)

Comment thread design/integration.md
Comment on lines +52 to +60
is:

1. An explicit operator override read from
`SPECKIT_INTEGRATION_<KEY>_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`.
@mnriem

mnriem commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and fix test & lint errors

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add cli_executable property to IntegrationBase for agents whose executable differs from their key

3 participants