mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
5 Commits
v0.9.4
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8664f9f6a | ||
|
|
fc9ce2cfec | ||
|
|
d24d3b18cf | ||
|
|
34ce66139e | ||
|
|
6355cec8de |
32
AGENTS.md
32
AGENTS.md
@@ -147,12 +147,12 @@ class CodexIntegration(SkillsIntegration):
|
||||
|
||||
| Field | Location | Purpose |
|
||||
|---|---|---|
|
||||
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
|
||||
| `key` | Class attribute | Unique identifier; for most CLI-based integrations this matches the executable name, but see `cli_executable` below for exceptions |
|
||||
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
|
||||
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
|
||||
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
|
||||
|
||||
**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"`).
|
||||
**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"`).
|
||||
|
||||
### 3. Register it
|
||||
|
||||
@@ -222,11 +222,37 @@ The base classes handle most work automatically. Override only when the agent de
|
||||
|
||||
| Override | When to use | Example |
|
||||
|---|---|---|
|
||||
| `cli_executable` | Binary name differs from `key` | RovoDev: key `"rovodev"`, binary `"acli"` → override returns `"acli"` |
|
||||
| `is_cli_available()` | Multiple binary names or non-PATH installs | Claude checks `~/.claude/local/`; Kiro accepts both `kiro-cli` and `kiro` |
|
||||
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||
|
||||
**`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:
|
||||
|
||||
```python
|
||||
@property
|
||||
def cli_executable(self) -> str:
|
||||
return "acli" # e.g. RovoDev: key="rovodev", binary="acli"
|
||||
```
|
||||
|
||||
**`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:
|
||||
|
||||
```python
|
||||
def is_cli_available(self) -> bool:
|
||||
# Multiple binary names (Kiro):
|
||||
return shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
|
||||
|
||||
# Non-PATH install locations (Claude):
|
||||
import specify_cli._utils as _utils_mod
|
||||
if _utils_mod.CLAUDE_LOCAL_PATH.is_file() or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file():
|
||||
return True
|
||||
return shutil.which(self.cli_executable) is not None
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
**Example — Copilot (fully custom `setup`):**
|
||||
|
||||
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
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
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.
|
||||
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`.
|
||||
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.
|
||||
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
|
||||
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,6 +2,20 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.9.4] - 2026-06-04
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(workflows): add JSON output for workflow run resume and status (#2814)
|
||||
- Update workflow-preset community catalog to v1.3.2 (#2841)
|
||||
- fix: recover active skills registration for extensions (#2803)
|
||||
- fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
|
||||
- Update Superpowers Implementation Bridge extension to v1.0.2 (#2852)
|
||||
- docs(agents): add PR review response guidance to AGENTS.md (#2850)
|
||||
- Allow `specify workflow run` to execute YAML files without a project (#2825)
|
||||
- feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
|
||||
- chore: release 0.9.3, begin 0.9.4.dev0 development (#2836)
|
||||
|
||||
## [0.9.3] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -33,6 +33,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
|
||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
|
||||
| [Roo Code](https://roocode.com/) | `roo` | |
|
||||
| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` |
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
|
||||
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-13T00:00:00Z",
|
||||
"updated_at": "2026-06-02T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -174,6 +174,15 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"rovodev": {
|
||||
"id": "rovodev",
|
||||
"name": "RovoDev ACLI",
|
||||
"version": "1.0.0",
|
||||
"description": "Atlassian RovoDev integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "atlassian"]
|
||||
},
|
||||
"bob": {
|
||||
"id": "bob",
|
||||
"name": "IBM Bob",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.4.dev0"
|
||||
version = "0.9.5.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -38,32 +38,44 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False
|
||||
def check_tool(tool: str, tracker=None) -> bool:
|
||||
"""Check if a tool is installed. Optionally update tracker.
|
||||
|
||||
For tools that correspond to a registered integration the check is
|
||||
delegated to ``IntegrationBase.is_cli_available()`` so that each
|
||||
integration can encode its own detection logic (e.g. multiple
|
||||
binary names, non-PATH install locations). Unknown tools fall back
|
||||
to a plain ``shutil.which`` look-up.
|
||||
|
||||
Args:
|
||||
tool: Name of the tool to check
|
||||
tool: Name of the tool to check (typically an integration key)
|
||||
tracker: StepTracker | None to update with results
|
||||
|
||||
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
|
||||
found: bool
|
||||
|
||||
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
|
||||
else:
|
||||
found = shutil.which(tool) is not None
|
||||
# Delegate to the integration's is_cli_available() when the tool
|
||||
# key matches a registered integration. This removes the need for
|
||||
# hard-coded special cases here (e.g. Claude local paths, kiro dual
|
||||
# binaries, rovodev/acli mismatch). See issue #2597.
|
||||
try:
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
impl = get_integration(tool)
|
||||
if impl is not None:
|
||||
found = impl.is_cli_available()
|
||||
if tracker:
|
||||
if found:
|
||||
tracker.complete(tool, "available")
|
||||
else:
|
||||
tracker.error(tool, "not found")
|
||||
return found
|
||||
except ImportError as exc:
|
||||
# Integrations module is unavailable in this environment; fall back
|
||||
# to PATH-based detection below for non-integration tools.
|
||||
_ = exc
|
||||
|
||||
# Fallback for non-integration tools (e.g. "git").
|
||||
found = shutil.which(tool) is not None
|
||||
|
||||
if tracker:
|
||||
if found:
|
||||
|
||||
@@ -74,6 +74,7 @@ def _register_builtins() -> None:
|
||||
from .qodercli import QodercliIntegration
|
||||
from .qwen import QwenIntegration
|
||||
from .roo import RooIntegration
|
||||
from .rovodev import RovodevIntegration
|
||||
from .shai import ShaiIntegration
|
||||
from .tabnine import TabnineIntegration
|
||||
from .trae import TraeIntegration
|
||||
@@ -108,6 +109,7 @@ def _register_builtins() -> None:
|
||||
_register(QodercliIntegration())
|
||||
_register(QwenIntegration())
|
||||
_register(RooIntegration())
|
||||
_register(RovodevIntegration())
|
||||
_register(ShaiIntegration())
|
||||
_register(TabnineIntegration())
|
||||
_register(TraeIntegration())
|
||||
|
||||
@@ -162,6 +162,45 @@ class IntegrationBase(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def cli_executable(self) -> str:
|
||||
"""Executable name used for CLI availability detection.
|
||||
|
||||
Defaults to ``self.key``. Integrations whose CLI binary name
|
||||
differs from the integration key should override this property.
|
||||
For example, RovoDev's key is ``"rovodev"`` but the binary is
|
||||
``"acli"``, so its override returns ``"acli"``.
|
||||
|
||||
This property is used by :meth:`is_cli_available` and by
|
||||
``check_tool()`` when checking whether the integration's CLI
|
||||
tool is installed. It intentionally does **not** honour the
|
||||
``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE`` env-var override — that
|
||||
variable controls which binary is *executed* at runtime (see
|
||||
:meth:`_resolve_executable`), whereas ``cli_executable`` names
|
||||
the tool to *detect* on ``PATH``.
|
||||
|
||||
See issue #2597.
|
||||
"""
|
||||
return self.key
|
||||
|
||||
def is_cli_available(self) -> bool:
|
||||
"""Return ``True`` if this integration's CLI tool is installed.
|
||||
|
||||
The default implementation checks ``shutil.which(self.cli_executable)``.
|
||||
Integrations with non-standard install locations or multiple
|
||||
possible binary names should override this method.
|
||||
|
||||
Examples of integrations that override this:
|
||||
|
||||
* **ClaudeIntegration** — also checks ``~/.claude/local/`` paths
|
||||
that are not on ``PATH``.
|
||||
* **KiroCliIntegration** — accepts both ``kiro-cli`` and the
|
||||
legacy ``kiro`` binary name.
|
||||
|
||||
See issue #2597.
|
||||
"""
|
||||
return shutil.which(self.cli_executable) is not None
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the executable for this integration's CLI tool.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -45,6 +46,27 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
context_file = "CLAUDE.md"
|
||||
multi_install_safe = True
|
||||
|
||||
def is_cli_available(self) -> bool:
|
||||
"""Return ``True`` if the Claude Code CLI is installed.
|
||||
|
||||
Claude Code can be installed in multiple locations, not all of
|
||||
which are on ``PATH``:
|
||||
|
||||
1. ``~/.claude/local/claude`` — ``claude migrate-installer``
|
||||
2. ``~/.claude/local/node_modules/.bin/claude`` — npm-local install (nvm)
|
||||
3. Anywhere on ``PATH`` — global npm install
|
||||
|
||||
See issues #123, #550, and #2597.
|
||||
"""
|
||||
import specify_cli._utils as _utils_mod
|
||||
|
||||
if (
|
||||
_utils_mod.CLAUDE_LOCAL_PATH.is_file()
|
||||
or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file()
|
||||
):
|
||||
return True
|
||||
return shutil.which(self.cli_executable) is not None
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Kiro CLI integration."""
|
||||
|
||||
import shutil
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
@@ -27,3 +29,17 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def is_cli_available(self) -> bool:
|
||||
"""Return ``True`` if the Kiro CLI is installed.
|
||||
|
||||
Kiro ships under two binary names: ``kiro-cli`` (preferred) and
|
||||
the legacy ``kiro`` alias. Either name satisfies the availability
|
||||
check so existing installations continue to work.
|
||||
|
||||
See issue #2597.
|
||||
"""
|
||||
return (
|
||||
shutil.which("kiro-cli") is not None
|
||||
or shutil.which("kiro") is not None
|
||||
)
|
||||
|
||||
263
src/specify_cli/integrations/rovodev/__init__.py
Normal file
263
src/specify_cli/integrations/rovodev/__init__.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""RovoDev integration — Atlassian Rovo Dev via ``acli rovodev``.
|
||||
|
||||
Extends ``SkillsIntegration`` to generate skill files under
|
||||
``.rovodev/skills/`` and additionally generates prompt wrappers
|
||||
under ``.rovodev/prompts/`` and a ``prompts.yml`` manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class RovodevIntegration(SkillsIntegration):
|
||||
"""Integration for Atlassian Rovo Dev.
|
||||
|
||||
Uses the skills layout (``speckit-<name>/SKILL.md``) and adds
|
||||
prompt wrappers plus a ``prompts.yml`` manifest on top.
|
||||
Runtime execution dispatches through ``acli rovodev``.
|
||||
"""
|
||||
|
||||
key = "rovodev"
|
||||
config = {
|
||||
"name": "RovoDev ACLI",
|
||||
"folder": ".rovodev/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://www.atlassian.com/software/rovo-dev",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".rovodev/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
# -- CLI dispatch ------------------------------------------------------
|
||||
|
||||
@property
|
||||
def cli_executable(self) -> str:
|
||||
"""Executable name for CLI availability detection (``acli``).
|
||||
|
||||
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the
|
||||
host binary; ``rovodev`` is a sub-command. The integration key
|
||||
is ``"rovodev"``, but the binary to detect on ``PATH`` is
|
||||
``"acli"``.
|
||||
|
||||
See issue #2597.
|
||||
"""
|
||||
return "acli"
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the binary to invoke (``acli``).
|
||||
|
||||
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the executable
|
||||
and ``rovodev`` is a subcommand. The base implementation falls back
|
||||
to ``self.key`` (``"rovodev"``), which is the wrong binary, so we
|
||||
override the fallback to ``"acli"`` while still honouring the
|
||||
standard ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` env-var override.
|
||||
"""
|
||||
env_name = (
|
||||
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE"
|
||||
)
|
||||
override = os.environ.get(env_name, "").strip()
|
||||
return override if override else "acli"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build non-interactive ACLI args for RovoDev.
|
||||
|
||||
RovoDev supports a positional ``message`` for non-interactive runs.
|
||||
``output_json`` maps to ``--output-schema`` so dispatch callers can
|
||||
request structured output.
|
||||
|
||||
The integration currently does not apply ``model`` overrides because
|
||||
the expected config shape for ``--config-override`` is not yet wired
|
||||
in this adapter.
|
||||
|
||||
Honours the standard env-var contract:
|
||||
- ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` overrides ``acli``
|
||||
- ``SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS`` injects extra CLI flags
|
||||
"""
|
||||
_ = model
|
||||
args = [self._resolve_executable(), "rovodev", "run", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if output_json:
|
||||
args.extend([
|
||||
"--output-schema",
|
||||
'{"type": "object", "properties": {"result": {"type": "string"}}}',
|
||||
])
|
||||
return args
|
||||
|
||||
|
||||
# -- Prompt wrapper + manifest generation ------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _render_prompt_wrapper(skill_name: str) -> str:
|
||||
return f"use skill {skill_name} $ARGUMENTS\n"
|
||||
|
||||
def _generate_prompt_files(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
skill_paths: list[Path],
|
||||
) -> tuple[list[Path], list[dict[str, str]]]:
|
||||
"""Create thin prompt wrappers for each SKILL.md.
|
||||
|
||||
Skill name is derived from the parent directory name
|
||||
(e.g. ``.rovodev/skills/speckit-plan/SKILL.md`` → ``speckit-plan``).
|
||||
|
||||
Returns (created_files, prompt_entries) where prompt_entries are
|
||||
dicts suitable for inclusion in ``prompts.yml``.
|
||||
"""
|
||||
prompts_dir = project_root / ".rovodev" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
created: list[Path] = []
|
||||
prompt_entries: list[dict[str, str]] = []
|
||||
|
||||
for skill_path in skill_paths:
|
||||
if skill_path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
skill_name = skill_path.parent.name
|
||||
if not skill_name:
|
||||
continue
|
||||
|
||||
prompt_filename = f"{skill_name}.prompt.md"
|
||||
prompt_file = self.write_file_and_record(
|
||||
self._render_prompt_wrapper(skill_name),
|
||||
prompts_dir / prompt_filename,
|
||||
project_root,
|
||||
manifest,
|
||||
)
|
||||
created.append(prompt_file)
|
||||
|
||||
prompt_entries.append({
|
||||
"name": skill_name,
|
||||
"description": f"Invoke {skill_name} skill",
|
||||
"content_file": f"prompts/{prompt_filename}",
|
||||
})
|
||||
|
||||
return created, prompt_entries
|
||||
|
||||
@staticmethod
|
||||
def _read_prompts_yml(path: Path) -> list[dict[str, Any]]:
|
||||
"""Read prompt entries from an existing ``prompts.yml``.
|
||||
|
||||
Returns an empty list if the file is missing, malformed, or
|
||||
contains no valid prompt entries.
|
||||
"""
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
prompts = data.get("prompts")
|
||||
if not isinstance(prompts, list):
|
||||
return []
|
||||
return [dict(item) for item in prompts if isinstance(item, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _merge_prompt_entries(
|
||||
existing: list[dict[str, Any]],
|
||||
generated: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge *generated* entries into *existing*, preserving user additions.
|
||||
|
||||
- Existing entries whose ``name`` matches a generated entry are
|
||||
replaced in-place (preserving the user's ordering).
|
||||
- Generated entries not already present are appended at the end.
|
||||
- User-added entries (no matching generated name) are kept as-is.
|
||||
"""
|
||||
generated_by_name = {e["name"]: e for e in generated if e.get("name")}
|
||||
|
||||
merged: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in existing:
|
||||
name = entry.get("name", "")
|
||||
if name in generated_by_name:
|
||||
merged.append(generated_by_name[name])
|
||||
seen.add(name)
|
||||
else:
|
||||
merged.append(entry)
|
||||
|
||||
for entry in generated:
|
||||
if entry.get("name", "") not in seen:
|
||||
merged.append(entry)
|
||||
|
||||
return merged
|
||||
|
||||
def _merge_prompts_manifest(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
prompt_entries: list[dict[str, str]],
|
||||
) -> Path | None:
|
||||
"""Write ``prompts.yml``, merging with any existing user entries."""
|
||||
if not prompt_entries:
|
||||
return None
|
||||
|
||||
prompts_yml = project_root / ".rovodev" / "prompts.yml"
|
||||
existing = self._read_prompts_yml(prompts_yml)
|
||||
merged = self._merge_prompt_entries(existing, prompt_entries)
|
||||
|
||||
content = yaml.safe_dump(
|
||||
{"prompts": merged},
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=10_000,
|
||||
)
|
||||
return self.write_file_and_record(
|
||||
content, prompts_yml, project_root, manifest,
|
||||
)
|
||||
|
||||
# -- setup() -----------------------------------------------------------
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install RovoDev skills, then generate prompt wrappers and manifest.
|
||||
|
||||
1. ``SkillsIntegration.setup()`` generates skill files and
|
||||
upserts the context section.
|
||||
2. Generates prompt wrappers and ``prompts.yml`` for each skill
|
||||
created in step 1.
|
||||
"""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Generate prompt wrappers + merge prompts.yml
|
||||
prompt_files, prompt_entries = self._generate_prompt_files(
|
||||
project_root, manifest, created
|
||||
)
|
||||
created.extend(prompt_files)
|
||||
|
||||
manifest_file = self._merge_prompts_manifest(
|
||||
project_root, manifest, prompt_entries
|
||||
)
|
||||
if manifest_file:
|
||||
created.append(manifest_file)
|
||||
|
||||
return created
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -126,12 +125,10 @@ class CommandStep(StepBase):
|
||||
if impl is None:
|
||||
return None
|
||||
|
||||
# Check if the integration supports CLI dispatch
|
||||
if impl.build_exec_args("test") is None:
|
||||
return None
|
||||
|
||||
# Check if the CLI tool is actually installed
|
||||
if not shutil.which(impl.key):
|
||||
# Check if the CLI tool is actually installed via the integration's
|
||||
# own availability check (honours custom executables, dual binaries,
|
||||
# and non-PATH install paths). See issue #2597.
|
||||
if not impl.is_cli_available():
|
||||
return None
|
||||
|
||||
project_root = Path(context.project_root) if context.project_root else None
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -115,10 +114,15 @@ class PromptStep(StepBase):
|
||||
return None
|
||||
|
||||
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
|
||||
if exec_args is None:
|
||||
|
||||
# Check if the CLI tool is actually installed via the integration's
|
||||
# own availability check (honours custom executables, dual binaries,
|
||||
# and non-PATH install paths). See issue #2597.
|
||||
if not impl.is_cli_available():
|
||||
return None
|
||||
|
||||
if not shutil.which(impl.key):
|
||||
# Prompt dispatch executes exec_args directly; require a non-empty argv.
|
||||
if not exec_args:
|
||||
return None
|
||||
|
||||
import subprocess
|
||||
|
||||
305
tests/integrations/test_integration_rovodev.py
Normal file
305
tests/integrations/test_integration_rovodev.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Tests for RovodevIntegration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from click.testing import Result
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
def _run_init(project, *flags: str) -> Result:
|
||||
"""Run ``specify init --here`` in *project* with the given extra flags.
|
||||
|
||||
Centralises the cwd-management boilerplate so individual tests just
|
||||
declare the flags they care about.
|
||||
"""
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
return CliRunner().invoke(
|
||||
app,
|
||||
["init", "--here", *flags, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rovodev_init_project(tmp_path):
|
||||
"""Run ``specify init --integration rovodev`` once and return the project root.
|
||||
|
||||
Shared across the slow init-inventory tests so we pay the full-CLI cost
|
||||
only once instead of three times.
|
||||
"""
|
||||
project = tmp_path / "rovodev-init"
|
||||
project.mkdir()
|
||||
result = _run_init(project, "--integration", "rovodev")
|
||||
assert result.exit_code == 0, result.output
|
||||
return project
|
||||
|
||||
|
||||
class TestRovodevIntegration:
|
||||
"""Rovodev-specific tests (not inherited from SkillsIntegrationTests because
|
||||
rovodev's setup() emits prompt wrappers + prompts.yml in addition to skills,
|
||||
which violates the base mixin's pure-skills assumptions)."""
|
||||
|
||||
KEY = "rovodev"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
# -- ACLI dispatch -----------------------------------------------------
|
||||
|
||||
def test_build_exec_args(self):
|
||||
impl = get_integration(self.KEY)
|
||||
args = impl.build_exec_args("/speckit.plan add OAuth")
|
||||
assert args[0:3] == ["acli", "rovodev", "run"]
|
||||
assert args[3] == "/speckit.plan add OAuth"
|
||||
assert "--output-schema" in args
|
||||
|
||||
def test_build_exec_args_without_json(self):
|
||||
impl = get_integration(self.KEY)
|
||||
args = impl.build_exec_args("/speckit.plan add OAuth", output_json=False)
|
||||
assert args == ["acli", "rovodev", "run", "/speckit.plan add OAuth"]
|
||||
|
||||
def test_build_exec_args_executable_env_override(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE overrides the binary path.
|
||||
|
||||
Lets operators pin a specific ``acli`` build or relocate the binary
|
||||
without modifying the integration. Mirrors codex/devin/claude/etc.
|
||||
"""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", "/opt/atl/bin/acli")
|
||||
impl = get_integration(self.KEY)
|
||||
args = impl.build_exec_args("hello", output_json=False)
|
||||
assert args == ["/opt/atl/bin/acli", "rovodev", "run", "hello"]
|
||||
|
||||
def test_build_exec_args_executable_env_blank_falls_back(self, monkeypatch):
|
||||
"""Whitespace/empty env override is treated as unset → default ``acli``."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", " ")
|
||||
impl = get_integration(self.KEY)
|
||||
args = impl.build_exec_args("hello", output_json=False)
|
||||
assert args[0] == "acli"
|
||||
|
||||
def test_build_exec_args_extra_args_env_injection(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS injects extra CLI flags.
|
||||
|
||||
Useful for CI or non-interactive contexts that need to pass flags
|
||||
the integration doesn't expose. Mirrors the contract on every other
|
||||
CLI integration (claude, codex, devin, …).
|
||||
"""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS", "--quiet --no-color")
|
||||
impl = get_integration(self.KEY)
|
||||
args = impl.build_exec_args("hello", output_json=False)
|
||||
assert args == [
|
||||
"acli", "rovodev", "run", "hello", "--quiet", "--no-color",
|
||||
]
|
||||
|
||||
# -- Setup-level: prompt wrappers + prompts.yml ------------------------
|
||||
|
||||
def test_setup_creates_prompts_and_manifest(self, tmp_path):
|
||||
impl = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = impl.setup(tmp_path, manifest)
|
||||
|
||||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||||
assert prompts_manifest in created
|
||||
assert prompts_manifest.exists()
|
||||
|
||||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||||
skills_dir = tmp_path / ".rovodev" / "skills"
|
||||
assert prompts_dir.is_dir()
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
templates = impl.list_command_templates()
|
||||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||||
skill_dirs = sorted(d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("speckit-"))
|
||||
assert len(prompt_files) == len(templates)
|
||||
assert len(skill_dirs) == len(templates)
|
||||
for skill_dir in skill_dirs:
|
||||
assert (skill_dir / "SKILL.md").exists()
|
||||
|
||||
def test_prompts_manifest_entries_well_formed(self, tmp_path):
|
||||
impl = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
impl.setup(tmp_path, manifest)
|
||||
|
||||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||||
assert list(data) == ["prompts"]
|
||||
entries = data["prompts"]
|
||||
assert entries
|
||||
for entry in entries:
|
||||
assert entry["name"].startswith("speckit-")
|
||||
assert entry["description"]
|
||||
content_file = tmp_path / ".rovodev" / entry["content_file"]
|
||||
assert content_file.exists(), f"Missing prompt file {content_file}"
|
||||
|
||||
def test_prompt_wrapper_format(self, tmp_path):
|
||||
"""Every prompt wrapper delegates to its paired skill via 'use skill ...'."""
|
||||
impl = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
impl.setup(tmp_path, manifest)
|
||||
|
||||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||||
assert prompt_files
|
||||
for prompt_file in prompt_files:
|
||||
skill_name = prompt_file.name.removesuffix(".prompt.md")
|
||||
content = prompt_file.read_text(encoding="utf-8")
|
||||
assert content == f"use skill {skill_name} $ARGUMENTS\n", (
|
||||
f"{prompt_file} has unexpected wrapper format"
|
||||
)
|
||||
|
||||
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
|
||||
impl = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
|
||||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||||
prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
user_entry = {
|
||||
"name": "my-custom-prompt",
|
||||
"description": "User-added prompt",
|
||||
"content_file": "prompts/my-custom-prompt.md",
|
||||
}
|
||||
prompts_manifest.write_text(
|
||||
yaml.safe_dump({"prompts": [user_entry]}, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
impl.setup(tmp_path, manifest)
|
||||
|
||||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||||
names = {entry.get("name") for entry in data.get("prompts", [])}
|
||||
assert "my-custom-prompt" in names
|
||||
assert "speckit-plan" in names
|
||||
|
||||
def test_modified_prompts_yml_survives_uninstall(self, tmp_path):
|
||||
impl = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
impl.install(tmp_path, manifest)
|
||||
manifest.save()
|
||||
modified = tmp_path / ".rovodev" / "prompts.yml"
|
||||
modified.write_text("user modified this", encoding="utf-8")
|
||||
_, skipped = impl.uninstall(tmp_path, manifest)
|
||||
assert modified.exists()
|
||||
assert modified in skipped
|
||||
|
||||
# -- Full-CLI init: skills + prompts integration with extensions -------
|
||||
|
||||
def test_init_inventory(self, rovodev_init_project):
|
||||
"""Rovodev + extensions produce the expected skill / prompt set.
|
||||
|
||||
Contract:
|
||||
- Rovodev.setup() emits one SKILL.md + one .prompt.md per core template.
|
||||
- Extensions install additional SKILL.md directories with NO prompt wrapper.
|
||||
"""
|
||||
project = rovodev_init_project
|
||||
impl = get_integration(self.KEY)
|
||||
core_skill_names = {
|
||||
f"speckit-{t.stem.replace('.', '-')}"
|
||||
for t in impl.list_command_templates()
|
||||
}
|
||||
|
||||
prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit-*.prompt.md"))
|
||||
prompt_stems = {p.name.removesuffix(".prompt.md") for p in prompt_files}
|
||||
|
||||
skills_dir = project / ".rovodev" / "skills"
|
||||
skill_names = {
|
||||
d.name for d in skills_dir.iterdir()
|
||||
if d.is_dir() and d.name.startswith("speckit-")
|
||||
}
|
||||
|
||||
# Prompts: exactly the core template set.
|
||||
assert prompt_stems == core_skill_names
|
||||
|
||||
# Skills: core ∪ extension-installed.
|
||||
assert core_skill_names.issubset(skill_names)
|
||||
extension_skills = skill_names - core_skill_names
|
||||
assert extension_skills, (
|
||||
"Expected at least one extension-installed skill (e.g. agent-context)"
|
||||
)
|
||||
|
||||
# prompts.yml mirrors the prompt files exactly.
|
||||
prompts_manifest = project / ".rovodev" / "prompts.yml"
|
||||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||||
assert {e["name"] for e in data["prompts"]} == core_skill_names
|
||||
|
||||
def test_init_skill_files_well_formed(self, rovodev_init_project):
|
||||
"""Every speckit-* SKILL.md from full init has valid frontmatter +
|
||||
processed body, including extension-installed skills."""
|
||||
project = rovodev_init_project
|
||||
skills_dir = project / ".rovodev" / "skills"
|
||||
skill_dirs = sorted(
|
||||
d for d in skills_dir.iterdir()
|
||||
if d.is_dir() and d.name.startswith("speckit-")
|
||||
)
|
||||
assert skill_dirs
|
||||
|
||||
for skill_dir in skill_dirs:
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
assert skill_file.exists(), f"Missing {skill_file}"
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
|
||||
# Frontmatter delimited by leading '---\n' ... '\n---\n'
|
||||
assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
|
||||
fm_end = content.find("\n---\n", 4)
|
||||
assert fm_end != -1, f"{skill_file} has unterminated frontmatter"
|
||||
fm = yaml.safe_load(content[4:fm_end])
|
||||
body = content[fm_end + len("\n---\n"):]
|
||||
|
||||
assert fm.get("name") == skill_dir.name
|
||||
assert fm.get("description")
|
||||
assert body.strip(), f"{skill_file} has empty body"
|
||||
|
||||
for placeholder in ("{SCRIPT}", "__AGENT__", "__CONTEXT_FILE__", "__SPECKIT_COMMAND_"):
|
||||
assert placeholder not in body, (
|
||||
f"{skill_file} body contains unprocessed placeholder {placeholder!r}"
|
||||
)
|
||||
# Skills agents must use hyphen-style refs in body.
|
||||
assert "/speckit." not in body, (
|
||||
f"{skill_file} body contains dot-notation /speckit. reference"
|
||||
)
|
||||
|
||||
# The plan skill must reference the agent's context file.
|
||||
plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert self.CONTEXT_FILE in plan_content
|
||||
|
||||
# -- Full-CLI init: integration metadata -------------------------------
|
||||
|
||||
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
|
||||
"""Full init must produce an integration manifest and well-formed
|
||||
init-options.json — used by extensions, presets, and uninstall."""
|
||||
import json
|
||||
|
||||
project = rovodev_init_project
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "rovodev.manifest.json"
|
||||
speckit_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
assert manifest_path.exists(), "rovodev integration manifest missing"
|
||||
assert speckit_manifest.exists(), "speckit shared manifest missing"
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert init_options["integration"] == self.KEY
|
||||
assert init_options["ai"] == self.KEY
|
||||
# Rovodev is a SkillsIntegration, so ai_skills is auto-set.
|
||||
assert init_options.get("ai_skills") is True
|
||||
assert init_options.get("script") == "sh"
|
||||
|
||||
def test_ai_flag_auto_promotes_to_integration(self, tmp_path):
|
||||
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``."""
|
||||
project = tmp_path / "rovodev-ai"
|
||||
project.mkdir()
|
||||
result = _run_init(project, "--ai", "rovodev")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".rovodev" / "prompts.yml").exists()
|
||||
assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists()
|
||||
@@ -22,7 +22,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
"copilot",
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
|
||||
@@ -283,3 +283,27 @@ class TestAgentConfigConsistency:
|
||||
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
|
||||
"Skills agents must use hyphen notation."
|
||||
)
|
||||
|
||||
# --- RovoDev consistency checks ---
|
||||
|
||||
def test_rovodev_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include rovodev with skills-based scaffold metadata."""
|
||||
assert "rovodev" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["rovodev"]["folder"] == ".rovodev/"
|
||||
assert AGENT_CONFIG["rovodev"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["rovodev"]["requires_cli"] is True
|
||||
|
||||
def test_rovodev_in_extension_registrar(self):
|
||||
"""CommandRegistrar.AGENT_CONFIGS should include rovodev skill scaffold metadata."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "rovodev" in cfg
|
||||
rovodev_cfg = cfg["rovodev"]
|
||||
assert rovodev_cfg["dir"] == ".rovodev/skills"
|
||||
assert rovodev_cfg["format"] == "markdown"
|
||||
assert rovodev_cfg["args"] == "$ARGUMENTS"
|
||||
assert rovodev_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_ai_help_includes_rovodev(self):
|
||||
"""CLI help text for --ai should include rovodev."""
|
||||
assert "rovodev" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app, check_tool
|
||||
from specify_cli.integrations import get_integration
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
@@ -111,6 +112,107 @@ class TestCheckToolOther:
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
assert check_tool("kiro-cli") is True
|
||||
|
||||
def test_rovodev_uses_acli_executable(self):
|
||||
"""rovodev should resolve through the shared acli executable."""
|
||||
|
||||
def fake_which(name):
|
||||
return "/usr/bin/acli" if name == "acli" else None
|
||||
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
assert check_tool("rovodev") is True
|
||||
|
||||
|
||||
class TestIsCliAvailable:
|
||||
"""Integration.is_cli_available() must encode correct detection logic."""
|
||||
|
||||
def test_rovodev_cli_executable_is_acli(self):
|
||||
"""RovodevIntegration.cli_executable should return 'acli'."""
|
||||
impl = get_integration("rovodev")
|
||||
assert impl.cli_executable == "acli"
|
||||
|
||||
def test_rovodev_is_cli_available_uses_acli(self):
|
||||
"""RovodevIntegration.is_cli_available() checks for 'acli', not 'rovodev'."""
|
||||
impl = get_integration("rovodev")
|
||||
|
||||
with patch("shutil.which", side_effect=lambda name: "/usr/bin/acli" if name == "acli" else None):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert impl.is_cli_available() is False
|
||||
|
||||
def test_kiro_is_cli_available_accepts_kiro_cli(self):
|
||||
"""KiroCliIntegration.is_cli_available() returns True for 'kiro-cli' binary."""
|
||||
impl = get_integration("kiro-cli")
|
||||
|
||||
with patch("shutil.which", side_effect=lambda name: "/usr/bin/kiro-cli" if name == "kiro-cli" else None):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
def test_kiro_is_cli_available_accepts_legacy_kiro(self):
|
||||
"""KiroCliIntegration.is_cli_available() accepts the legacy 'kiro' binary."""
|
||||
impl = get_integration("kiro-cli")
|
||||
|
||||
with patch("shutil.which", side_effect=lambda name: "/usr/bin/kiro" if name == "kiro" else None):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
def test_kiro_is_cli_available_false_when_neither(self):
|
||||
"""KiroCliIntegration.is_cli_available() returns False when neither binary exists."""
|
||||
impl = get_integration("kiro-cli")
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert impl.is_cli_available() is False
|
||||
|
||||
def test_claude_is_cli_available_local_path(self, tmp_path):
|
||||
"""ClaudeIntegration.is_cli_available() finds claude via local path."""
|
||||
impl = get_integration("claude")
|
||||
fake_claude = tmp_path / "claude"
|
||||
fake_claude.touch()
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
def test_claude_is_cli_available_npm_local_path(self, tmp_path):
|
||||
"""ClaudeIntegration.is_cli_available() finds claude via npm-local path."""
|
||||
impl = get_integration("claude")
|
||||
fake_npm = tmp_path / "node_modules" / ".bin" / "claude"
|
||||
fake_npm.parent.mkdir(parents=True)
|
||||
fake_npm.touch()
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
def test_claude_is_cli_available_path(self, tmp_path):
|
||||
"""ClaudeIntegration.is_cli_available() finds claude via PATH."""
|
||||
impl = get_integration("claude")
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value="/usr/local/bin/claude"):
|
||||
assert impl.is_cli_available() is True
|
||||
|
||||
def test_claude_is_cli_available_not_found(self, tmp_path):
|
||||
"""ClaudeIntegration.is_cli_available() returns False when not installed."""
|
||||
impl = get_integration("claude")
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert impl.is_cli_available() is False
|
||||
|
||||
def test_default_integration_uses_key(self):
|
||||
"""Integrations without an override use key as cli_executable."""
|
||||
# Use a non-CLI integration to test the default; any MarkdownIntegration
|
||||
# without a cli_executable override works.
|
||||
impl = get_integration("gemini")
|
||||
assert impl.cli_executable == impl.key
|
||||
|
||||
|
||||
class TestCheckTip:
|
||||
"""`specify check` should point users to the existing version check."""
|
||||
|
||||
@@ -467,6 +467,15 @@ class TestBuildExecArgs:
|
||||
args = impl.build_exec_args("do stuff", output_json=False)
|
||||
assert "--output-format" not in args
|
||||
|
||||
def test_rovodev_exec_args(self):
|
||||
from specify_cli.integrations.rovodev import RovodevIntegration
|
||||
|
||||
impl = RovodevIntegration()
|
||||
args = impl.build_exec_args("/speckit.plan add OAuth")
|
||||
assert args[0:3] == ["acli", "rovodev", "run"]
|
||||
assert args[3] == "/speckit.plan add OAuth"
|
||||
assert "--output-schema" in args
|
||||
|
||||
|
||||
# ===== Step Type Tests =====
|
||||
|
||||
@@ -495,6 +504,37 @@ class TestCommandStep:
|
||||
assert result.output["integration"] == "claude"
|
||||
assert result.output["input"]["args"] == "login"
|
||||
|
||||
def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path):
|
||||
"""When acli is installed, rovodev dispatch succeeds via acli."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
ctx = StepContext(
|
||||
default_integration="rovodev",
|
||||
project_root=str(tmp_path),
|
||||
)
|
||||
config = {
|
||||
"id": "test",
|
||||
"command": "speckit.plan",
|
||||
"input": {"args": "add OAuth"},
|
||||
}
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which",
|
||||
lambda name: "/usr/bin/acli" if name == "acli" else None), \
|
||||
patch("subprocess.run", return_value=mock_result):
|
||||
result = step.execute(config, ctx)
|
||||
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["dispatched"] is True
|
||||
assert result.output["exit_code"] == 0
|
||||
|
||||
def test_validate_missing_command(self):
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
@@ -709,6 +749,37 @@ class TestPromptStep:
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["model"] == "opus-4"
|
||||
|
||||
def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path):
|
||||
"""When acli is installed, rovodev prompt dispatch succeeds via acli."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
ctx = StepContext(
|
||||
default_integration="rovodev",
|
||||
project_root=str(tmp_path),
|
||||
)
|
||||
config = {
|
||||
"id": "test",
|
||||
"type": "prompt",
|
||||
"prompt": "Explain this code",
|
||||
}
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which",
|
||||
lambda name: "/usr/bin/acli" if name == "acli" else None), \
|
||||
patch("subprocess.run", return_value=mock_result):
|
||||
result = step.execute(config, ctx)
|
||||
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["dispatched"] is True
|
||||
assert result.output["exit_code"] == 0
|
||||
|
||||
def test_dispatch_with_mock_cli(self, tmp_path):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
Reference in New Issue
Block a user