Skip to content

Commit fc9ce2c

Browse files
authored
1 parent d24d3b1 commit fc9ce2c

9 files changed

Lines changed: 249 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,12 @@ class CodexIntegration(SkillsIntegration):
147147

148148
| Field | Location | Purpose |
149149
|---|---|---|
150-
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
150+
| `key` | Class attribute | Unique identifier; for most CLI-based integrations this matches the executable name, but see `cli_executable` below for exceptions |
151151
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
152152
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
153153
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
154154

155-
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
155+
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` should generally match the CLI executable name so that the default `is_cli_available()` check works without any override. When the executable name differs from the key (e.g., RovoDev's key is `"rovodev"` but the binary is `"acli"`), override the `cli_executable` property or `is_cli_available()` method — see [§6 Optional overrides](#6-optional-overrides) below. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
156156

157157
### 3. Register it
158158

@@ -222,11 +222,37 @@ The base classes handle most work automatically. Override only when the agent de
222222

223223
| Override | When to use | Example |
224224
|---|---|---|
225+
| `cli_executable` | Binary name differs from `key` | RovoDev: key `"rovodev"`, binary `"acli"` → override returns `"acli"` |
226+
| `is_cli_available()` | Multiple binary names or non-PATH installs | Claude checks `~/.claude/local/`; Kiro accepts both `kiro-cli` and `kiro` |
225227
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
226228
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
227229
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
228230
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
229231

232+
**`cli_executable` property** — Return the binary name to look up on `PATH` for tool-availability checks. The default implementation returns `self.key`. Override when the executable name differs from the integration key:
233+
234+
```python
235+
@property
236+
def cli_executable(self) -> str:
237+
return "acli" # e.g. RovoDev: key="rovodev", binary="acli"
238+
```
239+
240+
**`is_cli_available()` method** — Return `True` if the integration's CLI tool is installed. The default implementation calls `shutil.which(self.cli_executable)`. Override for more complex detection:
241+
242+
```python
243+
def is_cli_available(self) -> bool:
244+
# Multiple binary names (Kiro):
245+
return shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
246+
247+
# Non-PATH install locations (Claude):
248+
import specify_cli._utils as _utils_mod
249+
if _utils_mod.CLAUDE_LOCAL_PATH.is_file() or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file():
250+
return True
251+
return shutil.which(self.cli_executable) is not None
252+
```
253+
254+
`is_cli_available()` is used by `check_tool()` in `_utils.py` and by both `CommandStep` and `PromptStep` workflow steps to gate CLI dispatch. No hardcoded special cases should be added to those callers — encode detection logic in the integration class instead.
255+
230256
**Example — Copilot (fully custom `setup`):**
231257

232258
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
@@ -436,7 +462,7 @@ When an issue exists, include its number immediately after the prefix — this i
436462

437463
## Common Pitfalls
438464

439-
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
465+
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), `key` should generally match the executable name. When it cannot (e.g., the binary name differs), override `cli_executable` or `is_cli_available()` on the integration class. Do **not** add special-case mappings to `check_tool()`, `CommandStep`, or `PromptStep`.
440466
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
441467
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
442468
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.

src/specify_cli/_utils.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,35 +38,42 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False
3838
def check_tool(tool: str, tracker=None) -> bool:
3939
"""Check if a tool is installed. Optionally update tracker.
4040
41+
For tools that correspond to a registered integration the check is
42+
delegated to ``IntegrationBase.is_cli_available()`` so that each
43+
integration can encode its own detection logic (e.g. multiple
44+
binary names, non-PATH install locations). Unknown tools fall back
45+
to a plain ``shutil.which`` look-up.
46+
4147
Args:
42-
tool: Name of the tool to check
48+
tool: Name of the tool to check (typically an integration key)
4349
tracker: StepTracker | None to update with results
4450
4551
Returns:
4652
True if tool is found, False otherwise
4753
"""
48-
# Special handling for Claude CLI local installs
49-
# See: https://github.com/github/spec-kit/issues/123
50-
# See: https://github.com/github/spec-kit/issues/550
51-
# Claude Code can be installed in two local paths:
52-
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
53-
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
54-
# Neither path may be on the system PATH, so we check them explicitly.
55-
if tool == "claude":
56-
if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
54+
found: bool
55+
56+
# Delegate to the integration's is_cli_available() when the tool
57+
# key matches a registered integration. This removes the need for
58+
# hard-coded special cases here (e.g. Claude local paths, kiro dual
59+
# binaries, rovodev/acli mismatch). See issue #2597.
60+
try:
61+
from specify_cli.integrations import get_integration
62+
63+
impl = get_integration(tool)
64+
if impl is not None:
65+
found = impl.is_cli_available()
5766
if tracker:
58-
tracker.complete(tool, "available")
59-
return True
60-
61-
# Per-integration executable resolution.
62-
if tool == "kiro-cli":
63-
# Kiro currently supports both executable names. Prefer kiro-cli and
64-
# accept kiro as a compatibility fallback.
65-
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
66-
elif tool == "rovodev":
67-
found = shutil.which("acli") is not None
68-
else:
69-
found = shutil.which(tool) is not None
67+
if found:
68+
tracker.complete(tool, "available")
69+
else:
70+
tracker.error(tool, "not found")
71+
return found
72+
except ImportError:
73+
pass
74+
75+
# Fallback for non-integration tools (e.g. "git").
76+
found = shutil.which(tool) is not None
7077

7178
if tracker:
7279
if found:

src/specify_cli/integrations/base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,45 @@ def build_exec_args(
162162
"""
163163
return None
164164

165+
@property
166+
def cli_executable(self) -> str:
167+
"""Executable name used for CLI availability detection.
168+
169+
Defaults to ``self.key``. Integrations whose CLI binary name
170+
differs from the integration key should override this property.
171+
For example, RovoDev's key is ``"rovodev"`` but the binary is
172+
``"acli"``, so its override returns ``"acli"``.
173+
174+
This property is used by :meth:`is_cli_available` and by
175+
``check_tool()`` when checking whether the integration's CLI
176+
tool is installed. It intentionally does **not** honour the
177+
``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE`` env-var override — that
178+
variable controls which binary is *executed* at runtime (see
179+
:meth:`_resolve_executable`), whereas ``cli_executable`` names
180+
the tool to *detect* on ``PATH``.
181+
182+
See issue #2597.
183+
"""
184+
return self.key
185+
186+
def is_cli_available(self) -> bool:
187+
"""Return ``True`` if this integration's CLI tool is installed.
188+
189+
The default implementation checks ``shutil.which(self.cli_executable)``.
190+
Integrations with non-standard install locations or multiple
191+
possible binary names should override this method.
192+
193+
Examples of integrations that override this:
194+
195+
* **ClaudeIntegration** — also checks ``~/.claude/local/`` paths
196+
that are not on ``PATH``.
197+
* **KiroCliIntegration** — accepts both ``kiro-cli`` and the
198+
legacy ``kiro`` binary name.
199+
200+
See issue #2597.
201+
"""
202+
return shutil.which(self.cli_executable) is not None
203+
165204
def _resolve_executable(self) -> str:
166205
"""Return the executable for this integration's CLI tool.
167206

src/specify_cli/integrations/claude/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import shutil
56
from pathlib import Path
67
from typing import Any
78

@@ -45,6 +46,27 @@ class ClaudeIntegration(SkillsIntegration):
4546
context_file = "CLAUDE.md"
4647
multi_install_safe = True
4748

49+
def is_cli_available(self) -> bool:
50+
"""Return ``True`` if the Claude Code CLI is installed.
51+
52+
Claude Code can be installed in multiple locations, not all of
53+
which are on ``PATH``:
54+
55+
1. ``~/.claude/local/claude`` — ``claude migrate-installer``
56+
2. ``~/.claude/local/node_modules/.bin/claude`` — npm-local install (nvm)
57+
3. Anywhere on ``PATH`` — global npm install
58+
59+
See issues #123, #550, and #2597.
60+
"""
61+
import specify_cli._utils as _utils_mod
62+
63+
if (
64+
_utils_mod.CLAUDE_LOCAL_PATH.is_file()
65+
or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file()
66+
):
67+
return True
68+
return shutil.which(self.cli_executable) is not None
69+
4870
@staticmethod
4971
def inject_argument_hint(content: str, hint: str) -> str:
5072
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.

src/specify_cli/integrations/kiro_cli/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Kiro CLI integration."""
22

3+
import shutil
4+
35
from ..base import MarkdownIntegration
46

57

@@ -27,3 +29,17 @@ class KiroCliIntegration(MarkdownIntegration):
2729
"extension": ".md",
2830
}
2931
context_file = "AGENTS.md"
32+
33+
def is_cli_available(self) -> bool:
34+
"""Return ``True`` if the Kiro CLI is installed.
35+
36+
Kiro ships under two binary names: ``kiro-cli`` (preferred) and
37+
the legacy ``kiro`` alias. Either name satisfies the availability
38+
check so existing installations continue to work.
39+
40+
See issue #2597.
41+
"""
42+
return (
43+
shutil.which("kiro-cli") is not None
44+
or shutil.which("kiro") is not None
45+
)

src/specify_cli/integrations/rovodev/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ class RovodevIntegration(SkillsIntegration):
4343

4444
# -- CLI dispatch ------------------------------------------------------
4545

46+
@property
47+
def cli_executable(self) -> str:
48+
"""Executable name for CLI availability detection (``acli``).
49+
50+
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the
51+
host binary; ``rovodev`` is a sub-command. The integration key
52+
is ``"rovodev"``, but the binary to detect on ``PATH`` is
53+
``"acli"``.
54+
55+
See issue #2597.
56+
"""
57+
return "acli"
58+
4659
def _resolve_executable(self) -> str:
4760
"""Return the binary to invoke (``acli``).
4861

src/specify_cli/workflows/steps/command/__init__.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import shutil
65
from pathlib import Path
76
from typing import Any
87

@@ -126,15 +125,10 @@ def _try_dispatch(
126125
if impl is None:
127126
return None
128127

129-
# Build sample args for fallback executable detection when impl.key is not executable.
130-
exec_args = impl.build_exec_args("test")
131-
132-
# Check if the CLI tool is actually installed.
133-
# Try the integration key first (covers most agents), then fall back
134-
# to exec_args[0] for agents whose executable differs.
135-
cli_path = shutil.which(impl.key)
136-
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
137-
if cli_path is None and fallback_cli_path is None:
128+
# Check if the CLI tool is actually installed via the integration's
129+
# own availability check (honours custom executables, dual binaries,
130+
# and non-PATH install paths). See issue #2597.
131+
if not impl.is_cli_available():
138132
return None
139133

140134
project_root = Path(context.project_root) if context.project_root else None

src/specify_cli/workflows/steps/prompt/__init__.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import shutil
65
from pathlib import Path
76
from typing import Any
87

@@ -116,12 +115,10 @@ def _try_dispatch(
116115

117116
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
118117

119-
# Check if the CLI tool is actually installed.
120-
# Try the integration key first (covers most agents), then fall back
121-
# to exec_args[0] for agents whose executable differs.
122-
cli_path = shutil.which(impl.key)
123-
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
124-
if cli_path is None and fallback_cli_path is None:
118+
# Check if the CLI tool is actually installed via the integration's
119+
# own availability check (honours custom executables, dual binaries,
120+
# and non-PATH install paths). See issue #2597.
121+
if not impl.is_cli_available():
125122
return None
126123

127124
# Prompt dispatch executes exec_args directly; require a non-empty argv.

0 commit comments

Comments
 (0)