diff --git a/README.md b/README.md index c39d34af..6866d360 100644 --- a/README.md +++ b/README.md @@ -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/`) | diff --git a/src/core/config.ts b/src/core/config.ts index 9d1fbe11..e3e9c656 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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' }, diff --git a/src/core/configurators/costrict.ts b/src/core/configurators/costrict.ts new file mode 100644 index 00000000..6a1a8d1d --- /dev/null +++ b/src/core/configurators/costrict.ts @@ -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 { + const filePath = path.join(projectPath, this.configFileName); + const content = TemplateManager.getCostrictTemplate(); + + await FileSystemUtils.updateFileWithMarkers( + filePath, + content, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); + } +} \ No newline at end of file diff --git a/src/core/configurators/registry.ts b/src/core/configurators/registry.ts index a9bfc974..fedc8ff0 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -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 { @@ -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); } diff --git a/src/core/configurators/slash/costrict.ts b/src/core/configurators/slash/costrict.ts new file mode 100644 index 00000000..0ba92c64 --- /dev/null +++ b/src/core/configurators/slash/costrict.ts @@ -0,0 +1,36 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS = { + proposal: '.cospec/openspec/commands/openspec-proposal.md', + apply: '.cospec/openspec/commands/openspec-apply.md', + archive: '.cospec/openspec/commands/openspec-archive.md', +} as const satisfies Record; + +const FRONTMATTER = { + 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 +---` +} as const satisfies Record; + +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]; + } +} \ No newline at end of file diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index ebeef5ac..7c1b36fc 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -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 = new Map(); @@ -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); @@ -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 { diff --git a/src/core/templates/costrict-template.ts b/src/core/templates/costrict-template.ts new file mode 100644 index 00000000..62f0ca52 --- /dev/null +++ b/src/core/templates/costrict-template.ts @@ -0,0 +1 @@ +export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js'; diff --git a/src/core/templates/index.ts b/src/core/templates/index.ts index 3f88c246..8dab4b5f 100644 --- a/src/core/templates/index.ts +++ b/src/core/templates/index.ts @@ -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'; @@ -32,6 +33,10 @@ export class TemplateManager { return clineTemplate; } + static getCostrictTemplate(): string { + return costrictTemplate; + } + static getAgentsStandardTemplate(): string { return agentsRootStubTemplate; } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index b6d8e35e..fcf0fab3 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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(''); + 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 --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(''); + expect(content).toContain("@/openspec/AGENTS.md"); + expect(content).toContain('openspec update'); + expect(content).toContain(''); + }); + + 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(''); + expect(updatedContent).toContain("@/openspec/AGENTS.md"); + expect(updatedContent).toContain('openspec update'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain('Custom instructions here'); + }); }); describe('non-interactive mode', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 45388218..a1c9f1d2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -819,6 +819,188 @@ Old body await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false); }); + it('should refresh existing CoStrict slash command files', async () => { + const costrictPath = path.join( + testDir, + '.cospec/openspec/commands/openspec-proposal.md' + ); + await fs.mkdir(path.dirname(costrictPath), { recursive: true }); + const initialContent = `--- +description: "Old description" +argument-hint: old-hint +--- + +Old body +`; + await fs.writeFile(costrictPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(costrictPath, 'utf-8'); + // For slash commands, only the content between OpenSpec markers is updated + expect(updated).toContain('description: "Old description"'); + expect(updated).toContain('argument-hint: old-hint'); + expect(updated).toContain('**Guardrails**'); + expect(updated).toContain( + 'Validate with `openspec validate --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: .cospec/openspec/commands/openspec-proposal.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing CoStrict slash command files on update', async () => { + const costrictApply = path.join( + testDir, + '.cospec/openspec/commands/openspec-apply.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(costrictApply), { recursive: true }); + await fs.writeFile( + costrictApply, + `--- +description: "Old" +argument-hint: old +--- + +Old +` + ); + + await updateCommand.execute(testDir); + + const costrictProposal = path.join( + testDir, + '.cospec/openspec/commands/openspec-proposal.md' + ); + const costrictArchive = path.join( + testDir, + '.cospec/openspec/commands/openspec-archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(costrictProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(costrictArchive)).resolves.toBe(false); + }); + + it('should update only existing COSTRICT.md file', async () => { + // Create COSTRICT.md file with initial content + const costrictPath = path.join(testDir, 'COSTRICT.md'); + const initialContent = `# CoStrict Instructions + +Some existing CoStrict instructions here. + + +Old OpenSpec content + + +More instructions after.`; + await fs.writeFile(costrictPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + // Execute update command + await updateCommand.execute(testDir); + + // Check that COSTRICT.md was updated + const updatedContent = await fs.readFile(costrictPath, 'utf-8'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain("@/openspec/AGENTS.md"); + expect(updatedContent).toContain('openspec update'); + expect(updatedContent).toContain('Some existing CoStrict instructions here'); + expect(updatedContent).toContain('More instructions after'); + + // Check console output + 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 AI tool files: COSTRICT.md'); + 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'); + + // Execute update command + await updateCommand.execute(testDir); + + // Check that COSTRICT.md was not created + const fileExists = await FileSystemUtils.fileExists(costrictPath); + expect(fileExists).toBe(false); + }); + + it('should preserve CoStrict content outside markers during update', async () => { + const costrictPath = path.join( + testDir, + '.cospec/openspec/commands/openspec-proposal.md' + ); + await fs.mkdir(path.dirname(costrictPath), { recursive: true }); + const initialContent = `## Custom Intro Title\nSome intro text\n\nOld body\n\n\nFooter stays`; + await fs.writeFile(costrictPath, initialContent); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(costrictPath, 'utf-8'); + expect(updated).toContain('## Custom Intro Title'); + expect(updated).toContain('Footer stays'); + expect(updated).not.toContain('Old body'); + expect(updated).toContain('Validate with `openspec validate --strict`'); + }); + + it('should handle configurator errors gracefully for CoStrict', async () => { + // Create COSTRICT.md file but make it read-only to cause an error + const costrictPath = path.join(testDir, 'COSTRICT.md'); + await fs.writeFile( + costrictPath, + '\nOld\n' + ); + + const consoleSpy = vi.spyOn(console, 'log'); + const errorSpy = vi.spyOn(console, 'error'); + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + const writeSpy = vi + .spyOn(FileSystemUtils, 'writeFile') + .mockImplementation(async (filePath, content) => { + if (filePath.endsWith('COSTRICT.md')) { + throw new Error('EACCES: permission denied, open'); + } + + return originalWriteFile(filePath, content); + }); + + // Execute update command - should not throw + await updateCommand.execute(testDir); + + // Should report the failure + expect(errorSpy).toHaveBeenCalled(); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated OpenSpec instructions (openspec/AGENTS.md' + ); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Failed to update: COSTRICT.md'); + + consoleSpy.mockRestore(); + errorSpy.mockRestore(); + writeSpy.mockRestore(); + }); + it('should preserve Windsurf content outside markers during update', async () => { const wsPath = path.join( testDir,