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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ The init command SHALL generate slash command files for supported editors using
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) 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.

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlashCommandConfigurator> = new Map();
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions src/core/configurators/slash/roocode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const NEW_FILE_PATHS: Record<SlashCommandId, string> = {
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<SlashCommandId, string> = {
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}`;
}
}
48 changes: 47 additions & 1 deletion test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --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);

Expand Down Expand Up @@ -1278,7 +1325,6 @@ describe('InitCommand', () => {
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});

it('should update existing COSTRICT.md with markers', async () => {
queueSelections('costrict', DONE);

Expand Down
74 changes: 74 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
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 <id> --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

<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);

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,
Expand Down Expand Up @@ -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');
Expand Down
Loading