Skip to content

Commit 34ce661

Browse files
authored
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
1 parent 6355cec commit 34ce661

12 files changed

Lines changed: 694 additions & 10 deletions

File tree

docs/reference/integrations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
3333
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
3434
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
3535
| [Roo Code](https://roocode.com/) | `roo` | |
36+
| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` |
3637
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
3738
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
3839
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |

integrations/catalog.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": "1.0",
3-
"updated_at": "2026-05-13T00:00:00Z",
3+
"updated_at": "2026-06-02T00:00:00Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
55
"integrations": {
66
"claude": {
@@ -174,6 +174,15 @@
174174
"repository": "https://github.com/github/spec-kit",
175175
"tags": ["ide"]
176176
},
177+
"rovodev": {
178+
"id": "rovodev",
179+
"name": "RovoDev ACLI",
180+
"version": "1.0.0",
181+
"description": "Atlassian RovoDev integration",
182+
"author": "spec-kit-core",
183+
"repository": "https://github.com/github/spec-kit",
184+
"tags": ["cli", "atlassian"]
185+
},
177186
"bob": {
178187
"id": "bob",
179188
"name": "IBM Bob",

src/specify_cli/_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ def check_tool(tool: str, tracker=None) -> bool:
5858
tracker.complete(tool, "available")
5959
return True
6060

61+
# Per-integration executable resolution.
6162
if tool == "kiro-cli":
6263
# Kiro currently supports both executable names. Prefer kiro-cli and
6364
# accept kiro as a compatibility fallback.
6465
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
66+
elif tool == "rovodev":
67+
found = shutil.which("acli") is not None
6568
else:
6669
found = shutil.which(tool) is not None
6770

src/specify_cli/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def _register_builtins() -> None:
7474
from .qodercli import QodercliIntegration
7575
from .qwen import QwenIntegration
7676
from .roo import RooIntegration
77+
from .rovodev import RovodevIntegration
7778
from .shai import ShaiIntegration
7879
from .tabnine import TabnineIntegration
7980
from .trae import TraeIntegration
@@ -108,6 +109,7 @@ def _register_builtins() -> None:
108109
_register(QodercliIntegration())
109110
_register(QwenIntegration())
110111
_register(RooIntegration())
112+
_register(RovodevIntegration())
111113
_register(ShaiIntegration())
112114
_register(TabnineIntegration())
113115
_register(TraeIntegration())
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""RovoDev integration — Atlassian Rovo Dev via ``acli rovodev``.
2+
3+
Extends ``SkillsIntegration`` to generate skill files under
4+
``.rovodev/skills/`` and additionally generates prompt wrappers
5+
under ``.rovodev/prompts/`` and a ``prompts.yml`` manifest.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
from pathlib import Path
12+
from typing import Any
13+
14+
import yaml
15+
16+
from ..base import SkillsIntegration
17+
from ..manifest import IntegrationManifest
18+
19+
20+
class RovodevIntegration(SkillsIntegration):
21+
"""Integration for Atlassian Rovo Dev.
22+
23+
Uses the skills layout (``speckit-<name>/SKILL.md``) and adds
24+
prompt wrappers plus a ``prompts.yml`` manifest on top.
25+
Runtime execution dispatches through ``acli rovodev``.
26+
"""
27+
28+
key = "rovodev"
29+
config = {
30+
"name": "RovoDev ACLI",
31+
"folder": ".rovodev/",
32+
"commands_subdir": "skills",
33+
"install_url": "https://www.atlassian.com/software/rovo-dev",
34+
"requires_cli": True,
35+
}
36+
registrar_config = {
37+
"dir": ".rovodev/skills",
38+
"format": "markdown",
39+
"args": "$ARGUMENTS",
40+
"extension": "/SKILL.md",
41+
}
42+
context_file = "AGENTS.md"
43+
44+
# -- CLI dispatch ------------------------------------------------------
45+
46+
def _resolve_executable(self) -> str:
47+
"""Return the binary to invoke (``acli``).
48+
49+
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the executable
50+
and ``rovodev`` is a subcommand. The base implementation falls back
51+
to ``self.key`` (``"rovodev"``), which is the wrong binary, so we
52+
override the fallback to ``"acli"`` while still honouring the
53+
standard ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` env-var override.
54+
"""
55+
env_name = (
56+
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE"
57+
)
58+
override = os.environ.get(env_name, "").strip()
59+
return override if override else "acli"
60+
61+
def build_exec_args(
62+
self,
63+
prompt: str,
64+
*,
65+
model: str | None = None,
66+
output_json: bool = True,
67+
) -> list[str] | None:
68+
"""Build non-interactive ACLI args for RovoDev.
69+
70+
RovoDev supports a positional ``message`` for non-interactive runs.
71+
``output_json`` maps to ``--output-schema`` so dispatch callers can
72+
request structured output.
73+
74+
The integration currently does not apply ``model`` overrides because
75+
the expected config shape for ``--config-override`` is not yet wired
76+
in this adapter.
77+
78+
Honours the standard env-var contract:
79+
- ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` overrides ``acli``
80+
- ``SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS`` injects extra CLI flags
81+
"""
82+
_ = model
83+
args = [self._resolve_executable(), "rovodev", "run", prompt]
84+
self._apply_extra_args_env_var(args)
85+
if output_json:
86+
args.extend([
87+
"--output-schema",
88+
'{"type": "object", "properties": {"result": {"type": "string"}}}',
89+
])
90+
return args
91+
92+
93+
# -- Prompt wrapper + manifest generation ------------------------------
94+
95+
@staticmethod
96+
def _render_prompt_wrapper(skill_name: str) -> str:
97+
return f"use skill {skill_name} $ARGUMENTS\n"
98+
99+
def _generate_prompt_files(
100+
self,
101+
project_root: Path,
102+
manifest: IntegrationManifest,
103+
skill_paths: list[Path],
104+
) -> tuple[list[Path], list[dict[str, str]]]:
105+
"""Create thin prompt wrappers for each SKILL.md.
106+
107+
Skill name is derived from the parent directory name
108+
(e.g. ``.rovodev/skills/speckit-plan/SKILL.md`` → ``speckit-plan``).
109+
110+
Returns (created_files, prompt_entries) where prompt_entries are
111+
dicts suitable for inclusion in ``prompts.yml``.
112+
"""
113+
prompts_dir = project_root / ".rovodev" / "prompts"
114+
prompts_dir.mkdir(parents=True, exist_ok=True)
115+
116+
created: list[Path] = []
117+
prompt_entries: list[dict[str, str]] = []
118+
119+
for skill_path in skill_paths:
120+
if skill_path.name != "SKILL.md":
121+
continue
122+
123+
skill_name = skill_path.parent.name
124+
if not skill_name:
125+
continue
126+
127+
prompt_filename = f"{skill_name}.prompt.md"
128+
prompt_file = self.write_file_and_record(
129+
self._render_prompt_wrapper(skill_name),
130+
prompts_dir / prompt_filename,
131+
project_root,
132+
manifest,
133+
)
134+
created.append(prompt_file)
135+
136+
prompt_entries.append({
137+
"name": skill_name,
138+
"description": f"Invoke {skill_name} skill",
139+
"content_file": f"prompts/{prompt_filename}",
140+
})
141+
142+
return created, prompt_entries
143+
144+
@staticmethod
145+
def _read_prompts_yml(path: Path) -> list[dict[str, Any]]:
146+
"""Read prompt entries from an existing ``prompts.yml``.
147+
148+
Returns an empty list if the file is missing, malformed, or
149+
contains no valid prompt entries.
150+
"""
151+
if not path.exists():
152+
return []
153+
try:
154+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
155+
except (yaml.YAMLError, OSError, UnicodeError):
156+
return []
157+
if not isinstance(data, dict):
158+
return []
159+
prompts = data.get("prompts")
160+
if not isinstance(prompts, list):
161+
return []
162+
return [dict(item) for item in prompts if isinstance(item, dict)]
163+
164+
@staticmethod
165+
def _merge_prompt_entries(
166+
existing: list[dict[str, Any]],
167+
generated: list[dict[str, Any]],
168+
) -> list[dict[str, Any]]:
169+
"""Merge *generated* entries into *existing*, preserving user additions.
170+
171+
- Existing entries whose ``name`` matches a generated entry are
172+
replaced in-place (preserving the user's ordering).
173+
- Generated entries not already present are appended at the end.
174+
- User-added entries (no matching generated name) are kept as-is.
175+
"""
176+
generated_by_name = {e["name"]: e for e in generated if e.get("name")}
177+
178+
merged: list[dict[str, Any]] = []
179+
seen: set[str] = set()
180+
181+
for entry in existing:
182+
name = entry.get("name", "")
183+
if name in generated_by_name:
184+
merged.append(generated_by_name[name])
185+
seen.add(name)
186+
else:
187+
merged.append(entry)
188+
189+
for entry in generated:
190+
if entry.get("name", "") not in seen:
191+
merged.append(entry)
192+
193+
return merged
194+
195+
def _merge_prompts_manifest(
196+
self,
197+
project_root: Path,
198+
manifest: IntegrationManifest,
199+
prompt_entries: list[dict[str, str]],
200+
) -> Path | None:
201+
"""Write ``prompts.yml``, merging with any existing user entries."""
202+
if not prompt_entries:
203+
return None
204+
205+
prompts_yml = project_root / ".rovodev" / "prompts.yml"
206+
existing = self._read_prompts_yml(prompts_yml)
207+
merged = self._merge_prompt_entries(existing, prompt_entries)
208+
209+
content = yaml.safe_dump(
210+
{"prompts": merged},
211+
default_flow_style=False,
212+
sort_keys=False,
213+
allow_unicode=True,
214+
width=10_000,
215+
)
216+
return self.write_file_and_record(
217+
content, prompts_yml, project_root, manifest,
218+
)
219+
220+
# -- setup() -----------------------------------------------------------
221+
222+
def setup(
223+
self,
224+
project_root: Path,
225+
manifest: IntegrationManifest,
226+
parsed_options: dict[str, Any] | None = None,
227+
**opts: Any,
228+
) -> list[Path]:
229+
"""Install RovoDev skills, then generate prompt wrappers and manifest.
230+
231+
1. ``SkillsIntegration.setup()`` generates skill files and
232+
upserts the context section.
233+
2. Generates prompt wrappers and ``prompts.yml`` for each skill
234+
created in step 1.
235+
"""
236+
created = super().setup(project_root, manifest, parsed_options, **opts)
237+
238+
# Generate prompt wrappers + merge prompts.yml
239+
prompt_files, prompt_entries = self._generate_prompt_files(
240+
project_root, manifest, created
241+
)
242+
created.extend(prompt_files)
243+
244+
manifest_file = self._merge_prompts_manifest(
245+
project_root, manifest, prompt_entries
246+
)
247+
if manifest_file:
248+
created.append(manifest_file)
249+
250+
return created

src/specify_cli/workflows/steps/command/__init__.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,15 @@ def _try_dispatch(
126126
if impl is None:
127127
return None
128128

129-
# Check if the integration supports CLI dispatch
130-
if impl.build_exec_args("test") is None:
131-
return None
132-
133-
# Check if the CLI tool is actually installed
134-
if not shutil.which(impl.key):
129+
# Build sample args for fallback executable detection when impl.key is not executable.
130+
exec_args = impl.build_exec_args("test")
131+
132+
# Check if the CLI tool is actually installed.
133+
# Try the integration key first (covers most agents), then fall back
134+
# to exec_args[0] for agents whose executable differs.
135+
cli_path = shutil.which(impl.key)
136+
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
137+
if cli_path is None and fallback_cli_path is None:
135138
return None
136139

137140
project_root = Path(context.project_root) if context.project_root else None

src/specify_cli/workflows/steps/prompt/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,17 @@ def _try_dispatch(
115115
return None
116116

117117
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
118-
if exec_args is None:
118+
119+
# Check if the CLI tool is actually installed.
120+
# Try the integration key first (covers most agents), then fall back
121+
# to exec_args[0] for agents whose executable differs.
122+
cli_path = shutil.which(impl.key)
123+
fallback_cli_path = shutil.which(exec_args[0]) if exec_args else None
124+
if cli_path is None and fallback_cli_path is None:
119125
return None
120126

121-
if not shutil.which(impl.key):
127+
# Prompt dispatch executes exec_args directly; require a non-empty argv.
128+
if not exec_args:
122129
return None
123130

124131
import subprocess

0 commit comments

Comments
 (0)