Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

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,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' }
];
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
}
Expand Down
27 changes: 27 additions & 0 deletions src/core/configurators/slash/windsurf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
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<SlashCommandId, string> = {
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---`;
}
}
56 changes: 56 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
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('<!-- OPENSPEC:START -->');
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('<!-- OPENSPEC:START -->');
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
});

it('should always create AGENTS.md in project root', async () => {
queueSelections(DONE);

Expand Down Expand Up @@ -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', () => {
Expand Down
75 changes: 75 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
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<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\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 <id> --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,
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);

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');
Expand Down
Loading