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
13 changes: 13 additions & 0 deletions openspec/changes/add-crush-support/proposal.md
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions openspec/changes/add-crush-support/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions openspec/changes/add-crush-support/tasks.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
42 changes: 42 additions & 0 deletions src/core/configurators/slash/crush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.crush/commands/openspec/proposal.md',
apply: '.crush/commands/openspec/apply.md',
archive: '.crush/commands/openspec/archive.md'
};

const FRONTMATTER: Record<SlashCommandId, string> = {
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];
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlashCommandConfigurator> = new Map();
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
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 <id> --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', () => {
Expand Down
78 changes: 78 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
---
<!-- OPENSPEC:START -->
Old slash content
<!-- OPENSPEC:END -->`;
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 <id> --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]
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);

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,
Expand Down
Loading