fix(#1615): install Windsurf slash workflows
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(', () => {
|
||||
|
||||
Reference in New Issue
Block a user