diff --git a/openspec/changes/add-crush-support/proposal.md b/openspec/changes/add-crush-support/proposal.md new file mode 100644 index 00000000..e4936527 --- /dev/null +++ b/openspec/changes/add-crush-support/proposal.md @@ -0,0 +1,13 @@ +## Why +Add support for Crush AI assistant in OpenSpec to enable developers to use Crush's enhanced capabilities for spec-driven development workflows. + +## What Changes +- Add Crush slash command configurator for proposal, apply, and archive operations +- Add Crush-specific AGENTS.md configuration template +- Update tool registry to include Crush configurator +- **BREAKING**: None - this is additive functionality + +## Impact +- Affected specs: cli-init (new tool option) +- Affected code: src/core/configurators/slash/crush.ts, registry.ts +- New files: .crush/commands/openspec/ (proposal.md, apply.md, archive.md) \ No newline at end of file diff --git a/openspec/changes/add-crush-support/specs/cli-init/spec.md b/openspec/changes/add-crush-support/specs/cli-init/spec.md new file mode 100644 index 00000000..4d19b3c1 --- /dev/null +++ b/openspec/changes/add-crush-support/specs/cli-init/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements +### Requirement: Crush Tool Support +The system SHALL provide Crush AI assistant as a supported tool option during OpenSpec initialization. + +#### Scenario: Initialize project with Crush support +- **WHEN** user runs `openspec init --tool crush` +- **THEN** Crush-specific slash commands are configured in `.crush/commands/openspec/` +- **AND** Crush AGENTS.md includes OpenSpec workflow instructions +- **AND** Crush is registered as available configurator + +#### Scenario: Crush proposal command generation +- **WHEN** Crush slash commands are configured +- **THEN** `.crush/commands/openspec/proposal.md` contains proposal workflow with guardrails +- **AND** Includes Crush-specific frontmatter with OpenSpec category and tags +- **AND** Follows established slash command template pattern + +#### Scenario: Crush apply and archive commands +- **WHEN** Crush slash commands are configured +- **THEN** `.crush/commands/openspec/apply.md` contains implementation workflow +- **AND** `.crush/commands/openspec/archive.md` contains archiving workflow +- **AND** Both commands include appropriate frontmatter and references \ No newline at end of file diff --git a/openspec/changes/add-crush-support/tasks.md b/openspec/changes/add-crush-support/tasks.md new file mode 100644 index 00000000..cd25fb70 --- /dev/null +++ b/openspec/changes/add-crush-support/tasks.md @@ -0,0 +1,7 @@ +## 1. Implementation +- [x] 1.1 Create CrushSlashCommandConfigurator class in src/core/configurators/slash/crush.ts +- [x] 1.2 Define file paths for Crush commands (.crush/commands/openspec/) +- [x] 1.3 Create Crush-specific frontmatter for proposal, apply, archive commands +- [x] 1.4 Register Crush configurator in slash/registry.ts +- [x] 1.5 Add Crush to available tools in cli-init command +- [x] 1.6 Test integration with openspec init --tool crush \ No newline at end of file diff --git a/src/core/config.ts b/src/core/config.ts index 28d689be..44953388 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -19,6 +19,7 @@ export interface AIToolOption { 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: 'Crush', value: 'crush', available: true, successLabel: 'Crush' }, { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' }, { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' }, diff --git a/src/core/configurators/slash/crush.ts b/src/core/configurators/slash/crush.ts new file mode 100644 index 00000000..c4ae1f1f --- /dev/null +++ b/src/core/configurators/slash/crush.ts @@ -0,0 +1,42 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.crush/commands/openspec/proposal.md', + apply: '.crush/commands/openspec/apply.md', + archive: '.crush/commands/openspec/archive.md' +}; + +const FRONTMATTER: Record = { + proposal: `--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +---`, + apply: `--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +---`, + archive: `--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +---` +}; + +export class CrushSlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'crush'; + readonly isAvailable = true; + + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + protected getFrontmatter(id: SlashCommandId): string { + return FRONTMATTER[id]; + } +} \ No newline at end of file diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index bf07818e..2bb90570 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -9,6 +9,7 @@ import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js'; import { AmazonQSlashCommandConfigurator } from './amazon-q.js'; import { FactorySlashCommandConfigurator } from './factory.js'; import { AuggieSlashCommandConfigurator } from './auggie.js'; +import { CrushSlashCommandConfigurator } from './crush.js'; export class SlashCommandRegistry { private static configurators: Map = new Map(); @@ -24,6 +25,7 @@ export class SlashCommandRegistry { const amazonQ = new AmazonQSlashCommandConfigurator(); const factory = new FactorySlashCommandConfigurator(); const auggie = new AuggieSlashCommandConfigurator(); + const crush = new CrushSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); this.configurators.set(cursor.toolId, cursor); @@ -35,6 +37,7 @@ export class SlashCommandRegistry { this.configurators.set(amazonQ.toolId, amazonQ); this.configurators.set(factory.toolId, factory); this.configurators.set(auggie.toolId, auggie); + this.configurators.set(crush.toolId, crush); } static register(configurator: SlashCommandConfigurator): void { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 2a95d098..e42c93ea 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -737,6 +737,66 @@ describe('InitCommand', () => { ); expect(auggieChoice.configured).toBe(true); }); + + it('should create Crush slash command files with templates', async () => { + queueSelections('crush', DONE); + + await initCommand.execute(testDir); + + const crushProposal = path.join( + testDir, + '.crush/commands/openspec/proposal.md' + ); + const crushApply = path.join( + testDir, + '.crush/commands/openspec/apply.md' + ); + const crushArchive = path.join( + testDir, + '.crush/commands/openspec/archive.md' + ); + + expect(await fileExists(crushProposal)).toBe(true); + expect(await fileExists(crushApply)).toBe(true); + expect(await fileExists(crushArchive)).toBe(true); + + const proposalContent = await fs.readFile(crushProposal, 'utf-8'); + expect(proposalContent).toContain('---'); + expect(proposalContent).toContain('name: OpenSpec: Proposal'); + expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain('category: OpenSpec'); + expect(proposalContent).toContain('tags: [openspec, change]'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(crushApply, 'utf-8'); + expect(applyContent).toContain('---'); + expect(applyContent).toContain('name: OpenSpec: Apply'); + expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(applyContent).toContain('category: OpenSpec'); + expect(applyContent).toContain('tags: [openspec, apply]'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(crushArchive, 'utf-8'); + expect(archiveContent).toContain('---'); + expect(archiveContent).toContain('name: OpenSpec: Archive'); + expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('category: OpenSpec'); + expect(archiveContent).toContain('tags: [openspec, archive]'); + expect(archiveContent).toContain('openspec archive --yes'); + }); + + it('should mark Crush as already configured during extend mode', async () => { + queueSelections('crush', DONE, 'crush', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const crushChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'crush' + ); + expect(crushChoice.configured).toBe(true); + }); }); describe('non-interactive mode', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c5541b2f..588e995a 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -574,6 +574,84 @@ Old body await expect(FileSystemUtils.fileExists(auggieArchive)).resolves.toBe(false); }); + it('should refresh existing Crush slash command files', async () => { + const crushPath = path.join( + testDir, + '.crush/commands/openspec/proposal.md' + ); + await fs.mkdir(path.dirname(crushPath), { recursive: true }); + const initialContent = `--- +name: OpenSpec: Proposal +description: Old description +category: OpenSpec +tags: [openspec, change] +--- + +Old slash content +`; + await fs.writeFile(crushPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(crushPath, 'utf-8'); + expect(updated).toContain('name: OpenSpec: Proposal'); + expect(updated).toContain('**Guardrails**'); + expect(updated).toContain( + 'Validate with `openspec validate --strict`' + ); + expect(updated).not.toContain('Old slash content'); + + 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: .crush/commands/openspec/proposal.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing Crush slash command files on update', async () => { + const crushApply = path.join( + testDir, + '.crush/commands/openspec-apply.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(crushApply), { recursive: true }); + await fs.writeFile( + crushApply, + `--- +name: OpenSpec: Apply +description: Old description +category: OpenSpec +tags: [openspec, apply] +--- + +Old body +` + ); + + await updateCommand.execute(testDir); + + const crushProposal = path.join( + testDir, + '.crush/commands/openspec-proposal.md' + ); + const crushArchive = path.join( + testDir, + '.crush/commands/openspec-archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(crushProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false); + }); + it('should preserve Windsurf content outside markers during update', async () => { const wsPath = path.join( testDir,