diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 78837cb97d..f7b1861744 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. 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/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/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 9002902cdc..216ebff77a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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: {}, 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', () => {