diff --git a/README.md b/README.md index 2c454506..d393daab 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) | +| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) | Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`. diff --git a/src/core/config.ts b/src/core/config.ts index 1387967d..b7c76020 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -21,5 +21,6 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' }, + { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' }, { name: 'AGENTS.md (works with Codex, Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index 55dda210..7b22c2a8 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -1,6 +1,7 @@ import { SlashCommandConfigurator } from './base.js'; import { ClaudeSlashCommandConfigurator } from './claude.js'; import { CursorSlashCommandConfigurator } from './cursor.js'; +import { WindsurfSlashCommandConfigurator } from './windsurf.js'; import { KiloCodeSlashCommandConfigurator } from './kilocode.js'; import { OpenCodeSlashCommandConfigurator } from './opencode.js'; @@ -10,11 +11,13 @@ export class SlashCommandRegistry { static { const claude = new ClaudeSlashCommandConfigurator(); const cursor = new CursorSlashCommandConfigurator(); + const windsurf = new WindsurfSlashCommandConfigurator(); const kilocode = new KiloCodeSlashCommandConfigurator(); const opencode = new OpenCodeSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); this.configurators.set(cursor.toolId, cursor); + this.configurators.set(windsurf.toolId, windsurf); this.configurators.set(kilocode.toolId, kilocode); this.configurators.set(opencode.toolId, opencode); } diff --git a/src/core/configurators/slash/windsurf.ts b/src/core/configurators/slash/windsurf.ts new file mode 100644 index 00000000..c0542eca --- /dev/null +++ b/src/core/configurators/slash/windsurf.ts @@ -0,0 +1,27 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.windsurf/workflows/openspec-proposal.md', + apply: '.windsurf/workflows/openspec-apply.md', + archive: '.windsurf/workflows/openspec-archive.md' +}; + +export class WindsurfSlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'windsurf'; + readonly isAvailable = true; + + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + protected getFrontmatter(id: SlashCommandId): string | undefined { + const descriptions: Record = { + proposal: 'Scaffold a new OpenSpec change and validate strictly.', + apply: 'Implement an approved OpenSpec change and keep tasks in sync.', + archive: 'Archive a deployed OpenSpec change and update specs.' + }; + const description = descriptions[id]; + return `---\ndescription: ${description}\nauto_execution_mode: 3\n---`; + } +} diff --git a/test/core/init.test.ts b/test/core/init.test.ts index ad4f0435..11b8a4a2 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -129,6 +129,50 @@ describe('InitCommand', () => { expect(updatedContent).toContain('Custom instructions here'); }); + it('should create Windsurf workflows when Windsurf is selected', async () => { + queueSelections('windsurf', DONE); + + await initCommand.execute(testDir); + + const wsProposal = path.join( + testDir, + '.windsurf/workflows/openspec-proposal.md' + ); + const wsApply = path.join( + testDir, + '.windsurf/workflows/openspec-apply.md' + ); + const wsArchive = path.join( + testDir, + '.windsurf/workflows/openspec-archive.md' + ); + + expect(await fileExists(wsProposal)).toBe(true); + expect(await fileExists(wsApply)).toBe(true); + expect(await fileExists(wsArchive)).toBe(true); + + const proposalContent = await fs.readFile(wsProposal, 'utf-8'); + expect(proposalContent).toContain('---'); + expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain('auto_execution_mode: 3'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(wsApply, 'utf-8'); + expect(applyContent).toContain('---'); + expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(applyContent).toContain('auto_execution_mode: 3'); + expect(applyContent).toContain(''); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(wsArchive, 'utf-8'); + expect(archiveContent).toContain('---'); + expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('auto_execution_mode: 3'); + expect(archiveContent).toContain(''); + expect(archiveContent).toContain('Run `openspec archive --yes`'); + }); + it('should always create AGENTS.md in project root', async () => { queueSelections(DONE); @@ -399,6 +443,18 @@ describe('InitCommand', () => { const preselected = secondRunArgs.initialSelected ?? []; expect(preselected).toContain('kilocode'); }); + + it('should mark Windsurf as already configured during extend mode', async () => { + queueSelections('windsurf', DONE, 'windsurf', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const wsChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'windsurf' + ); + expect(wsChoice.configured).toBe(true); + }); }); describe('error handling', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 2c5f80b2..93c18d48 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -220,6 +220,81 @@ Old body consoleSpy.mockRestore(); }); + it('should refresh existing Windsurf workflows', async () => { + const wsPath = path.join( + testDir, + '.windsurf/workflows/openspec-apply.md' + ); + await fs.mkdir(path.dirname(wsPath), { recursive: true }); + const initialContent = `## OpenSpec: Apply (Windsurf) +Intro + +Old body +`; + await fs.writeFile(wsPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(wsPath, 'utf-8'); + expect(updated).toContain('Work through tasks sequentially'); + expect(updated).not.toContain('Old body'); + expect(updated).toContain('## OpenSpec: Apply (Windsurf)'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated slash commands: .windsurf/workflows/openspec-apply.md' + ); + consoleSpy.mockRestore(); + }); + + it('should preserve Windsurf content outside markers during update', async () => { + const wsPath = path.join( + testDir, + '.windsurf/workflows/openspec-proposal.md' + ); + await fs.mkdir(path.dirname(wsPath), { recursive: true }); + const initialContent = `## Custom Intro Title\nSome intro text\n\nOld body\n\n\nFooter stays`; + await fs.writeFile(wsPath, initialContent); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(wsPath, '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 not create missing Windsurf workflows on update', async () => { + const wsApply = path.join( + testDir, + '.windsurf/workflows/openspec-apply.md' + ); + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(wsApply), { recursive: true }); + await fs.writeFile( + wsApply, + '\nOld\n' + ); + + await updateCommand.execute(testDir); + + const wsProposal = path.join( + testDir, + '.windsurf/workflows/openspec-proposal.md' + ); + const wsArchive = path.join( + testDir, + '.windsurf/workflows/openspec-archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(wsProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(wsArchive)).resolves.toBe(false); + }); + it('should handle no AI tool files present', async () => { // Execute update command with no AI tool files const consoleSpy = vi.spyOn(console, 'log');