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
This commit is contained in:
Samir Abed
2026-06-05 02:34:05 +10:00
committed by GitHub
parent 6355cec8de
commit 34ce66139e
12 changed files with 693 additions and 9 deletions

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` | | | [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
| [Roo Code](https://roocode.com/) | `roo` | | | [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` | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | | [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 | | [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |

View File

@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "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", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": { "integrations": {
"claude": { "claude": {
@@ -174,6 +174,15 @@
"repository": "https://github.com/github/spec-kit", "repository": "https://github.com/github/spec-kit",
"tags": ["ide"] "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": { "bob": {
"id": "bob", "id": "bob",
"name": "IBM Bob", "name": "IBM Bob",

View File

@@ -58,10 +58,13 @@ def check_tool(tool: str, tracker=None) -> bool:
tracker.complete(tool, "available") tracker.complete(tool, "available")
return True return True
# Per-integration executable resolution.
if tool == "kiro-cli": if tool == "kiro-cli":
# Kiro currently supports both executable names. Prefer kiro-cli and # Kiro currently supports both executable names. Prefer kiro-cli and
# accept kiro as a compatibility fallback. # accept kiro as a compatibility fallback.
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
elif tool == "rovodev":
found = shutil.which("acli") is not None
else: else:
found = shutil.which(tool) is not None found = shutil.which(tool) is not None

View File

@@ -74,6 +74,7 @@ def _register_builtins() -> None:
from .qodercli import QodercliIntegration from .qodercli import QodercliIntegration
from .qwen import QwenIntegration from .qwen import QwenIntegration
from .roo import RooIntegration from .roo import RooIntegration
from .rovodev import RovodevIntegration
from .shai import ShaiIntegration from .shai import ShaiIntegration
from .tabnine import TabnineIntegration from .tabnine import TabnineIntegration
from .trae import TraeIntegration from .trae import TraeIntegration
@@ -108,6 +109,7 @@ def _register_builtins() -> None:
_register(QodercliIntegration()) _register(QodercliIntegration())
_register(QwenIntegration()) _register(QwenIntegration())
_register(RooIntegration()) _register(RooIntegration())
_register(RovodevIntegration())
_register(ShaiIntegration()) _register(ShaiIntegration())
_register(TabnineIntegration()) _register(TabnineIntegration())
_register(TraeIntegration()) _register(TraeIntegration())

View File

@@ -0,0 +1,250 @@
"""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 ------------------------------------------------------
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

@@ -126,12 +126,15 @@ class CommandStep(StepBase):
if impl is None: if impl is None:
return None return None
# Check if the integration supports CLI dispatch # Build sample args for fallback executable detection when impl.key is not executable.
if impl.build_exec_args("test") is None: exec_args = impl.build_exec_args("test")
return None
# Check if the CLI tool is actually installed # Check if the CLI tool is actually installed.
if not shutil.which(impl.key): # Try the integration key first (covers most agents), then fall back
# to exec_args[0] for agents whose executable differs.
cli_path = shutil.which(impl.key)
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
if cli_path is None and fallback_cli_path is None:
return None return None
project_root = Path(context.project_root) if context.project_root else None project_root = Path(context.project_root) if context.project_root else None

View File

@@ -115,10 +115,17 @@ class PromptStep(StepBase):
return None return None
exec_args = impl.build_exec_args(prompt, model=model, output_json=False) 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.
# Try the integration key first (covers most agents), then fall back
# to exec_args[0] for agents whose executable differs.
cli_path = shutil.which(impl.key)
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
if cli_path is None and fallback_cli_path is None:
return None 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 return None
import subprocess 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", "copilot",
# Stage 3 — standard markdown integrations # Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie", "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", "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
# Stage 4 — TOML integrations # Stage 4 — TOML integrations
"gemini", "tabnine", "gemini", "tabnine",

View File

@@ -283,3 +283,27 @@ class TestAgentConfigConsistency:
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. " "Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
"Skills agents must use hyphen notation." "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

@@ -111,6 +111,15 @@ class TestCheckToolOther:
with patch("shutil.which", side_effect=fake_which): with patch("shutil.which", side_effect=fake_which):
assert check_tool("kiro-cli") is True 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 TestCheckTip: class TestCheckTip:
"""`specify check` should point users to the existing version check.""" """`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) args = impl.build_exec_args("do stuff", output_json=False)
assert "--output-format" not in args 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 ===== # ===== Step Type Tests =====
@@ -495,6 +504,37 @@ class TestCommandStep:
assert result.output["integration"] == "claude" assert result.output["integration"] == "claude"
assert result.output["input"]["args"] == "login" 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): def test_validate_missing_command(self):
from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.steps.command import CommandStep
@@ -709,6 +749,37 @@ class TestPromptStep:
result = step.execute(config, ctx) result = step.execute(config, ctx)
assert result.output["model"] == "opus-4" 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): def test_dispatch_with_mock_cli(self, tmp_path):
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.steps.prompt import PromptStep