Compare commits

..

5 Commits

Author SHA1 Message Date
Manfred Riem
c8664f9f6a Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-04 12:23:09 -05:00
copilot-swe-agent[bot]
fc9ce2cfec Changes before error encountered
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/9bf72d24-ce5d-4f1b-8803-d75f9c366793
2026-06-04 16:47:21 +00:00
copilot-swe-agent[bot]
d24d3b18cf Initial plan 2026-06-04 16:35:06 +00:00
Samir Abed
34ce66139e feat: add support for rovodev (#2539)
* feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev
2026-06-04 11:34:05 -05:00
Manfred Riem
6355cec8de chore: release 0.9.4, begin 0.9.5.dev0 development (#2853)
* chore: bump version to 0.9.4

* chore: begin 0.9.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-04 11:19:11 -05:00
17 changed files with 928 additions and 35 deletions

View File

@@ -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.

View File

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

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.9.4"
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 = [

View File

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

View File

@@ -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())

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
)

View 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

View File

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

View File

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

View 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()

View File

@@ -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",

View File

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

View File

@@ -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."""

View File

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