mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
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:
co-authored by
Claude Opus 4.8
parent
96a6548664
commit
13e213e00f
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user