diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..762aeede9a 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -31,6 +31,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 | diff --git a/integrations/catalog.json b/integrations/catalog.json index 16e321cf58..746c1bab6d 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-05-02T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -165,6 +165,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", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d3eb36391e..ca25e11011 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -441,10 +441,13 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: tracker.complete(tool, "available") return True + # Per-integration executable resolution. 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 + elif tool == "rovodev": + found = shutil.which("acli") is not None else: found = shutil.which(tool) is not None diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 4a78e7d035..901c0fe36e 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -72,6 +72,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 @@ -104,6 +105,7 @@ def _register_builtins() -> None: _register(QodercliIntegration()) _register(QwenIntegration()) _register(RooIntegration()) + _register(RovodevIntegration()) _register(ShaiIntegration()) _register(TabnineIntegration()) _register(TraeIntegration()) diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py new file mode 100644 index 0000000000..dc3e5e0f22 --- /dev/null +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -0,0 +1,239 @@ +"""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 + +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-/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", + "requires_cli": True, + } + registrar_config = { + "dir": ".rovodev/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".prompt.md", + } + context_file = "AGENTS.md" + + + # -- CLI dispatch ------------------------------------------------------ + + 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. + """ + _ = model + args = ["acli", "rovodev", "run", prompt] + if output_json: + args.extend([ + "--output-schema", + '{"type": "object", "properties": {"result": {"type": "string"}}}', + ]) + return args + + + # -- Prompt wrapper + manifest generation ------------------------------ + + @staticmethod + def _render_prompt_wrapper(command_name: str) -> str: + return f"use skill {command_name} $ARGUMENTS\n" + + @staticmethod + def _skill_name_to_dot_name(skill_name: str) -> str: + """Convert ``speckit-git-commit`` to ``speckit.git.commit``.""" + return skill_name.replace("-", ".") + + 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. + + Derives the skill name from the parent directory + (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 is the parent directory name (e.g. speckit-plan) + skill_name = skill_path.parent.name + if not skill_name: + continue + + dot_name = self._skill_name_to_dot_name(skill_name) + prompt_filename = f"{dot_name}.prompt.md" + prompt_content = self._render_prompt_wrapper(skill_name) + prompt_file = self.write_file_and_record( + prompt_content, + prompts_dir / prompt_filename, + project_root, + manifest, + ) + created.append(prompt_file) + + prompt_entries.append({ + "name": dot_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: + 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, str]] = [] + 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. Generate prompt wrappers and ``prompts.yml``. + """ + 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 + diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py index 21fd4837d1..891b9da4e7 100644 --- a/src/specify_cli/workflows/steps/command/__init__.py +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -126,12 +126,15 @@ def _try_dispatch( 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): + # Build sample args for fallback executable detection when impl.key is not executable. + exec_args = impl.build_exec_args("test") + + # 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 project_root = Path(context.project_root) if context.project_root else None diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py index 44fa22508b..5ec99b794d 100644 --- a/src/specify_cli/workflows/steps/prompt/__init__.py +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -115,10 +115,17 @@ def _try_dispatch( 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. + # 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 - 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 diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py new file mode 100644 index 0000000000..72fe12fe6a --- /dev/null +++ b/tests/integrations/test_integration_rovodev.py @@ -0,0 +1,266 @@ +"""Tests for RovodevIntegration.""" + +from __future__ import annotations + +import json +import os + +import yaml + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestRovodevIntegration: + KEY = "rovodev" + + def test_key_and_config(self): + impl = get_integration(self.KEY) + assert impl is not None + assert impl.key == self.KEY + assert impl.config["folder"] == ".rovodev/" + assert impl.config["commands_subdir"] == "skills" + assert impl.registrar_config["dir"] == ".rovodev/prompts" + assert impl.registrar_config["extension"] == ".prompt.md" + assert impl.context_file == "AGENTS.md" + + def test_inherited_command_filename_for_base_compatibility(self): + impl = get_integration(self.KEY) + # RovoDev scaffolding does not use command_filename directly (skills + + # prompt wrappers), but this guards inherited IntegrationBase behavior. + assert impl.command_filename("plan") == "speckit.plan.md" + + 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_setup_creates_skills_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) + # Each skill dir has a SKILL.md + for skill_dir in skill_dirs: + assert (skill_dir / "SKILL.md").exists() + + def test_prompts_manifest_references_existing_files(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_files_delegate_to_paired_skills(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + impl.setup(tmp_path, manifest) + + prompt_file = tmp_path / ".rovodev" / "prompts" / "speckit.plan.prompt.md" + content = prompt_file.read_text(encoding="utf-8") + assert content == "use skill speckit-plan $ARGUMENTS\n" + + def test_skill_name_conversion_handles_multi_segment_names(self): + impl = get_integration(self.KEY) + assert impl._skill_name_to_dot_name("speckit-plan") == "speckit.plan" + assert ( + impl._skill_name_to_dot_name("speckit-git-commit") + == "speckit.git.commit" + ) + + 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) + prompts_manifest.write_text( + yaml.safe_dump( + { + "prompts": [ + { + "name": "custom.user.prompt", + "description": "User-added prompt", + "content_file": "prompts/custom.user.prompt.md", + } + ] + }, + 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 "custom.user.prompt" in names + assert "speckit.plan" in names + + def test_skill_files_have_rovodev_frontmatter(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + impl.setup(tmp_path, manifest) + + skill_file = tmp_path / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md" + content = skill_file.read_text(encoding="utf-8") + assert content.startswith("---\n") + first_end = content.find("\n---\n") + assert first_end != -1 + frontmatter = yaml.safe_load(content[4:first_end]) + assert frontmatter["name"] == "speckit-plan" + assert "description" in frontmatter + body = content[first_end + len("\n---\n"):] + assert not body.lstrip().startswith("---\n") + + def test_skill_templates_are_processed(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + impl.setup(tmp_path, manifest) + + skills_dir = tmp_path / ".rovodev" / "skills" + for skill_dir in skills_dir.iterdir(): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if not skill_file.exists(): + continue + content = skill_file.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content + assert "\nscripts:\n" not in content + + + def test_setup_upserts_context_section(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + impl.setup(tmp_path, manifest) + ctx_path = tmp_path / impl.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_all_created_files_tracked_in_manifest(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + created = impl.setup(tmp_path, manifest) + for path in created: + rel = path.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in manifest.files + + def test_install_uninstall_roundtrip(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + created = impl.install(tmp_path, manifest) + manifest.save() + removed, skipped = impl.uninstall(tmp_path, manifest) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + impl = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + created = impl.install(tmp_path, manifest) + manifest.save() + modified = tmp_path / ".rovodev" / "prompts.yml" + modified.write_text("user modified this", encoding="utf-8") + removed, skipped = impl.uninstall(tmp_path, manifest) + assert modified.exists() + assert modified in skipped + + def test_init_inventory_sh(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "rovodev-inventory" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + "rovodev", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + prompts_manifest = project / ".rovodev" / "prompts.yml" + assert prompts_manifest.exists() + prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit.*.prompt.md")) + 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 len(prompt_files) == 9 + assert len(skill_dirs) == 9 + assert (project / "AGENTS.md").exists() + assert (project / ".specify" / "integration.json").exists() + assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists() + assert (project / ".specify" / "integrations" / "speckit.manifest.json").exists() + data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8")) + assert len(data["prompts"]) == 9 + + def test_init_options_include_context_file(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opts" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + "rovodev", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + init_options = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert init_options["context_file"] == "AGENTS.md" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 1b36501056..18803c485f 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -22,7 +22,7 @@ "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", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 2f0fe15127..668676e677 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -283,3 +283,28 @@ def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): "Found dot-notation command ref (/speckit.) 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 prompt-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 prompt wrapper metadata.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "rovodev" in cfg + rovodev_cfg = cfg["rovodev"] + assert rovodev_cfg["dir"] == ".rovodev/prompts" + assert rovodev_cfg["format"] == "markdown" + assert rovodev_cfg["args"] == "$ARGUMENTS" + assert rovodev_cfg["extension"] == ".prompt.md" + + def test_ai_help_includes_rovodev(self): + """CLI help text for --ai should include rovodev.""" + assert "rovodev" in AI_ASSISTANT_HELP + diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 0eb267ba24..6492d52ea1 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -93,4 +93,13 @@ def fake_which(name): return "/usr/bin/kiro" if name == "kiro" else None with patch("shutil.which", side_effect=fake_which): - assert check_tool("kiro-cli") is True \ No newline at end of file + 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 diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 4c042fc7d5..fc271b938c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -427,6 +427,15 @@ def test_no_json_omits_flag(self): 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 ===== @@ -455,6 +464,37 @@ def test_execute_basic(self): 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 @@ -655,6 +695,37 @@ def test_execute_with_model(self): 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