Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 10 additions & 1 deletion integrations/catalog.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Comment thread
mnriem marked this conversation as resolved.
found = shutil.which("acli") is not None
else:
found = shutil.which(tool) is not None

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,6 +105,7 @@ def _register_builtins() -> None:
_register(QodercliIntegration())
_register(QwenIntegration())
_register(RooIntegration())
_register(RovodevIntegration())
_register(ShaiIntegration())
_register(TabnineIntegration())
_register(TraeIntegration())
Expand Down
239 changes: 239 additions & 0 deletions src/specify_cli/integrations/rovodev/__init__.py
Original file line number Diff line number Diff line change
@@ -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-<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",
"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

15 changes: 9 additions & 6 deletions src/specify_cli/workflows/steps/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +132 to 138

project_root = Path(context.project_root) if context.project_root else None
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/workflows/steps/prompt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +119 to 129

import subprocess
Expand Down
Loading