diff --git a/README.md b/README.md index bb98aeb4..97cd3498 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) | | **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) | +| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) | | **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) | | **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) | | **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | @@ -233,7 +234,7 @@ Or run the command yourself in terminal: $ openspec archive add-profile-filters --yes # Archive the completed change without prompts ``` -**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change". +**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change". ## Command Reference diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 04d94505..4ccb0260 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -238,6 +238,14 @@ The init command SHALL generate slash command files for supported editors using - **AND** wrap the OpenSpec managed markers (`` / ``) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing - **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools +#### Scenario: Generating slash commands for RooCode +- **WHEN** the user selects RooCode during initialization +- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md` +- **AND** populate each file from shared templates so command text matches other tools +- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter +- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands +- **AND** each template includes instructions for the relevant OpenSpec workflow stage + ### Requirement: Non-Interactive Mode The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases. diff --git a/src/core/config.ts b/src/core/config.ts index ef92099e..3f1507c5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -20,6 +20,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' }, { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' }, + { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' }, { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' }, { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' }, { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' }, diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index 50989f06..afa7a691 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -16,6 +16,7 @@ import { ClineSlashCommandConfigurator } from './cline.js'; import { CrushSlashCommandConfigurator } from './crush.js'; import { CostrictSlashCommandConfigurator } from './costrict.js'; import { QwenSlashCommandConfigurator } from './qwen.js'; +import { RooCodeSlashCommandConfigurator } from './roocode.js'; export class SlashCommandRegistry { private static configurators: Map = new Map(); @@ -38,6 +39,7 @@ export class SlashCommandRegistry { const crush = new CrushSlashCommandConfigurator(); const costrict = new CostrictSlashCommandConfigurator(); const qwen = new QwenSlashCommandConfigurator(); + const roocode = new RooCodeSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); this.configurators.set(codeBuddy.toolId, codeBuddy); @@ -56,6 +58,7 @@ export class SlashCommandRegistry { this.configurators.set(crush.toolId, crush); this.configurators.set(costrict.toolId, costrict); this.configurators.set(qwen.toolId, qwen); + this.configurators.set(roocode.toolId, roocode); } static register(configurator: SlashCommandConfigurator): void { diff --git a/src/core/configurators/slash/roocode.ts b/src/core/configurators/slash/roocode.ts new file mode 100644 index 00000000..faf89b41 --- /dev/null +++ b/src/core/configurators/slash/roocode.ts @@ -0,0 +1,27 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const NEW_FILE_PATHS: Record = { + proposal: '.roo/commands/openspec-proposal.md', + apply: '.roo/commands/openspec-apply.md', + archive: '.roo/commands/openspec-archive.md' +}; + +export class RooCodeSlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'roocode'; + readonly isAvailable = true; + + protected getRelativePath(id: SlashCommandId): string { + return NEW_FILE_PATHS[id]; + } + + protected getFrontmatter(id: SlashCommandId): string | undefined { + const descriptions: Record = { + proposal: 'Scaffold a new OpenSpec change and validate strictly.', + apply: 'Implement an approved OpenSpec change and keep tasks in sync.', + archive: 'Archive a deployed OpenSpec change and update specs.' + }; + const description = descriptions[id]; + return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`; + } +} diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 3d814992..a76b7fee 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1194,6 +1194,53 @@ describe('InitCommand', () => { expect(costrictChoice.configured).toBe(true); }); + it('should create RooCode slash command files with templates', async () => { + queueSelections('roocode', DONE); + + await initCommand.execute(testDir); + + const rooProposal = path.join( + testDir, + '.roo/commands/openspec-proposal.md' + ); + const rooApply = path.join( + testDir, + '.roo/commands/openspec-apply.md' + ); + const rooArchive = path.join( + testDir, + '.roo/commands/openspec-archive.md' + ); + + expect(await fileExists(rooProposal)).toBe(true); + expect(await fileExists(rooApply)).toBe(true); + expect(await fileExists(rooArchive)).toBe(true); + + const proposalContent = await fs.readFile(rooProposal, 'utf-8'); + expect(proposalContent).toContain('# OpenSpec: Proposal'); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(rooApply, 'utf-8'); + expect(applyContent).toContain('# OpenSpec: Apply'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(rooArchive, 'utf-8'); + expect(archiveContent).toContain('# OpenSpec: Archive'); + expect(archiveContent).toContain('openspec archive --yes'); + }); + + it('should mark RooCode as already configured during extend mode', async () => { + queueSelections('roocode', DONE, 'roocode', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const rooChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'roocode' + ); + expect(rooChoice.configured).toBe(true); + }); + it('should create Qoder slash command files with templates', async () => { queueSelections('qoder', DONE); @@ -1278,7 +1325,6 @@ describe('InitCommand', () => { expect(content).toContain('openspec update'); expect(content).toContain(''); }); - it('should update existing COSTRICT.md with markers', async () => { queueSelections('costrict', DONE); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 027c5ec6..d462d64e 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1071,6 +1071,79 @@ Old slash content consoleSpy.mockRestore(); }); + it('should refresh existing RooCode slash command files', async () => { + const rooPath = path.join( + testDir, + '.roo/commands/openspec-proposal.md' + ); + await fs.mkdir(path.dirname(rooPath), { recursive: true }); + const initialContent = `# OpenSpec: Proposal + +Old description + + +Old body +`; + await fs.writeFile(rooPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(rooPath, 'utf-8'); + // For RooCode, the header is Markdown, preserve it and update only managed block + expect(updated).toContain('# OpenSpec: Proposal'); + expect(updated).toContain('**Guardrails**'); + expect(updated).toContain( + 'Validate with `openspec validate --strict`' + ); + expect(updated).not.toContain('Old body'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated OpenSpec instructions (openspec/AGENTS.md' + ); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain( + 'Updated slash commands: .roo/commands/openspec-proposal.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing RooCode slash command files on update', async () => { + const rooApply = path.join( + testDir, + '.roo/commands/openspec-apply.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(rooApply), { recursive: true }); + await fs.writeFile( + rooApply, + `# OpenSpec: Apply + + +Old body +` + ); + + await updateCommand.execute(testDir); + + const rooProposal = path.join( + testDir, + '.roo/commands/openspec-proposal.md' + ); + const rooArchive = path.join( + testDir, + '.roo/commands/openspec-archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(rooProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(rooArchive)).resolves.toBe(false); + }); + it('should not create missing CoStrict slash command files on update', async () => { const costrictApply = path.join( testDir, @@ -1181,6 +1254,7 @@ More instructions after.`; consoleSpy.mockRestore(); }); + it('should not create COSTRICT.md if it does not exist', async () => { // Ensure COSTRICT.md does not exist const costrictPath = path.join(testDir, 'COSTRICT.md');