fix(#1615): install Windsurf slash workflows

This commit is contained in:
Tom Boucher
2026-06-23 12:10:21 -04:00
parent c28cccbf85
commit fc2a7c0555
18 changed files with 222 additions and 162 deletions

View File

@@ -56,13 +56,13 @@ const RUNTIME_META = {
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
trae: { localDir: '.trae', globalSuffix: '.trae' },
windsurf: { localDir: '.devin', globalSuffix: path.join('.codeium', 'windsurf') },
windsurf: { localDir: '.windsurf', globalSuffix: path.join('.codeium', 'windsurf') },
};
// Runtimes that emit per-skill files under skills/ (not rules-based or commands-based)
const SKILL_RUNTIMES = [
'claude', 'opencode', 'gemini', 'kilo', 'codex', 'copilot', 'antigravity',
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
'cursor', 'augment', 'trae', 'qwen', 'codebuddy',
];
// ─── Helper functions ─────────────────────────────────────────────────────────
@@ -114,7 +114,7 @@ function runMinimalInstall({ runtime, scope, extraArgs = [] }) {
const LOCAL_DIR_NAME = {
claude: '.claude', opencode: '.opencode', gemini: '.gemini', kilo: '.kilo',
codex: '.codex', copilot: '.github', antigravity: '.agents', cursor: '.cursor',
windsurf: '.devin', augment: '.augment', trae: '.trae', qwen: '.qwen',
windsurf: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen',
codebuddy: '.codebuddy', cline: '.',
};
let configDir;

View File

@@ -140,7 +140,7 @@ describe('installRuntimeArtifacts — consumes Runtime Artifact Install Plan Mod
const SKILLS_RUNTIMES_LAYOUT = [
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'kimi', 'codebuddy',
'augment', 'trae', 'qwen', 'kimi', 'codebuddy',
];
const ALL_RUNTIMES_LAYOUT = [
@@ -298,6 +298,47 @@ describe('installRuntimeArtifacts — cursor commands layout (#785)', () => {
});
});
describe('installRuntimeArtifacts — windsurf workflows layout (#1615)', () => {
test('windsurf: local install writes workflow slash-command files, not skills', (t) => {
const configDir = createTempDir('gsd-ial-windsurf-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('windsurf', configDir, 'local', RESOLVED_CORE);
const workflowsDir = path.join(configDir, 'workflows');
assert.ok(fs.existsSync(workflowsDir), 'workflows/ must exist for Windsurf local install');
assert.ok(fs.existsSync(path.join(workflowsDir, 'gsd-help.md')),
'workflows/gsd-help.md must exist for /gsd-help');
assert.ok(!fs.existsSync(path.join(configDir, 'skills')),
'Windsurf must not install dead skills/ artifacts for slash commands');
const helpContent = fs.readFileSync(path.join(workflowsDir, 'gsd-help.md'), 'utf8');
assert.ok(!helpContent.startsWith('---'), 'Windsurf workflows must be plain markdown, not SKILL.md frontmatter');
assert.match(helpContent, /# gsd-help/, 'workflow should identify the slash command it backs');
assert.ok(helpContent.includes(`${configDir}/gsd-core/commands/gsd/help.md`),
'workflow should reference the installed command body using the actual install target');
for (const fileName of fs.readdirSync(workflowsDir)) {
if (!fileName.endsWith('.md')) continue;
const workflowPath = path.join(workflowsDir, fileName);
const byteLength = Buffer.byteLength(fs.readFileSync(workflowPath, 'utf8'), 'utf8');
assert.ok(byteLength <= 12000, `${fileName} must respect Windsurf's 12,000-character workflow limit`);
}
});
test('windsurf: global install is explicit no-op for workflow artifacts', (t) => {
const configDir = createTempDir('gsd-ial-windsurf-global-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('windsurf', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(path.join(configDir, 'workflows')),
'global Windsurf install must not write workflows under the config root');
assert.ok(!fs.existsSync(path.join(configDir, 'skills')),
'global Windsurf install must not write dead skills artifacts');
});
});
describe('installRuntimeArtifacts — cline skills (#782)', () => {
test('cline: global install writes gsd-prefixed skill dirs under skills/', (t) => {
const configDir = createTempDir('gsd-ial-cline-');

View File

@@ -1269,12 +1269,12 @@ describe('install — --devin-desktop CLI flag routes to windsurf runtime (#792)
assert.deepStrictEqual(selectRuntimesFromArgs(['--devin-desktop']), ['windsurf']);
});
});
// ─── Section N: Windsurf .devin canonical workspace dir (#1085) ─────────────
// ─── Section N: Windsurf workflow slash-command install (#1615) ─────────────
// allow-test-rule: runtime-contract-is-the-product
// Reads deployed skill .md files whose text IS the product surface the
// Windsurf/Devin Desktop runtime loads at startup (path references, command names).
// Reads deployed workflow .md files whose text IS the product surface the
// Windsurf runtime loads at startup (path references, command names).
describe('windsurf local install writes to .devin/ canonical dir (#1085)', () => {
describe('windsurf local install writes workflow slash commands (#1615)', () => {
let tmpDir;
let previousCwd;
@@ -1289,50 +1289,48 @@ describe('windsurf local install writes to .devin/ canonical dir (#1085)', () =>
cleanup(tmpDir);
});
test('install writes workspace skills under .devin/skills/', () => {
test('install writes workspace workflows under .windsurf/workflows/', () => {
const result = install(false, 'windsurf');
const devinDir = path.join(tmpDir, '.devin');
const windsurfDir = path.join(tmpDir, '.windsurf');
assert.strictEqual(result.runtime, 'windsurf');
assert.ok(fs.existsSync(devinDir), '.devin/ must be created for local windsurf install');
const skillsDir = path.join(devinDir, 'skills');
assert.ok(fs.existsSync(skillsDir), '.devin/skills/ must exist after install');
const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillEntries.length > 0, 'at least one gsd-* skill must be installed under .devin/skills/');
const firstSkill = path.join(skillsDir, skillEntries[0].name, 'SKILL.md');
assert.ok(fs.existsSync(firstSkill), `SKILL.md must exist at ${firstSkill}`);
assert.ok(fs.existsSync(windsurfDir), '.windsurf/ must be created for local windsurf install');
const workflowsDir = path.join(windsurfDir, 'workflows');
assert.ok(fs.existsSync(workflowsDir), '.windsurf/workflows/ must exist after install');
const workflowEntries = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(workflowEntries.length > 0, 'at least one gsd-* workflow must be installed under .windsurf/workflows/');
assert.ok(fs.existsSync(path.join(workflowsDir, 'gsd-help.md')), 'gsd-help.md workflow must exist');
});
test('legacy .windsurf/ is NOT written on a fresh local install', () => {
test('legacy .devin/skills is NOT written on a fresh local install', () => {
install(false, 'windsurf');
const legacyDir = path.join(tmpDir, '.windsurf');
assert.ok(!fs.existsSync(legacyDir),
'.windsurf/ must not be created by a fresh install (new installs use .devin/)');
assert.ok(!fs.existsSync(path.join(tmpDir, '.devin', 'skills')),
'.devin/skills must not be created by a fresh install (new installs use .windsurf/workflows)');
assert.ok(!fs.existsSync(path.join(tmpDir, '.windsurf', 'skills')),
'.windsurf/skills must not be created for slash commands');
});
test('installed skill content references .devin/ not bare .windsurf/ or ~/.claude/', () => {
test('installed workflow content references command body, not skill locations', () => {
install(false, 'windsurf');
const skillsDir = path.join(tmpDir, '.devin', 'skills');
const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillEntries.length > 0, 'pre-condition: at least one gsd-* skill must be installed');
for (const skillEntry of skillEntries) {
const skillFile = path.join(skillsDir, skillEntry.name, 'SKILL.md');
if (!fs.existsSync(skillFile)) continue;
const content = fs.readFileSync(skillFile, 'utf8');
const workflowsDir = path.join(tmpDir, '.windsurf', 'workflows');
const workflowEntries = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(workflowEntries.length > 0, 'pre-condition: at least one gsd-* workflow must be installed');
for (const workflowEntry of workflowEntries) {
const workflowFile = path.join(workflowsDir, workflowEntry.name);
const content = fs.readFileSync(workflowFile, 'utf8');
assert.ok(
!content.includes('~/.claude/') && !content.includes('$HOME/.claude/'),
`${skillEntry.name}/SKILL.md must not contain ~/.claude/ or $HOME/.claude/ in a local install`,
content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`),
`${workflowEntry.name} must reference the installed canonical command body`,
);
// Local install must use workspace-relative .devin/ form, not the legacy .windsurf/ form
assert.ok(
!content.includes('~/.windsurf/') && !content.includes('.windsurf/skills/'),
`${skillEntry.name}/SKILL.md must not contain bare .windsurf/ path in a local install (use .devin/ instead)`,
!content.includes('/skills/') && !content.includes('SKILL.md'),
`${workflowEntry.name} must not reference Windsurf skill locations`,
);
}
});
test('global windsurf install still writes to ~/.codeium/windsurf/ (unchanged)', () => {
test('global windsurf install does not write unsupported workflows or skills', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
@@ -1348,12 +1346,12 @@ describe('windsurf local install writes to .devin/ canonical dir (#1085)', () =>
`global windsurf install must go to codeium/windsurf path, got: ${result.configDir}`,
);
assert.ok(
fs.existsSync(path.join(result.configDir, 'skills')),
'global windsurf install must create skills/ under ~/.codeium/windsurf',
!fs.existsSync(path.join(result.configDir, 'workflows')),
'global windsurf install must not create unsupported workflows/ under ~/.codeium/windsurf',
);
assert.ok(
!fs.existsSync(path.join(homeDir, '.devin')),
'.devin/ must NOT be created by a global install (global path is ~/.codeium/windsurf)',
!fs.existsSync(path.join(result.configDir, 'skills')),
'global windsurf install must not create dead skills/ artifacts',
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
@@ -1366,7 +1364,7 @@ describe('windsurf local install writes to .devin/ canonical dir (#1085)', () =>
}
});
test('global windsurf install skill content references codeium path not .devin/', () => {
test('global windsurf install still installs shared workflow assets', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-c-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
@@ -1376,37 +1374,10 @@ describe('windsurf local install writes to .devin/ canonical dir (#1085)', () =>
process.env.USERPROFILE = homeDir;
try {
const result = install(true, 'windsurf');
const skillsDir = path.join(result.configDir, 'skills');
if (!fs.existsSync(skillsDir)) return; // no skills emitted — skip
const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
// At least one skill body must reference the codeium/windsurf global path (#1085):
// the isGlobal-threaded rewrite converts .devin/skills/ → $HOME/.codeium/windsurf/skills/
let foundGlobalRef = false;
for (const skillEntry of skillEntries) {
const skillFile = path.join(skillsDir, skillEntry.name, 'SKILL.md');
if (!fs.existsSync(skillFile)) continue;
const content = fs.readFileSync(skillFile, 'utf8');
// Global skill content must not reference local workspace-relative .devin/ paths
assert.ok(
!content.includes('.devin/skills/'),
`${skillEntry.name}/SKILL.md must not reference .devin/skills/ in global install (should use codeium path)`,
);
assert.ok(
!content.includes('~/.claude/') && !content.includes('$HOME/.claude/'),
`${skillEntry.name}/SKILL.md must not contain ~/.claude/ or $HOME/.claude/ in global install`,
);
if (content.includes('codeium/windsurf/skills/') || content.includes('$HOME/.codeium/windsurf/skills/')) {
foundGlobalRef = true;
}
}
// Verify the global-path rewrite actually fired on at least one skill (FIX 1 guard)
if (skillEntries.some(e => fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))) {
assert.ok(
foundGlobalRef,
'at least one global windsurf SKILL.md must reference the codeium/windsurf/skills/ path (isGlobal rewrite must have fired)',
);
}
assert.ok(fs.existsSync(path.join(result.configDir, 'gsd-core', 'workflows', 'update.md')),
'global windsurf install should still copy shared gsd-core workflow assets');
assert.ok(!fs.existsSync(path.join(result.configDir, 'workflows')),
'global windsurf install must not write workflow files into an undocumented global path');
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;

View File

@@ -104,12 +104,9 @@ const GOLDEN = {
],
// ── windsurf ─────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'windsurf/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'windsurf/global': [],
'windsurf/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'workflows', prefix: 'gsd-' },
],
// ── augment ──────────────────────────────────────────────────────────────────

View File

@@ -134,16 +134,23 @@ describe('resolveRuntimeArtifactLayout — antigravity', () => {
});
describe('resolveRuntimeArtifactLayout — windsurf', () => {
test('returns correct layout for windsurf', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR);
test('returns local workflow layout for windsurf', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'local');
assert.strictEqual(layout.runtime, 'windsurf');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'workflows');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
});
test('returns empty global layout for windsurf', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'global');
assert.strictEqual(layout.runtime, 'windsurf');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 0);
});
});
describe('resolveRuntimeArtifactLayout — augment', () => {

View File

@@ -13,6 +13,7 @@ const assert = require('node:assert/strict');
const {
convertClaudeCommandToWindsurfSkill,
convertClaudeCommandToWindsurfWorkflow,
convertClaudeAgentToWindsurfAgent,
convertClaudeToWindsurfMarkdown,
} = require('../bin/install.js');
@@ -73,6 +74,29 @@ Body content.
});
});
describe('convertClaudeCommandToWindsurfWorkflow', () => {
test('writes a plain workflow wrapper for slash commands', () => {
const input = `---
name: quick
description: Execute a quick task
---
<objective>
Test body
</objective>
`;
const result = convertClaudeCommandToWindsurfWorkflow(input, 'gsd-quick');
assert.ok(!result.startsWith('---'), 'workflow has no YAML frontmatter');
assert.match(result, /^# gsd-quick$/m, 'workflow title names the slash command');
assert.ok(result.includes('Execute a quick task'), 'description is preserved');
assert.ok(result.includes('@~/.claude/gsd-core/commands/gsd/quick.md'), 'workflow delegates to canonical command body');
assert.ok(result.includes('/gsd-quick'), 'workflow mentions the slash command invocation');
assert.ok(Buffer.byteLength(result, 'utf8') <= 12000, 'workflow respects Windsurf limit');
});
});
describe('convertClaudeAgentToWindsurfAgent', () => {
test('converts agent frontmatter with unquoted name', () => {
const input = `---
@@ -105,17 +129,17 @@ describe('convertClaudeToWindsurfMarkdown', () => {
assert.ok(!result.includes('Claude Code'), 'original brand removed');
});
test('replaces CLAUDE.md with .devin/rules (no trailing slash)', () => {
test('replaces CLAUDE.md with .windsurf/rules (no trailing slash)', () => {
const input = 'See `CLAUDE.md` for configuration. Also check ./CLAUDE.md file.';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(result.includes('.devin/rules'), 'CLAUDE.md replaced with .devin/rules (#1085)');
assert.ok(!result.includes('.devin/rules/'), 'no trailing slash (Node v25 compat)');
assert.ok(result.includes('.windsurf/rules'), 'CLAUDE.md replaced with .windsurf/rules');
assert.ok(!result.includes('.windsurf/rules/'), 'no trailing slash (Node v25 compat)');
});
test('replaces .claude/skills/ with .devin/skills/', () => {
test('replaces .claude/skills/ with .windsurf/skills/', () => {
const input = 'Skills are stored in .claude/skills/ directory.';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(result.includes('.devin/skills/'), 'skills path replaced with .devin/skills/ (#1085)');
assert.ok(result.includes('.windsurf/skills/'), 'skills path replaced with .windsurf/skills/');
});
test('replaces Bash( with Shell( and Edit( with StrReplace(', () => {