From f1f7412d07e7f5e6a114c50a87e531d2c3b29094 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 10:56:56 -0500 Subject: [PATCH 1/2] feat(tools): add Atlassian Rovo Dev CLI as a first-class tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rovo Dev CLI loads project Agent Skills from `.rovodev/skills//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 --- docs/supported-tools.md | 1 + src/core/config.ts | 1 + test/core/init.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 78837cb97d..48a601e077 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -96,6 +96,7 @@ to read the hint. | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.md` | +| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | diff --git a/src/core/config.ts b/src/core/config.ts index 876dda4f1e..4e027d28fb 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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' }, diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 9002902cdc..9478c5f85c 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -524,6 +524,31 @@ 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); + + 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); + }); + it('should support Hermes Agent as an adapterless skills-only tool with a setup note', async () => { saveGlobalConfig({ featureFlags: {}, From b07d5d5075994a16775f51764a22113207858830 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 5 Aug 2026 11:33:48 -0500 Subject: [PATCH 2/2] fix(tools): reference Rovo skills by natural language, not dead slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` now renders as "the openspec- 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 --- docs/supported-tools.md | 2 +- src/core/init.ts | 10 ++++++-- src/utils/command-references.ts | 35 +++++++++++++++++++++++++-- test/core/init.test.ts | 24 ++++++++++++++++++ test/utils/command-references.test.ts | 11 +++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 48a601e077..f7b1861744 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -96,7 +96,7 @@ to read the hint. | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.md` | -| [Rovo Dev CLI](https://support.atlassian.com/rovo/docs/use-rovo-dev-cli/) (`rovodev`) | `.rovodev/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| [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-.md` | | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | diff --git a/src/core/init.ts b/src/core/init.ts index 93e428e2c6..28d2337168 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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; } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index c4800513c9..d4cbf00d71 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -77,6 +77,32 @@ const SKILL_INVOCATION_PREFIX: Record = { codex: '$', }; +/** + * Tools that have no slash-command surface at all: skills are matched + * automatically or invoked by natural-language prompts, never by typing a + * `/` 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(['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; diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 9478c5f85c..216ebff77a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -540,6 +540,25 @@ describe('InitCommand', () => { 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( @@ -547,6 +566,11 @@ describe('InitCommand', () => { (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 () => { diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 27a937208e..d5886f2dfe 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -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', () => {