mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -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 |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
250
src/specify_cli/integrations/rovodev/__init__.py
Normal file
250
src/specify_cli/integrations/rovodev/__init__.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user