feat(#1085): migrate windsurf workspace skills to .devin/ + fix global content refs (#1093)

Fresh windsurf/devin-desktop workspace installs write skills under .devin/ (legacy .windsurf/ recognized); global ~/.codeium/windsurf/ unchanged. Also threads real isGlobal through _applyRuntimeRewrites so global skill content references the codeium path. Closes #1085.
This commit is contained in:
Tom Boucher
2026-06-11 23:17:01 -04:00
committed by GitHub
parent 77a671ec53
commit b4a7eabaae
10 changed files with 215 additions and 39 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1093
---
**Windsurf/Devin workspace skills now install to the canonical `.devin/skills/` directory** — fresh workspace installs write skills under `.devin/skills/` (Devin Desktop's documented preferred location) instead of `.windsurf/skills/`; the legacy `.windsurf/skills/` layout is still recognized. The global `~/.codeium/windsurf/skills/` path is unchanged. (#1093)

View File

@@ -467,7 +467,7 @@ function getDirName(runtime) {
if (runtime === 'codex') return '.codex';
if (runtime === 'antigravity') return '.agents';
if (runtime === 'cursor') return '.cursor';
if (runtime === 'windsurf') return '.windsurf';
if (runtime === 'windsurf') return '.devin';
if (runtime === 'augment') return '.augment';
if (runtime === 'trae') return '.trae';
if (runtime === 'qwen') return '.qwen';
@@ -2504,18 +2504,20 @@ function convertClaudeToWindsurfMarkdown(content) {
// Replace subagent_type from Claude to Windsurf format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
// Replace project-level Claude conventions with Windsurf equivalents
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
converted = converted.replace(/\.claude\//g, '.windsurf/');
// Replace project-level Claude conventions with Windsurf/Devin equivalents
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
// Legacy .windsurf/ is still recognized on read but new installs use .devin/.
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.devin/');
converted = converted.replace(/\.claude\//g, '.devin/');
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
// Environment variable name rewrite
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
// Remove Claude Code-specific bug workarounds before brand replacement
@@ -6782,8 +6784,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
* @param {string} stagedDir
* @param {string} runtime
* @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
*/
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
if (!fs.existsSync(stagedDir)) return;
// Walk all SKILL.md files under stagedDir
@@ -6794,7 +6797,7 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
walkAndRewrite(fullPath);
} else if (entry.name.endsWith('.md')) {
let content = fs.readFileSync(fullPath, 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
fs.writeFileSync(fullPath, content);
}
}
@@ -6815,9 +6818,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
* @param {string} runtime
* @param {string} pathPrefix
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
*/
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
if (!fs.existsSync(stagedDir)) return stagedDir;
// Always copy to a temp dir — stageSkillsForProfile() returns the original source
// dir on full/default profile (skills === '*'), so writing in-place would corrupt the
@@ -6827,7 +6831,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
// For augment commands, apply the markdown conversion so tool references
// and skill paths use Augment equivalents.
if (runtime === 'augment') {
@@ -6849,9 +6853,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
* @param {string} content
* @param {string} runtime
* @param {string} pathPrefix trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string}
*/
function _applyRuntimeRewrites(content, runtime, pathPrefix) {
function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
const dirName = getDirName(runtime);
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
@@ -6888,18 +6893,30 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
content = processAttribution(content, getCommitAttribution(runtime));
break;
case 'windsurf':
case 'windsurf': {
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
// Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
// .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
// fires between 'e' and '-', which rewrites .claude-plugin → .windsurf-plugin).
// fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
// Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
// form). For global installs the real path is pathPrefix + skills/, so fix that up
// here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
// not derived from pathPrefix substring which misclassifies custom config dirs).
// For local installs, the relative .devin/ form is correct — leave it. (#1085)
if (isGlobal) {
content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
content = content.replace(/\.\/\.devin\//g, pathPrefix);
content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
}
content = processAttribution(content, getCommitAttribution(runtime));
break;
}
case 'augment':
content = content.replace(/~\/\.claude\//g, pathPrefix);
@@ -7331,11 +7348,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
// stagedForCopy: the directory to copy from (may differ from staged if rewrites
// produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
let stagedForCopy = staged;
const isGlobal = scope === 'global';
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
} else if (kind.kind === 'commands') {
// Returns a temp dir with rewritten content so source files are never mutated.
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
}
// applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
// os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
@@ -7670,11 +7688,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
fs.writeFileSync(destPath, jsContent);
} else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
// For Windsurf, also convert Claude references in JS/CJS utility scripts
// For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
let jsContent = fs.readFileSync(srcPath, 'utf8');
jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.windsurf/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.windsurf/rules');
jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
fs.writeFileSync(destPath, jsContent);
} else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
@@ -11907,7 +11926,8 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
'.agents', // antigravity local form (canonical, #791)
'.agent', // antigravity local form (legacy, backward-compat)
'.cursor',
'.windsurf',
'.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
'.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
'.codeium/windsurf',
'.augment',
'.trae',

View File

@@ -586,7 +586,7 @@ Equivalent paths for other runtimes:
- **Copilot:** `~/.copilot/` global or `./.github/` local
- **Antigravity:** auto-detected global root (`~/.gemini/antigravity/`, `~/.gemini/antigravity-ide/`, or `~/.gemini/antigravity-cli/`) or `./.agent/` local
- **Cursor:** `~/.cursor/` global or `./.cursor/` local
- **Windsurf:** `~/.codeium/windsurf/` global or `./.windsurf/` local
- **Windsurf/Devin Desktop:** `~/.codeium/windsurf/` global or `./.devin/` local (canonical, #1085); `./.windsurf/` local is still recognized as legacy
- **Augment Code:** `~/.augment/` global or `./.augment/` local
- **Trae:** `~/.trae/` global or `./.trae/` local
- **Qwen Code:** `~/.qwen/` global or `./.qwen/` local
@@ -825,7 +825,7 @@ The migration-specific ownership and source snapshots live in
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` (flat), `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline |
| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills/<name>/SKILL.md` (nested concretes) | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD |
| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; `hooks.json` with sessionStart context injection and postToolUse STATE.md monitor (#777) |
| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
| Windsurf | `~/.codeium/windsurf` | `./.devin` (canonical, #1085); `./.windsurf` legacy recognized | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
| Augment Code | `~/.augment` | `./.augment` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills/<name>/SKILL.md` (nested concretes) | `agents/gsd-*.md` | No GSD hooks or statusline |
| Trae | `~/.trae` | `./.trae` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills/<name>/SKILL.md` (nested concretes) | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
| Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills/<name>/SKILL.md` (nested concretes) | `agents/gsd-*.md` | Common GSD settings and hook entries where supported |

View File

@@ -318,7 +318,7 @@ npx @opengsd/gsd-core@latest --windsurf --global
npx @opengsd/gsd-core@latest --devin-desktop --global
```
Skills land in `~/.codeium/windsurf/` (global path unchanged). GSD installs skills, agents, and workspace rules.
Global skills land in `~/.codeium/windsurf/` (unchanged). Local workspace installs write to `.devin/skills/` (Devin Desktop's preferred location, #1085); the legacy `.windsurf/skills/` layout is still recognized for backward-compat. GSD installs skills, agents, and workspace rules.
**Override the install directory:**

View File

@@ -373,7 +373,7 @@ for the new shape before changing migration behavior.
| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 |
| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agents` (canonical, #791) or `./.agent` (legacy, recognized for backward-compat) | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. Fresh installs write to `.agents/` (the Google-Codelabs-documented form); existing `.agent/` installs continue to be detected and served. |
| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/`; lifecycle hooks via `hooks.json` (sessionStart + postToolUse, #777) | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents, GSD rule files or references, and GSD-managed `hooks.json` entries (sentinel `gsd-managed:true`); no statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); [Cursor hooks](https://docs.cursor.com/context/hooks); docs not versioned, checked 2026-06-07 |
| Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules |
| Windsurf / Devin Desktop | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.devin` (canonical, #1085) or `./.windsurf` (legacy, recognized for backward-compat) | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf has rebranded to Devin Desktop; workspace skills install to `.devin/` per Devin Desktop documented preferred location (#1085). Global `~/.codeium/windsurf/` is unchanged. Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.devin/rules` and must be rechecked before migrations rewrite rules |
| Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 |
| Trae | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `TRAE_CONFIG_DIR` or `~/.trae`; local `./.trae` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Public Trae docs expose AI settings and `.rules` announcements, but no stable skills/config API was found as of 2026-05-11; migrations must treat this row as source-limited |
| Qwen Code | Claude-compatible skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; optional common hook/settings integration through GSD | Global `QWEN_CONFIG_DIR` or `~/.qwen`; local `./.qwen` | GSD owns generated skills/agents/hooks and GSD settings entries only | [Qwen commands and skills](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/); docs last updated 2026-05-06 |

View File

@@ -26,6 +26,8 @@ export const RUNTIME_DIRS: RuntimeDirEntry[] = [
['antigravity', '.gemini/antigravity'],
['antigravity', '.agents'], // local Antigravity install dir canonical (#791; bin/install.js getDirName('antigravity'))
['antigravity', '.agent'], // local Antigravity install dir legacy (#503; backward-compat with pre-#791 installs)
['windsurf', '.devin'], // local Windsurf/Devin Desktop install dir canonical (#1085; bin/install.js getDirName('windsurf'))
['windsurf', '.windsurf'], // local Windsurf install dir legacy (#1085; backward-compat with pre-#1085 installs)
['gemini', '.gemini'],
['kilo', '.config/kilo'],
['kilo', '.kilo'],

View File

@@ -29,24 +29,24 @@ const {
// ─── Windsurf converter bare-form tests ─────────────────────────────────────
describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => {
test('bare ~/.claude rewritten to ~/.windsurf', () => {
test('bare ~/.claude rewritten to ~/.devin (#1085: workspace dir is now .devin)', () => {
const input = 'Config dir: (~/.claude), skills at ~/.claude/skills';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(
!/~\/\.claude(?![\w-])/.test(result),
`bare ~/.claude must be rewritten; got: ${result}`,
);
assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf');
assert.ok(result.includes('~/.devin'), 'must rewrite to ~/.devin');
});
test('$HOME/.claude rewritten to $HOME/.windsurf', () => {
test('$HOME/.claude rewritten to $HOME/.devin (#1085: workspace dir is now .devin)', () => {
const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(
!/\$HOME\/\.claude(?![\w-])/.test(result),
`bare $HOME/.claude must be rewritten; got: ${result}`,
);
assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf');
assert.ok(result.includes('$HOME/.devin'), 'must rewrite to $HOME/.devin');
});
test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => {

View File

@@ -54,7 +54,7 @@ const RUNTIME_META = {
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
trae: { localDir: '.trae', globalSuffix: '.trae' },
windsurf: { localDir: '.windsurf', globalSuffix: path.join('.codeium', 'windsurf') },
windsurf: { localDir: '.devin', globalSuffix: path.join('.codeium', 'windsurf') },
};
// Runtimes that emit per-skill files under skills/ (not rules-based or commands-based)
@@ -112,7 +112,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: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen',
windsurf: '.devin', augment: '.augment', trae: '.trae', qwen: '.qwen',
codebuddy: '.codebuddy', cline: '.',
};
let configDir;

View File

@@ -954,6 +954,155 @@ 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) ─────────────
// 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).
describe('windsurf local install writes to .devin/ canonical dir (#1085)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-windsurf-1085-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('install writes workspace skills under .devin/skills/', () => {
const result = install(false, 'windsurf');
const devinDir = path.join(tmpDir, '.devin');
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}`);
});
test('legacy .windsurf/ 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/)');
});
test('installed skill content references .devin/ not bare .windsurf/ or ~/.claude/', () => {
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');
assert.ok(
!content.includes('~/.claude/') && !content.includes('$HOME/.claude/'),
`${skillEntry.name}/SKILL.md must not contain ~/.claude/ or $HOME/.claude/ in a local install`,
);
// 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)`,
);
}
});
test('global windsurf install still writes to ~/.codeium/windsurf/ (unchanged)', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedWindsurfConfig = process.env.WINDSURF_CONFIG_DIR;
delete process.env.WINDSURF_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
try {
const result = install(true, 'windsurf');
assert.strictEqual(result.runtime, 'windsurf');
assert.ok(
result.configDir.includes('codeium') || result.configDir.includes('windsurf'),
`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',
);
assert.ok(
!fs.existsSync(path.join(homeDir, '.devin')),
'.devin/ must NOT be created by a global install (global path is ~/.codeium/windsurf)',
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedWindsurfConfig === undefined) delete process.env.WINDSURF_CONFIG_DIR;
else process.env.WINDSURF_CONFIG_DIR = savedWindsurfConfig;
cleanup(homeDir);
}
});
test('global windsurf install skill content references codeium path not .devin/', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-c-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedWindsurfConfig = process.env.WINDSURF_CONFIG_DIR;
delete process.env.WINDSURF_CONFIG_DIR;
process.env.HOME = homeDir;
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)',
);
}
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedWindsurfConfig === undefined) delete process.env.WINDSURF_CONFIG_DIR;
else process.env.WINDSURF_CONFIG_DIR = savedWindsurfConfig;
cleanup(homeDir);
}
});
});
// ─── Section N+1: #767 — disallowedTools injection for read-only agents ──────
//
// Verifies (installer-behavioral test — drives install() to a temp dir):

View File

@@ -105,17 +105,17 @@ describe('convertClaudeToWindsurfMarkdown', () => {
assert.ok(!result.includes('Claude Code'), 'original brand removed');
});
test('replaces CLAUDE.md with .windsurf/rules (no trailing slash)', () => {
test('replaces CLAUDE.md with .devin/rules (no trailing slash)', () => {
const input = 'See `CLAUDE.md` for configuration. Also check ./CLAUDE.md file.';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(result.includes('.windsurf/rules'), 'CLAUDE.md replaced');
assert.ok(!result.includes('.windsurf/rules/'), 'no trailing slash (Node v25 compat)');
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)');
});
test('replaces .claude/skills/ with .windsurf/skills/', () => {
test('replaces .claude/skills/ with .devin/skills/', () => {
const input = 'Skills are stored in .claude/skills/ directory.';
const result = convertClaudeToWindsurfMarkdown(input);
assert.ok(result.includes('.windsurf/skills/'), 'skills path replaced');
assert.ok(result.includes('.devin/skills/'), 'skills path replaced with .devin/skills/ (#1085)');
});
test('replaces Bash( with Shell( and Edit( with StrReplace(', () => {