Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<KEY>_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
Expand Down
23 changes: 23 additions & 0 deletions design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<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`.
Comment on lines +52 to +60

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
Expand Down
32 changes: 10 additions & 22 deletions src/specify_cli/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 36 additions & 5 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -306,11 +320,28 @@ 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.

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.

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() and os.access(executable, os.X_OK)
return shutil.which(executable) is not None
Comment thread
Copilot marked this conversation as resolved.

def _apply_extra_args_env_var(self, args: list[str]) -> None:
"""Append `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` env-var value to *args*.
Expand Down
22 changes: 22 additions & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

import os
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
Expand Down Expand Up @@ -65,6 +68,25 @@ 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. 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 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):
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.
Expand Down
25 changes: 23 additions & 2 deletions src/specify_cli/integrations/docker_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,38 @@ 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;
# preflight and the subprocess runner report the unavailable CLI.
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 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()
if self._executable_override() is None:
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]:
opts = super().options()
Expand Down
16 changes: 16 additions & 0 deletions src/specify_cli/integrations/kiro_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Kiro CLI integration."""

import shutil

from ..base import MarkdownIntegration


Expand Down Expand Up @@ -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 self._executable_override() is not None:
return resolved
if shutil.which(resolved) is None and shutil.which("kiro"):
return "kiro"
return resolved
Loading
Loading