Skip to content

Commit 67ab683

Browse files
M0Rf30charmcrush
andauthored
feat: add Crush AI assistant support (#206)
Add comprehensive OpenSpec integration for Crush AI assistant including: - CrushSlashCommandConfigurator for proposal, apply, and archive commands - Integration with slash command registry and CLI tools - Generates .crush/commands/openspec/ with proper frontmatter and workflows - Available via `openspec init --tools crush` 💘 Generated with Crush Co-authored-by: Crush <[email protected]>
1 parent f82e243 commit 67ab683

File tree

8 files changed

+225
-0
lines changed

8 files changed

+225
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Why
2+
Add support for Crush AI assistant in OpenSpec to enable developers to use Crush's enhanced capabilities for spec-driven development workflows.
3+
4+
## What Changes
5+
- Add Crush slash command configurator for proposal, apply, and archive operations
6+
- Add Crush-specific AGENTS.md configuration template
7+
- Update tool registry to include Crush configurator
8+
- **BREAKING**: None - this is additive functionality
9+
10+
## Impact
11+
- Affected specs: cli-init (new tool option)
12+
- Affected code: src/core/configurators/slash/crush.ts, registry.ts
13+
- New files: .crush/commands/openspec/ (proposal.md, apply.md, archive.md)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## ADDED Requirements
2+
### Requirement: Crush Tool Support
3+
The system SHALL provide Crush AI assistant as a supported tool option during OpenSpec initialization.
4+
5+
#### Scenario: Initialize project with Crush support
6+
- **WHEN** user runs `openspec init --tool crush`
7+
- **THEN** Crush-specific slash commands are configured in `.crush/commands/openspec/`
8+
- **AND** Crush AGENTS.md includes OpenSpec workflow instructions
9+
- **AND** Crush is registered as available configurator
10+
11+
#### Scenario: Crush proposal command generation
12+
- **WHEN** Crush slash commands are configured
13+
- **THEN** `.crush/commands/openspec/proposal.md` contains proposal workflow with guardrails
14+
- **AND** Includes Crush-specific frontmatter with OpenSpec category and tags
15+
- **AND** Follows established slash command template pattern
16+
17+
#### Scenario: Crush apply and archive commands
18+
- **WHEN** Crush slash commands are configured
19+
- **THEN** `.crush/commands/openspec/apply.md` contains implementation workflow
20+
- **AND** `.crush/commands/openspec/archive.md` contains archiving workflow
21+
- **AND** Both commands include appropriate frontmatter and references
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## 1. Implementation
2+
- [x] 1.1 Create CrushSlashCommandConfigurator class in src/core/configurators/slash/crush.ts
3+
- [x] 1.2 Define file paths for Crush commands (.crush/commands/openspec/)
4+
- [x] 1.3 Create Crush-specific frontmatter for proposal, apply, archive commands
5+
- [x] 1.4 Register Crush configurator in slash/registry.ts
6+
- [x] 1.5 Add Crush to available tools in cli-init command
7+
- [x] 1.6 Test integration with openspec init --tool crush

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface AIToolOption {
1919
export const AI_TOOLS: AIToolOption[] = [
2020
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
2121
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
22+
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
2223
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
2324
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
2425
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { SlashCommandConfigurator } from './base.js';
2+
import { SlashCommandId } from '../../templates/index.js';
3+
4+
const FILE_PATHS: Record<SlashCommandId, string> = {
5+
proposal: '.crush/commands/openspec/proposal.md',
6+
apply: '.crush/commands/openspec/apply.md',
7+
archive: '.crush/commands/openspec/archive.md'
8+
};
9+
10+
const FRONTMATTER: Record<SlashCommandId, string> = {
11+
proposal: `---
12+
name: OpenSpec: Proposal
13+
description: Scaffold a new OpenSpec change and validate strictly.
14+
category: OpenSpec
15+
tags: [openspec, change]
16+
---`,
17+
apply: `---
18+
name: OpenSpec: Apply
19+
description: Implement an approved OpenSpec change and keep tasks in sync.
20+
category: OpenSpec
21+
tags: [openspec, apply]
22+
---`,
23+
archive: `---
24+
name: OpenSpec: Archive
25+
description: Archive a deployed OpenSpec change and update specs.
26+
category: OpenSpec
27+
tags: [openspec, archive]
28+
---`
29+
};
30+
31+
export class CrushSlashCommandConfigurator extends SlashCommandConfigurator {
32+
readonly toolId = 'crush';
33+
readonly isAvailable = true;
34+
35+
protected getRelativePath(id: SlashCommandId): string {
36+
return FILE_PATHS[id];
37+
}
38+
39+
protected getFrontmatter(id: SlashCommandId): string {
40+
return FRONTMATTER[id];
41+
}
42+
}

src/core/configurators/slash/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
99
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
1010
import { FactorySlashCommandConfigurator } from './factory.js';
1111
import { AuggieSlashCommandConfigurator } from './auggie.js';
12+
import { CrushSlashCommandConfigurator } from './crush.js';
1213

1314
export class SlashCommandRegistry {
1415
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -24,6 +25,7 @@ export class SlashCommandRegistry {
2425
const amazonQ = new AmazonQSlashCommandConfigurator();
2526
const factory = new FactorySlashCommandConfigurator();
2627
const auggie = new AuggieSlashCommandConfigurator();
28+
const crush = new CrushSlashCommandConfigurator();
2729

2830
this.configurators.set(claude.toolId, claude);
2931
this.configurators.set(cursor.toolId, cursor);
@@ -35,6 +37,7 @@ export class SlashCommandRegistry {
3537
this.configurators.set(amazonQ.toolId, amazonQ);
3638
this.configurators.set(factory.toolId, factory);
3739
this.configurators.set(auggie.toolId, auggie);
40+
this.configurators.set(crush.toolId, crush);
3841
}
3942

4043
static register(configurator: SlashCommandConfigurator): void {

test/core/init.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,66 @@ describe('InitCommand', () => {
737737
);
738738
expect(auggieChoice.configured).toBe(true);
739739
});
740+
741+
it('should create Crush slash command files with templates', async () => {
742+
queueSelections('crush', DONE);
743+
744+
await initCommand.execute(testDir);
745+
746+
const crushProposal = path.join(
747+
testDir,
748+
'.crush/commands/openspec/proposal.md'
749+
);
750+
const crushApply = path.join(
751+
testDir,
752+
'.crush/commands/openspec/apply.md'
753+
);
754+
const crushArchive = path.join(
755+
testDir,
756+
'.crush/commands/openspec/archive.md'
757+
);
758+
759+
expect(await fileExists(crushProposal)).toBe(true);
760+
expect(await fileExists(crushApply)).toBe(true);
761+
expect(await fileExists(crushArchive)).toBe(true);
762+
763+
const proposalContent = await fs.readFile(crushProposal, 'utf-8');
764+
expect(proposalContent).toContain('---');
765+
expect(proposalContent).toContain('name: OpenSpec: Proposal');
766+
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
767+
expect(proposalContent).toContain('category: OpenSpec');
768+
expect(proposalContent).toContain('tags: [openspec, change]');
769+
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
770+
expect(proposalContent).toContain('**Guardrails**');
771+
772+
const applyContent = await fs.readFile(crushApply, 'utf-8');
773+
expect(applyContent).toContain('---');
774+
expect(applyContent).toContain('name: OpenSpec: Apply');
775+
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
776+
expect(applyContent).toContain('category: OpenSpec');
777+
expect(applyContent).toContain('tags: [openspec, apply]');
778+
expect(applyContent).toContain('Work through tasks sequentially');
779+
780+
const archiveContent = await fs.readFile(crushArchive, 'utf-8');
781+
expect(archiveContent).toContain('---');
782+
expect(archiveContent).toContain('name: OpenSpec: Archive');
783+
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
784+
expect(archiveContent).toContain('category: OpenSpec');
785+
expect(archiveContent).toContain('tags: [openspec, archive]');
786+
expect(archiveContent).toContain('openspec archive <id> --yes');
787+
});
788+
789+
it('should mark Crush as already configured during extend mode', async () => {
790+
queueSelections('crush', DONE, 'crush', DONE);
791+
await initCommand.execute(testDir);
792+
await initCommand.execute(testDir);
793+
794+
const secondRunArgs = mockPrompt.mock.calls[1][0];
795+
const crushChoice = secondRunArgs.choices.find(
796+
(choice: any) => choice.value === 'crush'
797+
);
798+
expect(crushChoice.configured).toBe(true);
799+
});
740800
});
741801

742802
describe('non-interactive mode', () => {

test/core/update.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,84 @@ Old body
574574
await expect(FileSystemUtils.fileExists(auggieArchive)).resolves.toBe(false);
575575
});
576576

577+
it('should refresh existing Crush slash command files', async () => {
578+
const crushPath = path.join(
579+
testDir,
580+
'.crush/commands/openspec/proposal.md'
581+
);
582+
await fs.mkdir(path.dirname(crushPath), { recursive: true });
583+
const initialContent = `---
584+
name: OpenSpec: Proposal
585+
description: Old description
586+
category: OpenSpec
587+
tags: [openspec, change]
588+
---
589+
<!-- OPENSPEC:START -->
590+
Old slash content
591+
<!-- OPENSPEC:END -->`;
592+
await fs.writeFile(crushPath, initialContent);
593+
594+
const consoleSpy = vi.spyOn(console, 'log');
595+
596+
await updateCommand.execute(testDir);
597+
598+
const updated = await fs.readFile(crushPath, 'utf-8');
599+
expect(updated).toContain('name: OpenSpec: Proposal');
600+
expect(updated).toContain('**Guardrails**');
601+
expect(updated).toContain(
602+
'Validate with `openspec validate <id> --strict`'
603+
);
604+
expect(updated).not.toContain('Old slash content');
605+
606+
const [logMessage] = consoleSpy.mock.calls[0];
607+
expect(logMessage).toContain(
608+
'Updated OpenSpec instructions (openspec/AGENTS.md'
609+
);
610+
expect(logMessage).toContain('AGENTS.md (created)');
611+
expect(logMessage).toContain(
612+
'Updated slash commands: .crush/commands/openspec/proposal.md'
613+
);
614+
615+
consoleSpy.mockRestore();
616+
});
617+
618+
it('should not create missing Crush slash command files on update', async () => {
619+
const crushApply = path.join(
620+
testDir,
621+
'.crush/commands/openspec-apply.md'
622+
);
623+
624+
// Only create apply; leave proposal and archive missing
625+
await fs.mkdir(path.dirname(crushApply), { recursive: true });
626+
await fs.writeFile(
627+
crushApply,
628+
`---
629+
name: OpenSpec: Apply
630+
description: Old description
631+
category: OpenSpec
632+
tags: [openspec, apply]
633+
---
634+
<!-- OPENSPEC:START -->
635+
Old body
636+
<!-- OPENSPEC:END -->`
637+
);
638+
639+
await updateCommand.execute(testDir);
640+
641+
const crushProposal = path.join(
642+
testDir,
643+
'.crush/commands/openspec-proposal.md'
644+
);
645+
const crushArchive = path.join(
646+
testDir,
647+
'.crush/commands/openspec-archive.md'
648+
);
649+
650+
// Confirm they weren't created by update
651+
await expect(FileSystemUtils.fileExists(crushProposal)).resolves.toBe(false);
652+
await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false);
653+
});
654+
577655
it('should preserve Windsurf content outside markers during update', async () => {
578656
const wsPath = path.join(
579657
testDir,

0 commit comments

Comments
 (0)