feat(tools): add Atlassian Rovo Dev CLI as a first-class tool (#1516)

* feat(tools): add Atlassian Rovo Dev CLI as a first-class tool

Rovo Dev CLI loads project Agent Skills from `.rovodev/skills/<name>/SKILL.md`
(Atlassian docs), the same SKILL.md format OpenSpec generates. It was usable
only via the generic "Shared .agents skills" fallback; this makes it a named,
selectable target in `openspec init`.

Rovo has no slash-command surface, so it is registered as an adapterless
skills-only tool (like CodeArts/ForgeCode/Hermes) — no command adapter.

Closes #212

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): reference Rovo skills by natural language, not dead slash commands

Rovo Dev CLI has no slash-command surface — it matches skills
automatically or by prompt, and `/skills` only manages them. The
generated skills and the getting-started hint still advertised
`/openspec-*` slash commands (18 references across the skill bodies plus
the "Start your first change" hint), so every one was a dead command.

Adds a natural-language skill-reference path for no-slash tools:
`/opsx:<id>` now renders as "the openspec-<skill> skill" for rovodev, in
both skill bodies and the init hint. Other tools are unchanged.

- src/utils/command-references.ts: NATURAL_LANGUAGE_SKILL_TOOLS +
  usesNaturalLanguageSkillReferences(); getSkillReferenceTransformer
  returns the prose transformer for rovodev.
- src/core/init.ts: phrase the skills-only hint as an instruction for
  no-slash tools ("ask Rovo Dev CLI to use the openspec-propose skill…").
- docs/supported-tools.md: correct the Rovo row (was "use skill-based
  /openspec-* invocations").
- tests: assert generated Rovo skills contain no /openspec-* or /opsx
  slash tokens, the hint advertises no dead command, and the transformer
  emits prose.

Addresses alfred-openspec review on #1516.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-08-05 17:11:21 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 96a6548664
commit 13e213e00f
6 changed files with 103 additions and 4 deletions
+1
View File
@@ -96,6 +96,7 @@ to read the hint.
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated. Rovo has no slash-command surface — it matches skills automatically or by prompt (e.g. "use the openspec-propose skill"); `/skills` only manages them. Generated content references skills by name, never as `/openspec-*` commands. |
| [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.md` |
| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-<id>.md` |
| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/<id>.md` |
+1
View File
@@ -69,6 +69,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'Rovo Dev CLI', value: 'rovodev', available: true, successLabel: 'Rovo Dev CLI', skillsDir: '.rovodev', detectionPaths: ['.rovodev/skills', '.rovodev'] },
{ name: 'Zoo Code', value: 'roocode', available: true, successLabel: 'Zoo Code', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
{ name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' },
+8 -2
View File
@@ -13,7 +13,7 @@ import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
@@ -1041,7 +1041,13 @@ export class InitCommand {
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
const skillReference = getSkillReferenceTransformer(tool.value)(command);
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
// prose ("the openspec-propose skill"); phrase the hint so it reads
// as an instruction rather than a dead command with an argument.
hint = usesNaturalLanguageSkillReferences(tool.value)
? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
: `Start your first change: ${skillReference} "your idea"`;
} else {
continue;
}
+33 -2
View File
@@ -77,6 +77,32 @@ const SKILL_INVOCATION_PREFIX: Record<string, string> = {
codex: '$',
};
/**
* Tools that have no slash-command surface at all: skills are matched
* automatically or invoked by natural-language prompts, never by typing a
* `/<name>` command. Rovo Dev CLI is such a tool — `/skills` only manages
* skills, and any `/openspec-*` form would be a dead command (see
* docs/supported-tools.md). References for these tools are spelled as prose
* ("the openspec-propose skill") so generated content never tells the user to
* type a command their CLI does not register.
*/
const NATURAL_LANGUAGE_SKILL_TOOLS = new Set<string>(['rovodev']);
/**
* Whether a tool references skills by natural language rather than a slash
* command (see NATURAL_LANGUAGE_SKILL_TOOLS).
*/
export function usesNaturalLanguageSkillReferences(toolId: string): boolean {
return NATURAL_LANGUAGE_SKILL_TOOLS.has(toolId);
}
function replaceCommandsWithNaturalLanguageSkillReferences(text: string): string {
return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => {
const skillName = COMMAND_TO_SKILL_NAME[commandId];
return skillName === undefined ? match : `the ${skillName} skill`;
});
}
function replaceCommandsWithSkillReferences(text: string, prefix: string): string {
return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => {
const skillName = COMMAND_TO_SKILL_NAME[commandId];
@@ -121,12 +147,17 @@ export function transformToSkillReferences(text: string): string {
/**
* Returns the skill-reference transformer for a specific tool, honoring the
* tool's documented skill invocation syntax (e.g. Kimi Code's
* `/skill:openspec-propose`). Falls back to the default `/openspec-*` form.
* `/skill:openspec-propose`). Tools with no slash surface (e.g. Rovo Dev) get
* natural-language references ("the openspec-propose skill"); everything else
* falls back to the default `/openspec-*` form.
*
* @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe')
* @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe', 'rovodev')
* @returns A transformer converting `/opsx:*` references to skill invocations
*/
export function getSkillReferenceTransformer(toolId: string): (text: string) => string {
if (usesNaturalLanguageSkillReferences(toolId)) {
return replaceCommandsWithNaturalLanguageSkillReferences;
}
const prefix = SKILL_INVOCATION_PREFIX[toolId];
if (prefix === undefined) {
return transformToSkillReferences;
+49
View File
@@ -524,6 +524,55 @@ describe('InitCommand', () => {
).toBe(true);
});
it('should support Rovo Dev CLI as an adapterless skills-only tool', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
delivery: 'both',
});
const initCommand = new InitCommand({ tools: 'rovodev', force: true });
await initCommand.execute(testDir);
const skillFile = path.join(testDir, '.rovodev', 'skills', 'openspec-explore', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);
const commandsDir = path.join(testDir, '.rovodev', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);
// Rovo has no slash-command surface: skills are invoked by natural
// language, so no generated skill may tell the user to type a
// `/openspec-*` or `/opsx…` command that its CLI never registers.
const skillsRoot = path.join(testDir, '.rovodev', 'skills');
const skillDirs = await fs.readdir(skillsRoot);
expect(skillDirs.length).toBeGreaterThan(0);
for (const dir of skillDirs) {
const body = await fs.readFile(path.join(skillsRoot, dir, 'SKILL.md'), 'utf-8');
expect(body, `${dir}/SKILL.md should not reference /openspec-* commands`).not.toMatch(/\/openspec-/);
expect(body, `${dir}/SKILL.md should not reference /opsx commands`).not.toMatch(/\/opsx[:-]/);
}
// The apply skill hands off to other workflows; confirm the handoff is
// spelled as a natural-language skill reference.
const applyBody = await fs.readFile(
path.join(skillsRoot, 'openspec-apply-change', 'SKILL.md'),
'utf-8',
);
expect(applyBody).toMatch(/the openspec-archive-change skill/);
const rovoLogCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(rovoLogCalls.some((entry) => entry.includes('Created: Rovo Dev CLI'))).toBe(true);
expect(
rovoLogCalls.some(
(entry) => entry.includes('Commands skipped for: rovodev') && entry.includes('(no adapter)'),
),
).toBe(true);
// The getting-started hint must not advertise a dead slash command.
const hintLine = rovoLogCalls.find((entry) => entry.includes('Start your first change'));
expect(hintLine).toBeDefined();
expect(hintLine).not.toMatch(/\/openspec-/);
expect(hintLine).toContain('the openspec-propose skill');
});
it('should support Hermes Agent as an adapterless skills-only tool with a setup note', async () => {
saveGlobalConfig({
featureFlags: {},
+11
View File
@@ -233,6 +233,17 @@ describe('getSkillReferenceTransformer', () => {
expect(transformer('/opsx:propose')).toBe('$openspec-propose');
expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command');
});
it('uses natural-language references for Rovo Dev, which has no slash surface', () => {
const transformer = getSkillReferenceTransformer('rovodev');
expect(transformer('/opsx:propose')).toBe('the openspec-propose skill');
expect(transformer('Run `/opsx:apply` then /opsx:archive')).toBe(
'Run `the openspec-apply-change skill` then the openspec-archive-change skill'
);
// No `/openspec-*` or other slash-command form is ever emitted.
expect(transformer('/opsx:propose')).not.toMatch(/\/openspec-/);
expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command');
});
});
describe('getTransformerForTool', () => {