From 901dabe6f10192cca6080af9f28b8fd262e213e3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 26 Jun 2026 13:13:48 -0400 Subject: [PATCH] refactor(#1758): data-drive copyWithPathReplacement converter dispatch (#1759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-1239 Phase B (parent #1679). Replace copyWithPathReplacement's 13 hardcoded `const isX = runtime === 'x'` flags + two ~100-line per-runtime if/else converter chains with a module-level RUNTIME_CONTENT_DISPATCH table (one entry per runtime: md transform, mdSkipGenericRewrite, mdReattributeAfter, mdTomlRenameOnCommand, js transform) + a uniform dispatch loop that applies the cross-cutting steps (path rewrite -> attribution -> stamp -> normalize) once. Byte-identical install output for all 16 runtimes (golden-parity harness #1730); codex-verified the transform order + every per-runtime quirk (gemini .toml rename, copilot/antigravity skip-generic + reattribute, qwen/hermes inline swaps, .cjs/.js fall-through) is preserved. Also folds in a pre-existing bookkeeping fix (no-defer): gsd-core/bin/lib/ cli-skew-check.cjs (tsc build artifact from #1755) was eslint-ignored but missing from .gitignore — added for consistency with the other built artifacts. Closes #1758 Co-authored-by: review-bot Co-authored-by: Claude Opus 4.8 --- .changeset/eager-elks-frolic.md | 7 + .gitignore | 1 + bin/install.js | 258 +++++++++++++++++--------------- 3 files changed, 148 insertions(+), 118 deletions(-) create mode 100644 .changeset/eager-elks-frolic.md diff --git a/.changeset/eager-elks-frolic.md b/.changeset/eager-elks-frolic.md new file mode 100644 index 000000000..de18b72a0 --- /dev/null +++ b/.changeset/eager-elks-frolic.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1759 +--- +**Internal: copyWithPathReplacement converter selection is now data-driven** — the installer's back-compat content-copy path replaced its 13 hardcoded `runtime === 'x'` flag chains with a single per-runtime dispatch table (ADR-1239 Phase B). Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change. + + diff --git a/.gitignore b/.gitignore index e7ec2064f..79a512a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ build/ /tsconfig.build.tsbuildinfo /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/install-engine.cjs +/gsd-core/bin/lib/cli-skew-check.cjs /gsd-core/bin/lib/capability-loader.cjs /gsd-core/bin/lib/capability-source.cjs /gsd-core/bin/lib/capability-ledger.cjs diff --git a/bin/install.js b/bin/install.js index a44456b04..1f11be324 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6533,6 +6533,125 @@ function writeHermesCategoryDescription(categoryDir) { const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites; const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults; +/** + * Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B). + * Keyed by runtime id. Each entry declares ONLY what that runtime does differently. + * The DEFAULT (no entry, or entry with no md/js key) = identity transform after + * the uniform steps — covers claude, augment, codebuddy, kimi, etc. + * + * Entry shape: + * mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (copilot, antigravity) + * md?: (content, ctx) => string — per-runtime .md transform + * mdReattributeAfter?: boolean — re-run processAttribution after md() (copilot, antigravity) + * mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml (gemini) + * js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync) + * + * ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime } + */ +const RUNTIME_CONTENT_DISPATCH = { + opencode: { + md: (content) => convertClaudeToOpencodeFrontmatter(content), + }, + kilo: { + md: (content) => convertClaudeToKiloFrontmatter(content), + }, + gemini: { + md: (content, ctx) => + convertClaudeToGeminiMarkdown(content, { + isCommand: ctx.isCommand, + commandName: ctx.isCommand ? ctx.entryName.replace(/\.md$/, '') : null, + }), + mdTomlRenameOnCommand: true, + }, + codex: { + md: (content) => convertClaudeToCodexMarkdown(content), + }, + copilot: { + mdSkipGenericRewrite: true, + md: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal), + mdReattributeAfter: true, + js: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal), + }, + antigravity: { + mdSkipGenericRewrite: true, + md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal), + mdReattributeAfter: true, + js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal), + }, + cursor: { + md: (content) => convertClaudeToCursorMarkdown(content), + js: (content) => { + content = content.replace(/gsd:/gi, 'gsd-'); + content = content.replace(/\.claude\/skills\//g, '.cursor/skills/'); + content = content.replace(/CLAUDE\.md/g, '.cursor/rules/'); + content = content.replace(/\bClaude Code\b/g, 'Cursor'); + return content; + }, + }, + windsurf: { + md: (content) => convertClaudeToWindsurfMarkdown(content), + js: (content) => { + // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085). + content = content.replace(/gsd:/gi, 'gsd-'); + content = content.replace(/\.claude\/skills\//g, '.devin/skills/'); + content = content.replace(/CLAUDE\.md/g, '.devin/rules'); + content = content.replace(/\bClaude Code\b/g, 'Windsurf'); + return content; + }, + }, + trae: { + md: (content) => convertClaudeToTraeMarkdown(content), + js: (content) => { + content = content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { + return `/gsd-${commandName}`; + }); + content = content.replace(/\.claude\/skills\//g, '.trae/skills/'); + content = content.replace(/CLAUDE\.md/g, '.trae/rules/'); + content = content.replace(/\bClaude Code\b/g, 'Trae'); + return content; + }, + }, + cline: { + md: (content) => convertClaudeToCliineMarkdown(content), + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.cline/skills/'); + content = content.replace(/CLAUDE\.md/g, '.clinerules'); + content = content.replace(/\bClaude Code\b/g, 'Cline'); + return content; + }, + }, + qwen: { + md: (content) => { + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + content = content.replace(/\.claude\//g, '.qwen/'); + return content; + }, + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.qwen/skills/'); + content = content.replace(/\.claude\//g, '.qwen/'); + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + return content; + }, + }, + hermes: { + md: (content) => { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/\.claude\//g, '.hermes/'); + return content; + }, + js: (content) => { + content = content.replace(/\.claude\/skills\//g, '.hermes/skills/'); + content = content.replace(/\.claude\//g, '.hermes/'); + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + return content; + }, + }, +}; + /** * Recursively copy directory, replacing paths in .md files * Deletes existing destDir first to remove orphaned files from previous versions @@ -6544,19 +6663,6 @@ const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaude * @param {boolean} isGlobal - Whether the install is global */ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) { - const isOpencode = runtime === 'opencode'; - const isKilo = runtime === 'kilo'; - const isGemini = runtime === 'gemini'; - const isCodex = runtime === 'codex'; - const isCopilot = runtime === 'copilot'; - const isAntigravity = runtime === 'antigravity'; - const isCursor = runtime === 'cursor'; - const isWindsurf = runtime === 'windsurf'; - const isAugment = runtime === 'augment'; - const isTrae = runtime === 'trae'; - const isQwen = runtime === 'qwen'; - const isHermes = runtime === 'hermes'; - const isCline = runtime === 'cline'; const dirName = getDirName(runtime); // ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that @@ -6593,10 +6699,13 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand if (entry.isDirectory()) { copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot); } else if (entry.name.endsWith('.md')) { + const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {}; + const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime }; + // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths - // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths + // Skip generic replacement for Copilot/Antigravity — their converters handle all paths let content = fs.readFileSync(srcPath, 'utf8'); - if (!isCopilot && !isAntigravity) { + if (!dispatch.mdSkipGenericRewrite) { const globalClaudeRegex = /~\/\.claude\//g; const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; const localClaudeRegex = /\.\/\.claude\//g; @@ -6631,112 +6740,25 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand // colon-canonical runtimes (Gemini). content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); - // Convert frontmatter for opencode compatibility - if (isOpencode || isKilo) { - content = isKilo - ? convertClaudeToKiloFrontmatter(content) - : convertClaudeToOpencodeFrontmatter(content); - fs.writeFileSync(destPath, content); - } else if (isGemini) { - // Apply Gemini-specific Markdown transformations (slash commands, TOML). - // #778: thread the command name (file stem) so per-command TOML - // enrichment (live-state injection) can target a specific command. - const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null; - const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName }); - const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath; - fs.writeFileSync(finalPath, processed); - } else if (isCodex) { - content = convertClaudeToCodexMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isCopilot) { - content = convertClaudeToCopilotContent(content, isGlobal); - content = processAttribution(content, getCommitAttribution(runtime)); - fs.writeFileSync(destPath, content); - } else if (isAntigravity) { - content = convertClaudeToAntigravityContent(content, isGlobal); - content = processAttribution(content, getCommitAttribution(runtime)); - fs.writeFileSync(destPath, content); - } else if (isCursor) { - content = convertClaudeToCursorMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isWindsurf) { - content = convertClaudeToWindsurfMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isTrae) { - content = convertClaudeToTraeMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isCline) { - content = convertClaudeToCliineMarkdown(content); - fs.writeFileSync(destPath, content); - } else if (isQwen) { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - content = content.replace(/\.claude\//g, '.qwen/'); - fs.writeFileSync(destPath, content); - } else if (isHermes) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - content = content.replace(/\.claude\//g, '.hermes/'); + // Apply per-runtime .md converter (if any) + if (dispatch.md) content = dispatch.md(content, ctx); + + // Re-run attribution after converter for runtimes that need it (copilot, antigravity) + if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime)); + + // Gemini: rename .md → .toml for command files + const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath; + fs.writeFileSync(finalPath, content); + } else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) { + const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {}; + if (dispatch.js) { + const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime }; + let content = fs.readFileSync(srcPath, 'utf8'); + content = dispatch.js(content, ctx); fs.writeFileSync(destPath, content); } else { - fs.writeFileSync(destPath, content); + fs.copyFileSync(srcPath, destPath); } - } else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // Copilot: also transform .cjs/.js files for CONV-06 and CONV-07 - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeToCopilotContent(content, isGlobal); - fs.writeFileSync(destPath, content); - } else if (isAntigravity && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // Antigravity: also transform .cjs/.js files for path/command conversions - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeToAntigravityContent(content, isGlobal); - fs.writeFileSync(destPath, content); - } else if (isCursor && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - // For Cursor, also convert Claude references in JS/CJS utility scripts - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/gsd:/gi, 'gsd-'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.cursor/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.cursor/rules/'); - 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/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, '.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'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { - return `/gsd-${commandName}`; - }); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.trae/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.trae/rules/'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Trae'); - fs.writeFileSync(destPath, jsContent); - } else if (isCline && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.cline/skills/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, '.clinerules'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cline'); - fs.writeFileSync(destPath, jsContent); - } else if (isQwen && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.qwen/skills/'); - jsContent = jsContent.replace(/\.claude\//g, '.qwen/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, 'QWEN.md'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Qwen Code'); - fs.writeFileSync(destPath, jsContent); - } else if (isHermes && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { - let jsContent = fs.readFileSync(srcPath, 'utf8'); - jsContent = jsContent.replace(/\.claude\/skills\//g, '.hermes/skills/'); - jsContent = jsContent.replace(/\.claude\//g, '.hermes/'); - jsContent = jsContent.replace(/CLAUDE\.md/g, 'HERMES.md'); - jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent'); - fs.writeFileSync(destPath, jsContent); } else { fs.copyFileSync(srcPath, destPath); }