Skip to content
Merged
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/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
10 changes: 8 additions & 2 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
35 changes: 33 additions & 2 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate rovoLogCalls declaration.

The snippet declares const rovoLogCalls twice inside the same it() callback at Line 543. TypeScript rejects the second declaration, so test/core/init.test.ts cannot compile. Keep one declaration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/init.test.ts` at line 543, Remove the duplicate rovoLogCalls const
declaration within the affected it() callback in test/core/init.test.ts, keeping
a single declaration and preserving its existing usage.

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: {},
Expand Down
11 changes: 11 additions & 0 deletions test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading