Skip to content

Commit b3d05d2

Browse files
riccorohlTabishB
andauthored
Add Windsurf IDE support with slash commands (#113)
* docs(windsurf): propose workflow support * restore missing opencode spec * Add Windsurf IDE support with slash commands * feat(windsurf): add Windsurf workflows support under .windsurf/workflows and simplify templates\n\n- Write workflows to .windsurf/workflows instead of .windsurf/commands\n- Remove YAML frontmatter; add concise intro before managed markers\n- Add init/update tests for Windsurf and marker preservation\n- List Windsurf in README native tools table\n- Normalize registry indentation * chore(windsurf): remove optional intro content to simplify workflows\n\n- Drop intro hook and headings for Windsurf workflows\n- Keep OPENSPEC markers-only body for safe updates\n- Adjust tests to assert marker-managed content * feat(windsurf): add required YAML frontmatter to workflows\n\n- Include description and auto_execution_mode: 3 for proposal/apply/archive\n- Keep content minimal; body remains marker-managed --------- Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com> Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
1 parent 9848242 commit b3d05d2

File tree

6 files changed

+163
-0
lines changed

6 files changed

+163
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
8585
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
8686
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
8787
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
88+
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
8889

8990
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`.
9091

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ export const AI_TOOLS: AIToolOption[] = [
2121
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
2222
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
2323
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
24+
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
2425
{ name: 'AGENTS.md (works with Codex, Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
2526
];

src/core/configurators/slash/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SlashCommandConfigurator } from './base.js';
22
import { ClaudeSlashCommandConfigurator } from './claude.js';
33
import { CursorSlashCommandConfigurator } from './cursor.js';
4+
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
45
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
56
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
67

@@ -10,11 +11,13 @@ export class SlashCommandRegistry {
1011
static {
1112
const claude = new ClaudeSlashCommandConfigurator();
1213
const cursor = new CursorSlashCommandConfigurator();
14+
const windsurf = new WindsurfSlashCommandConfigurator();
1315
const kilocode = new KiloCodeSlashCommandConfigurator();
1416
const opencode = new OpenCodeSlashCommandConfigurator();
1517

1618
this.configurators.set(claude.toolId, claude);
1719
this.configurators.set(cursor.toolId, cursor);
20+
this.configurators.set(windsurf.toolId, windsurf);
1821
this.configurators.set(kilocode.toolId, kilocode);
1922
this.configurators.set(opencode.toolId, opencode);
2023
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { SlashCommandConfigurator } from './base.js';
2+
import { SlashCommandId } from '../../templates/index.js';
3+
4+
const FILE_PATHS: Record<SlashCommandId, string> = {
5+
proposal: '.windsurf/workflows/openspec-proposal.md',
6+
apply: '.windsurf/workflows/openspec-apply.md',
7+
archive: '.windsurf/workflows/openspec-archive.md'
8+
};
9+
10+
export class WindsurfSlashCommandConfigurator extends SlashCommandConfigurator {
11+
readonly toolId = 'windsurf';
12+
readonly isAvailable = true;
13+
14+
protected getRelativePath(id: SlashCommandId): string {
15+
return FILE_PATHS[id];
16+
}
17+
18+
protected getFrontmatter(id: SlashCommandId): string | undefined {
19+
const descriptions: Record<SlashCommandId, string> = {
20+
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
21+
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
22+
archive: 'Archive a deployed OpenSpec change and update specs.'
23+
};
24+
const description = descriptions[id];
25+
return `---\ndescription: ${description}\nauto_execution_mode: 3\n---`;
26+
}
27+
}

test/core/init.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,50 @@ describe('InitCommand', () => {
129129
expect(updatedContent).toContain('Custom instructions here');
130130
});
131131

132+
it('should create Windsurf workflows when Windsurf is selected', async () => {
133+
queueSelections('windsurf', DONE);
134+
135+
await initCommand.execute(testDir);
136+
137+
const wsProposal = path.join(
138+
testDir,
139+
'.windsurf/workflows/openspec-proposal.md'
140+
);
141+
const wsApply = path.join(
142+
testDir,
143+
'.windsurf/workflows/openspec-apply.md'
144+
);
145+
const wsArchive = path.join(
146+
testDir,
147+
'.windsurf/workflows/openspec-archive.md'
148+
);
149+
150+
expect(await fileExists(wsProposal)).toBe(true);
151+
expect(await fileExists(wsApply)).toBe(true);
152+
expect(await fileExists(wsArchive)).toBe(true);
153+
154+
const proposalContent = await fs.readFile(wsProposal, 'utf-8');
155+
expect(proposalContent).toContain('---');
156+
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
157+
expect(proposalContent).toContain('auto_execution_mode: 3');
158+
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
159+
expect(proposalContent).toContain('**Guardrails**');
160+
161+
const applyContent = await fs.readFile(wsApply, 'utf-8');
162+
expect(applyContent).toContain('---');
163+
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
164+
expect(applyContent).toContain('auto_execution_mode: 3');
165+
expect(applyContent).toContain('<!-- OPENSPEC:START -->');
166+
expect(applyContent).toContain('Work through tasks sequentially');
167+
168+
const archiveContent = await fs.readFile(wsArchive, 'utf-8');
169+
expect(archiveContent).toContain('---');
170+
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
171+
expect(archiveContent).toContain('auto_execution_mode: 3');
172+
expect(archiveContent).toContain('<!-- OPENSPEC:START -->');
173+
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
174+
});
175+
132176
it('should always create AGENTS.md in project root', async () => {
133177
queueSelections(DONE);
134178

@@ -399,6 +443,18 @@ describe('InitCommand', () => {
399443
const preselected = secondRunArgs.initialSelected ?? [];
400444
expect(preselected).toContain('kilocode');
401445
});
446+
447+
it('should mark Windsurf as already configured during extend mode', async () => {
448+
queueSelections('windsurf', DONE, 'windsurf', DONE);
449+
await initCommand.execute(testDir);
450+
await initCommand.execute(testDir);
451+
452+
const secondRunArgs = mockPrompt.mock.calls[1][0];
453+
const wsChoice = secondRunArgs.choices.find(
454+
(choice: any) => choice.value === 'windsurf'
455+
);
456+
expect(wsChoice.configured).toBe(true);
457+
});
402458
});
403459

404460
describe('error handling', () => {

test/core/update.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,81 @@ Old body
220220
consoleSpy.mockRestore();
221221
});
222222

223+
it('should refresh existing Windsurf workflows', async () => {
224+
const wsPath = path.join(
225+
testDir,
226+
'.windsurf/workflows/openspec-apply.md'
227+
);
228+
await fs.mkdir(path.dirname(wsPath), { recursive: true });
229+
const initialContent = `## OpenSpec: Apply (Windsurf)
230+
Intro
231+
<!-- OPENSPEC:START -->
232+
Old body
233+
<!-- OPENSPEC:END -->`;
234+
await fs.writeFile(wsPath, initialContent);
235+
236+
const consoleSpy = vi.spyOn(console, 'log');
237+
238+
await updateCommand.execute(testDir);
239+
240+
const updated = await fs.readFile(wsPath, 'utf-8');
241+
expect(updated).toContain('Work through tasks sequentially');
242+
expect(updated).not.toContain('Old body');
243+
expect(updated).toContain('## OpenSpec: Apply (Windsurf)');
244+
245+
const [logMessage] = consoleSpy.mock.calls[0];
246+
expect(logMessage).toContain(
247+
'Updated slash commands: .windsurf/workflows/openspec-apply.md'
248+
);
249+
consoleSpy.mockRestore();
250+
});
251+
252+
it('should preserve Windsurf content outside markers during update', async () => {
253+
const wsPath = path.join(
254+
testDir,
255+
'.windsurf/workflows/openspec-proposal.md'
256+
);
257+
await fs.mkdir(path.dirname(wsPath), { recursive: true });
258+
const initialContent = `## Custom Intro Title\nSome intro text\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\n\nFooter stays`;
259+
await fs.writeFile(wsPath, initialContent);
260+
261+
await updateCommand.execute(testDir);
262+
263+
const updated = await fs.readFile(wsPath, 'utf-8');
264+
expect(updated).toContain('## Custom Intro Title');
265+
expect(updated).toContain('Footer stays');
266+
expect(updated).not.toContain('Old body');
267+
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
268+
});
269+
270+
it('should not create missing Windsurf workflows on update', async () => {
271+
const wsApply = path.join(
272+
testDir,
273+
'.windsurf/workflows/openspec-apply.md'
274+
);
275+
// Only create apply; leave proposal and archive missing
276+
await fs.mkdir(path.dirname(wsApply), { recursive: true });
277+
await fs.writeFile(
278+
wsApply,
279+
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
280+
);
281+
282+
await updateCommand.execute(testDir);
283+
284+
const wsProposal = path.join(
285+
testDir,
286+
'.windsurf/workflows/openspec-proposal.md'
287+
);
288+
const wsArchive = path.join(
289+
testDir,
290+
'.windsurf/workflows/openspec-archive.md'
291+
);
292+
293+
// Confirm they weren't created by update
294+
await expect(FileSystemUtils.fileExists(wsProposal)).resolves.toBe(false);
295+
await expect(FileSystemUtils.fileExists(wsArchive)).resolves.toBe(false);
296+
});
297+
223298
it('should handle no AI tool files present', async () => {
224299
// Execute update command with no AI tool files
225300
const consoleSpy = vi.spyOn(console, 'log');

0 commit comments

Comments
 (0)