diff --git a/.changeset/1085-devin-skills-workspace.md b/.changeset/1085-devin-skills-workspace.md new file mode 100644 index 000000000..a3e7f7a1d --- /dev/null +++ b/.changeset/1085-devin-skills-workspace.md @@ -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) diff --git a/bin/install.js b/bin/install.js index 34349fec5..0aa0f4ab5 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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', diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0367a81ae..84c8ac658 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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//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//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//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//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 59d361209..18f6c4c6a 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -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:** diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 7ba863c56..89a084e3f 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -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 | diff --git a/src/update-context.cts b/src/update-context.cts index c11ab5895..16bbb2f33 100644 --- a/src/update-context.cts +++ b/src/update-context.cts @@ -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'], diff --git a/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs b/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs index 035a70828..228679664 100644 --- a/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs +++ b/tests/bug-983-trae-windsurf-claude-path-leak.test.cjs @@ -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', () => { diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 472e96d14..860327ef8 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -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; diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 51dea95b1..a3a7d951a 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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): diff --git a/tests/windsurf-conversion.test.cjs b/tests/windsurf-conversion.test.cjs index 75e1a3711..34e1b4863 100644 --- a/tests/windsurf-conversion.test.cjs +++ b/tests/windsurf-conversion.test.cjs @@ -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(', () => {