Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **Costrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ 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' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
Expand Down
23 changes: 23 additions & 0 deletions src/core/configurators/costrict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';

export class CostrictConfigurator implements ToolConfigurator {
name = 'Costrict';
configFileName = 'COSTRICT.md';
isAvailable = true;

async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getCostrictTemplate();

await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ToolConfigurator } from './base.js';
import { ClaudeConfigurator } from './claude.js';
import { ClineConfigurator } from './cline.js';
import { CodeBuddyConfigurator } from './codebuddy.js';
import { CostrictConfigurator } from './costrict.js';
import { AgentsStandardConfigurator } from './agents.js';

export class ToolRegistry {
Expand All @@ -11,11 +12,13 @@ export class ToolRegistry {
const claudeConfigurator = new ClaudeConfigurator();
const clineConfigurator = new ClineConfigurator();
const codeBuddyConfigurator = new CodeBuddyConfigurator();
const costrictConfigurator = new CostrictConfigurator();
const agentsConfigurator = new AgentsStandardConfigurator();
// Register with the ID that matches the checkbox value
this.tools.set('claude', claudeConfigurator);
this.tools.set('cline', clineConfigurator);
this.tools.set('codebuddy', codeBuddyConfigurator);
this.tools.set('costrict', costrictConfigurator);
this.tools.set('agents', agentsConfigurator);
}

Expand Down
36 changes: 36 additions & 0 deletions src/core/configurators/slash/costrict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

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

const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
description: "Scaffold a new OpenSpec change and validate strictly."
argument-hint: feature description or request
---`,
apply: `---
description: "Implement an approved OpenSpec change and keep tasks in sync."
argument-hint: change-id
---`,
archive: `---
description: "Archive a deployed OpenSpec change and update specs."
argument-hint: change-id
---`
};

export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'costrict';
readonly isAvailable = true;

protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}

protected getFrontmatter(id: SlashCommandId): string | undefined {
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 @@ -12,6 +12,7 @@ import { FactorySlashCommandConfigurator } from './factory.js';
import { AuggieSlashCommandConfigurator } from './auggie.js';
import { ClineSlashCommandConfigurator } from './cline.js';
import { CrushSlashCommandConfigurator } from './crush.js';
import { CostrictSlashCommandConfigurator } from './costrict.js';

export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
Expand All @@ -30,6 +31,7 @@ export class SlashCommandRegistry {
const auggie = new AuggieSlashCommandConfigurator();
const cline = new ClineSlashCommandConfigurator();
const crush = new CrushSlashCommandConfigurator();
const costrict = new CostrictSlashCommandConfigurator();

this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
Expand All @@ -44,6 +46,7 @@ export class SlashCommandRegistry {
this.configurators.set(auggie.toolId, auggie);
this.configurators.set(cline.toolId, cline);
this.configurators.set(crush.toolId, crush);
this.configurators.set(costrict.toolId, costrict);
}

static register(configurator: SlashCommandConfigurator): void {
Expand Down
1 change: 1 addition & 0 deletions src/core/templates/costrict-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
5 changes: 5 additions & 0 deletions src/core/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { agentsTemplate } from './agents-template.js';
import { projectTemplate, ProjectContext } from './project-template.js';
import { claudeTemplate } from './claude-template.js';
import { clineTemplate } from './cline-template.js';
import { costrictTemplate } from './costrict-template.js';
import { agentsRootStubTemplate } from './agents-root-stub.js';
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';

Expand Down Expand Up @@ -32,6 +33,10 @@ export class TemplateManager {
return clineTemplate;
}

static getCostrictTemplate(): string {
return costrictTemplate;
}

static getAgentsStandardTemplate(): string {
return agentsRootStubTemplate;
}
Expand Down
87 changes: 87 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,93 @@ describe('InitCommand', () => {
);
expect(crushChoice.configured).toBe(true);
});

it('should create Costrict slash command files with templates', async () => {
queueSelections('costrict', DONE);

await initCommand.execute(testDir);

const costrictProposal = path.join(
testDir,
'.cospec/openspec/commands/openspec-proposal.md'
);
const costrictApply = path.join(
testDir,
'.cospec/openspec/commands/openspec-apply.md'
);
const costrictArchive = path.join(
testDir,
'.cospec/openspec/commands/openspec-archive.md'
);

expect(await fileExists(costrictProposal)).toBe(true);
expect(await fileExists(costrictApply)).toBe(true);
expect(await fileExists(costrictArchive)).toBe(true);

const proposalContent = await fs.readFile(costrictProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('argument-hint: feature description or request');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');

const applyContent = await fs.readFile(costrictApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
expect(applyContent).toContain('argument-hint: change-id');
expect(applyContent).toContain('Work through tasks sequentially');

const archiveContent = await fs.readFile(costrictArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('argument-hint: change-id');
expect(archiveContent).toContain('openspec archive <id> --yes');
});

it('should mark Costrict as already configured during extend mode', async () => {
queueSelections('costrict', DONE, 'costrict', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);

const secondRunArgs = mockPrompt.mock.calls[1][0];
const costrictChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'costrict'
);
expect(costrictChoice.configured).toBe(true);
});

it('should create COSTRICT.md when Costrict is selected', async () => {
queueSelections('costrict', DONE);

await initCommand.execute(testDir);

const costrictPath = path.join(testDir, 'COSTRICT.md');
expect(await fileExists(costrictPath)).toBe(true);

const content = await fs.readFile(costrictPath, 'utf-8');
expect(content).toContain('<!-- OPENSPEC:START -->');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});

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

const costrictPath = path.join(testDir, 'COSTRICT.md');
const existingContent =
'# My Costrict Instructions\nCustom instructions here';
await fs.writeFile(costrictPath, existingContent);

await initCommand.execute(testDir);

const updatedContent = await fs.readFile(costrictPath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('Custom instructions here');
});
});

describe('non-interactive mode', () => {
Expand Down
Loading
Loading