diff --git a/CONTEXT.md b/CONTEXT.md index 71788c750..9d1e5672a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -152,10 +152,10 @@ Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `gsd-core/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011. ### Runtime Artifact Layout Module -Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. ### Runtime Artifact Conversion Module -Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. +Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). ### Command Roster Module Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state. diff --git a/bin/install.js b/bin/install.js index a3877657e..0b2e127b1 100755 --- a/bin/install.js +++ b/bin/install.js @@ -593,30 +593,12 @@ if (hasHelp) { process.exit(0); } -/** - * Compute the path prefix used for `@file` references in installed command/skill - * markdown. For global installs into a runtime config dir under $HOME, we - * normally substitute the home prefix with `$HOME` so paths expand correctly - * inside double-quoted shell commands. OpenCode is exempt on every platform: - * its `@file` include syntax does NOT shell-expand `$HOME`, so a literal - * `@$HOME/...` is treated as a path relative to the config command/ dir, which - * resolves to `command/$HOME/...` (file not found). For OpenCode we always emit - * the absolute resolved path. (#2376 Windows, #2831 macOS/Linux.) - * - * @param {object} args - * @param {boolean} args.isGlobal - Global runtime install vs local project - * @param {boolean} args.isOpencode - Whether the runtime is OpenCode - * @param {boolean} args.isWindowsHost - process.platform === 'win32' - * @param {string} args.resolvedTarget - Absolute target dir, forward-slashed - * @param {string} args.homeDir - User home dir, forward-slashed - * @returns {string} pathPrefix ending with '/' - */ -function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { - if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) { - return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; - } - return `${resolvedTarget}/`; -} +// computePathPrefix: implementation moved to runtimeArtifactConversion._computePathPrefix +// (ADR-1508 / #1511 Phase 2 — single owner). The const binding above (~line 638) +// re-exports it here for call sites and module.exports. +// Original doc: Compute the path prefix used for `@file` references in installed +// command/skill markdown. For global installs under $HOME uses $HOME/... form; +// OpenCode always uses the absolute path (#2376 Windows, #2831 macOS/Linux). // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are // now owned by the runtime-hooks-surface module. Import them here so @@ -636,6 +618,14 @@ const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks; // callers continue to work and there is a single implementation. (All call // sites are below this line, so the const binding has no TDZ hazard.) const processAttribution = runtimeArtifactConversion.processAttribution; +// computePathPrefix / applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace: +// Single implementations now live in runtimeArtifactConversion (ADR-1508 / #1511 Phase 2). +// Re-bound here so install.js call sites and exports continue to work unchanged. +// Local bodies replaced by breadcrumb comments at their original locations. +// All call sites are below this line → no TDZ hazard. +const computePathPrefix = runtimeArtifactConversion._computePathPrefix; +const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace; +const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace; function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) { return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts); @@ -6667,24 +6657,10 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = ' * @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, isGlobal = false) { - if (!fs.existsSync(stagedDir)) return; - - // Walk all SKILL.md files under stagedDir - const walkAndRewrite = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkAndRewrite(fullPath); - } else if (entry.name.endsWith('.md')) { - let content = fs.readFileSync(fullPath, 'utf8'); - content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal); - fs.writeFileSync(fullPath, content); - } - } - }; - walkAndRewrite(stagedDir); -} +// applyRuntimeContentRewritesInPlace: walk loop is now owned by +// runtimeArtifactConversion.applyRuntimeContentRewritesInPlace (ADR-1508 / #1511 Phase 2). +// The const binding above (~line 629) delegates here. Call sites in installRuntimeArtifacts +// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract. /** * Apply per-runtime content rewrites to flat .md files in a staged commands dir. @@ -6702,30 +6678,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGl * @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, 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 - // package source. A temp copy is unconditional to keep the code simple and safe. - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-')); - try { - 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, isGlobal); - // For augment commands, apply the markdown conversion so tool references - // and skill paths use Augment equivalents. - if (runtime === 'augment') { - content = convertClaudeToAugmentMarkdown(content); - } - fs.writeFileSync(path.join(tempDir, entry.name), content); - } - } catch (err) { - try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } - throw err; - } - return tempDir; -} +// applyRuntimeContentRewritesForCommandsInPlace: copy+rewrite loop is now owned by +// runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace (ADR-1508 / #1511 Phase 2). +// The const binding above (~line 630) delegates here. Call sites in installRuntimeArtifacts +// pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract. /** * Apply the per-runtime rewrite table to a single content string. @@ -6737,196 +6693,11 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install * @returns {string} */ -function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) { - const dirName = getDirName(runtime); - const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); - - switch (runtime) { - case 'codex': - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.codex\//g, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'cline': - // Slash forms: both the original ~/.claude/ (safety net) and the stage-time - // converted ~/.cline/ (from convertClaudeToCliineMarkdown) → pathPrefix - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.cline\//g, pathPrefix); - content = content.replace(/\$HOME\/\.cline\//g, pathPrefix); - // Bare forms (no trailing slash) - content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/~\/\.cline\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.cline\b/g, normalizedPathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'cursor': - 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 → .cursor-plugin). - content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); - content = content.replace(/~\/\.cursor\//g, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - 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 → .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); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); - content = content.replace(/~\/\.augment\//g, pathPrefix); - content = content.replace(/\$HOME\/\.augment\//g, pathPrefix); - content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'trae': - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); - content = content.replace(/~\/\.trae\//g, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'codebuddy': - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); - // The codebuddy converter rewrites `.claude/` → `.codebuddy/` at stage - // time, so `$HOME/.claude/...` arrives here as `$HOME/.codebuddy/...`. - // Normalize BOTH the `~/` and `$HOME/` forms (slash + bare) to the install - // target so `--config-dir`/local installs don't leak the default home. - content = content.replace(/~\/\.codebuddy\//g, pathPrefix); - content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix); - content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'copilot': - // Copilot converter handles path rewrites; only attribution here - content = processAttribution(content, getCommitAttribution('copilot')); - break; - - case 'antigravity': - // Antigravity converter handles path rewrites; only attribution here - content = processAttribution(content, getCommitAttribution('antigravity')); - break; - - case 'claude': - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'qwen': - // Branding rewrites run before path rewrites to avoid consuming - // patterns that the path step would also match. - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - // Base path rewrites (use ~/ and $HOME/ slash forms first — most specific) - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/~\/\.qwen\//g, pathPrefix); - content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); - content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/~\/\.qwen(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.qwen(?![\w-])/g, normalizedPathPrefix); - // Bare relative .claude/ → .qwen/ (residual refs not matched above) - content = content.replace(/\.claude\//g, '.qwen/'); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'hermes': - // Branding rewrites run before path rewrites (same rationale as qwen) - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - // Base path rewrites - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/~\/\.hermes\//g, pathPrefix); - content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); - content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/~\/\.hermes(?![\w-])/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.hermes(?![\w-])/g, normalizedPathPrefix); - // Bare relative .claude/ → .hermes/ (residual refs) - content = content.replace(/\.claude\//g, '.hermes/'); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - case 'kimi': - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); - content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); - content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); - content = processAttribution(content, getCommitAttribution(runtime)); - break; - - default: - // Unknown runtime — no rewrites. - // OpenCode/Kilo are intentionally absent: their skills are written by - // installOpencodeFamilySkills, which applies pathPrefix BEFORE the - // command→skill conversion (mirroring copyFlattenedCommands) rather than - // rewriting already-converted SKILL.md bodies. See #784. - break; - } - - return content; -} +// _applyRuntimeRewrites: single implementation lives in runtimeArtifactConversion +// (ADR-1508 / #1511 Phase 2). Bound here so install.js call sites and exports are +// reference-identical to the conversion module (consistent with the walkers above). +// All call sites are below this line → no TDZ hazard. +const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites; /** * Copy a staged directory's contents into destDir. @@ -7256,10 +7027,10 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { let stagedForCopy = staged; const isGlobal = scope === 'global'; if (kind.kind === 'skills' || kind.kind === 'kimi-agents') { - applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal); + applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime)); } else if (kind.kind === 'commands') { // Returns a temp dir with rewritten content so source files are never mutated. - stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal); + stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime)); } // applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under // os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856). @@ -10022,7 +9793,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); let content = fs.readFileSync(path.join(gsdSrc, entry.name), 'utf8'); - content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal); + content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime)); content = normalizeAgentBodyForRuntime(content, runtime, cmdNames); fs.writeFileSync(path.join(commandsDir, `gsd-${stem}.md`), content); cmdCount++; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 422cba86e..ac5c01181 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -17,9 +17,13 @@ */ import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; import commandRoster = require('./command-roster.cjs'); const { readGsdCommandNames, transformContentToHyphen } = commandRoster; const pkg = require('../../../package.json'); +import runtimeNamePolicy = require('./runtime-name-policy.cjs'); +const { getDirName } = runtimeNamePolicy; const colorNameToHex = { @@ -2095,6 +2099,335 @@ function convertClaudeCommandToKiloSkill(content, skillName) { } +// ── Rewrite engine — ADR-1508 Phase 2 ─────────────────────────────────────── +// Relocated from bin/install.js (#1511). Behavior is byte-for-behavior identical +// to the originals; the only change is the injected `attribution` 5th param in +// _applyRuntimeRewrites (replacing the internal getCommitAttribution() call). + +/** + * Compute the path prefix for a runtime install. + * Global installs under $HOME use $HOME/... form; others use the resolved target. + * isOpencode excludes OpenCode (uses ~/.config/opencode which breaks $HOME shorthand). + * isWindowsHost is not used today but reserved for future Windows-specific logic. + * + * @private — exported as `_computePathPrefix` for tests. + */ +function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { + if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) { + return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; + } + return `${resolvedTarget}/`; +} + +/** + * Apply the per-runtime rewrite table to a single content string. + * Relocated from bin/install.js `_applyRuntimeRewrites`. + * + * The 5th `attribution` param replaces the internal getCommitAttribution() call + * so the function is pure (no config I/O). Pass the resolved attribution value + * from the installer; pass `undefined` to leave Co-Authored-By lines untouched. + * + * @private — exported as `_applyRuntimeRewrites` for tests. + */ +function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) { + const dirName = getDirName(runtime); + const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); + + switch (runtime) { + case 'codex': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.codex\//g, pathPrefix); + content = processAttribution(content, attribution); + break; + + case 'cline': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.cline\//g, pathPrefix); + content = content.replace(/\$HOME\/\.cline\//g, pathPrefix); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/~\/\.cline\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.cline\b/g, normalizedPathPrefix); + content = processAttribution(content, attribution); + break; + + case 'cursor': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); + content = content.replace(/~\/\.cursor\//g, pathPrefix); + content = processAttribution(content, attribution); + break; + + case 'windsurf': { + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix); + 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, attribution); + break; + } + + case 'augment': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`); + content = content.replace(/~\/\.augment\//g, pathPrefix); + content = content.replace(/\$HOME\/\.augment\//g, pathPrefix); + content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix); + content = processAttribution(content, attribution); + break; + + case 'trae': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = content.replace(/~\/\.trae\//g, pathPrefix); + content = processAttribution(content, attribution); + break; + + case 'codebuddy': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = content.replace(/~\/\.codebuddy\//g, pathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix); + content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix); + content = processAttribution(content, attribution); + break; + + case 'copilot': + content = processAttribution(content, attribution); + break; + + case 'antigravity': + content = processAttribution(content, attribution); + break; + + case 'claude': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = processAttribution(content, attribution); + break; + + case 'qwen': + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/~\/\.qwen\//g, pathPrefix); + content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/~\/\.qwen(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.qwen(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\.claude\//g, '.qwen/'); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); + content = processAttribution(content, attribution); + break; + + case 'hermes': + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/~\/\.hermes\//g, pathPrefix); + content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/~\/\.hermes(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.hermes(?![\w-])/g, normalizedPathPrefix); + content = content.replace(/\.claude\//g, '.hermes/'); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); + content = processAttribution(content, attribution); + break; + + case 'kimi': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = processAttribution(content, attribution); + break; + + default: + // Unknown runtime — no rewrites (OpenCode/Kilo handled by their own install path). + break; + } + + return content; +} + +/** + * LOW-LEVEL: In-place fs walk: rewrite all .md files under stagedDir. + * + * pathPrefix and attribution are passed in (already resolved by the caller). + * Single owner of the walk loop — both the high-level rewriteStagedSkillBodies + * and the install.js compat wrapper delegate here. + * + * @param stagedDir directory of staged skill/agent files + * @param runtime canonical runtime ID + * @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/') + * @param isGlobal true for global scope installs + * @param attribution Co-Authored-By value (string | null | undefined) + */ +function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false, attribution = undefined) { + if (!fs.existsSync(stagedDir)) return; + + const walkAndRewrite = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkAndRewrite(fullPath); + } else if (entry.name.endsWith('.md')) { + let content = fs.readFileSync(fullPath, 'utf8'); + content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, attribution); + fs.writeFileSync(fullPath, content); + } + } + }; + walkAndRewrite(stagedDir); +} + +/** + * LOW-LEVEL: Copy-to-temp then rewrite all .md files. + * + * pathPrefix and attribution are passed in (already resolved by the caller). + * Single owner of the copy+rewrite loop — both the high-level + * rewriteStagedCommandBodies and the install.js compat wrapper delegate here. + * + * IMPORTANT: always copies to a fresh mkdtemp dir — never mutates the source dir + * (stageSkillsForProfile returns the source dir on full profile; mutation would + * corrupt the package source). + * + * @param stagedDir directory of staged flat .md command files + * @param runtime canonical runtime ID + * @param pathPrefix trailing-slash path prefix + * @param isGlobal true for global scope installs + * @param attribution Co-Authored-By value (string | null | undefined) + * @returns {string} path to the temp dir (caller is responsible for cleanup) + */ +function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false, attribution = undefined) { + if (!fs.existsSync(stagedDir)) return stagedDir; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-')); + try { + 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, isGlobal, attribution); + if (runtime === 'augment') { + content = convertClaudeToAugmentMarkdown(content); + } + fs.writeFileSync(path.join(tempDir, entry.name), content); + } + } catch (err) { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw err; + } + return tempDir; +} + +/** + * HIGH-LEVEL: In-place fs walk: rewrite all .md files under stagedDir for the given runtime. + * + * Deep public seam (ADR-1508 Phase 2). Derives resolvedTarget/homeDir/isGlobal/pathPrefix/ + * attribution from opts, then delegates to applyRuntimeContentRewritesInPlace (single walk owner). + * + * @param stagedDir directory of staged skill/agent files + * @param opts.runtime canonical runtime ID + * @param opts.configDir runtime config directory (absolute path) + * @param opts.scope 'global' | 'local' + * @param opts.homedir optional homedir resolver (injectable for tests; defaults to os.homedir) + * @param opts.platform optional platform string (injectable for tests; defaults to process.platform) + * @param opts.resolveAttribution optional fn(runtime)→string|null|undefined; called once per invocation + */ +function rewriteStagedSkillBodies(stagedDir, opts) { + const { + runtime, + configDir, + scope = 'global', + homedir = () => os.homedir(), + platform = process.platform, + resolveAttribution, + } = opts; + if (!fs.existsSync(stagedDir)) return; + + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + const homeDir = homedir().replace(/\\/g, '/'); + const isGlobal = scope === 'global'; + const isOpencode = runtime === 'opencode'; + const isWindowsHost = platform === 'win32'; + const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; + + applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); +} + +/** + * HIGH-LEVEL: Copy-to-temp then rewrite all .md files for the given runtime. + * + * Deep public seam (ADR-1508 Phase 2). Derives resolvedTarget/homeDir/isGlobal/pathPrefix/ + * attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace + * (single copy+rewrite owner). + * + * @returns {string} path to the temp dir (caller is responsible for cleanup) + */ +function rewriteStagedCommandBodies(stagedDir, opts) { + const { + runtime, + configDir, + scope = 'global', + homedir = () => os.homedir(), + platform = process.platform, + resolveAttribution, + } = opts; + if (!fs.existsSync(stagedDir)) return stagedDir; + + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + const homeDir = homedir().replace(/\\/g, '/'); + const isGlobal = scope === 'global'; + const isOpencode = runtime === 'opencode'; + const isWindowsHost = platform === 'win32'; + const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); + const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; + + return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); +} + +// ── End rewrite engine ──────────────────────────────────────────────────────── + /** * Apply Co-Authored-By attribution policy to file content. * - null -> remove the Co-Authored-By line and its preceding blank line @@ -2175,4 +2508,13 @@ export = { convertClaudeAgentToCodebuddyAgent, convertClaudeAgentToClineAgent, convertClaudeAgentToCodexAgent, + // #1511 ADR-1508 Phase 2: rewrite engine deep seam + // Low-level walkers (pathPrefix + attribution pre-resolved by caller): + applyRuntimeContentRewritesInPlace, + applyRuntimeContentRewritesForCommandsInPlace, + // High-level wrappers (derive pathPrefix + attribution from opts): + rewriteStagedSkillBodies, + rewriteStagedCommandBodies, + _computePathPrefix: computePathPrefix, + _applyRuntimeRewrites, }; diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 6791af886..3adb6c43a 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -34,39 +34,10 @@ const conversionExports = runtimeArtifactConversion as Record & // In .cts (CommonJS output) files, `require` is available as a global. const _require: NodeRequire = require; -// --------------------------------------------------------------------------- -// Lazy installer exports (avoids GSD_TEST_MODE env mutation at module load) -// --------------------------------------------------------------------------- - -interface InstallExports { - computePathPrefix: (opts: { isGlobal: boolean; isOpencode: boolean; isWindowsHost: boolean; resolvedTarget: string; homeDir: string }) => string; - applyRuntimeContentRewritesInPlace: (stagedDir: string, runtime: string, pathPrefix: string) => void; - [converterName: string]: unknown; -} - -/** - * Load bin/install.js exports in a test-safe way. - * Sets GSD_TEST_MODE only for the duration of the require() call and only if - * it was not already set, restoring the original value in a finally block so - * the module-level environment is never permanently mutated. - */ -function loadInstallExports(): InstallExports { - const savedTestMode = process.env['GSD_TEST_MODE']; - if (savedTestMode === undefined) process.env['GSD_TEST_MODE'] = '1'; - try { - return _require('../../../bin/install.js') as InstallExports; - } finally { - if (savedTestMode === undefined) delete process.env['GSD_TEST_MODE']; - else process.env['GSD_TEST_MODE'] = savedTestMode; - } -} - -/** Cache after first successful load. */ -let _installExports: InstallExports | null = null; -function getInstallExports(): InstallExports { - if (!_installExports) _installExports = loadInstallExports(); - return _installExports; -} +// loadInstallExports / getInstallExports / InstallExports removed in ADR-1508 +// / #1511 Phase 2 — removed this module's upward dependency on bin/install.js +// (the getInstallExports relay). surface.cts now calls +// runtimeArtifactConversion.rewriteStagedSkillBodies directly. // --------------------------------------------------------------------------- // Types @@ -494,4 +465,5 @@ function resolveRuntimeArtifactLayoutFromRegistry( return { runtime, configDir, scope, kinds }; } -export = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot, getInstallExports }; +// getInstallExports removed in ADR-1508 / #1511 Phase 2 (last upward .cts→install.js dep). +export = { resolveRuntimeArtifactLayout, resolveRuntimeArtifactLayoutFromRegistry, findInstallSourceRoot }; diff --git a/src/surface.cts b/src/surface.cts index bbd04141d..8422a3871 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -30,7 +30,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; import { platformWriteSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import installProfiles = require('./install-profiles.cjs'); @@ -43,7 +42,9 @@ import { CLUSTERS } from './clusters.cjs'; import type { ClusterMap } from './clusters.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); -const { findInstallSourceRoot, getInstallExports } = runtimeArtifactLayout; +const { findInstallSourceRoot } = runtimeArtifactLayout; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); const SURFACE_FILE_NAME = '.gsd-surface.json'; @@ -305,26 +306,18 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map { + process.env['GSD_TEST_MODE'] = '1'; + conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); +}); + +// --------------------------------------------------------------------------- +// _computePathPrefix unit tests +// --------------------------------------------------------------------------- + +describe('_computePathPrefix', () => { + test('global under home → $HOME/... form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/home/u/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '$HOME/.cursor/'); + }); + + test('non-global → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/project/.cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/project/.cursor/'); + }); + + test('global opencode skips $HOME shorthand', () => { + // OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: true, + isWindowsHost: false, + resolvedTarget: '/home/u/.config/opencode', + homeDir: '/home/u', + }); + assert.equal(prefix, '/home/u/.config/opencode/'); + }); + + test('global target outside home → resolvedTarget/ form', () => { + const prefix = conversion._computePathPrefix({ + isGlobal: true, + isOpencode: false, + isWindowsHost: false, + resolvedTarget: '/opt/custom-cursor', + homeDir: '/home/u', + }); + assert.equal(prefix, '/opt/custom-cursor/'); + }); +}); + +// --------------------------------------------------------------------------- +// _applyRuntimeRewrites with injected attribution +// --------------------------------------------------------------------------- + +describe('_applyRuntimeRewrites — attribution injection', () => { + const PREFIX = '$HOME/.cursor/'; + + test('attribution=null removes Co-Authored-By line', () => { + const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null); + assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed'); + }); + + test('attribution=undefined leaves Co-Authored-By unchanged', () => { + const content = '# Hello\n\nCo-Authored-By: Claude\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined); + assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined'); + }); + + test('attribution=string replaces Co-Authored-By value', () => { + const content = '# Hello\n\nCo-Authored-By: OldName\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName '); + assert.ok(result.includes('Co-Authored-By: NewName '), 'Co-Authored-By should be replaced'); + }); + + test('cursor runtime replaces ~/.claude/ paths', () => { + const content = 'See ~/.claude/skills/ for more info\n'; + const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined); + assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix'); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedSkillBodies — behavioral filesystem test +// --------------------------------------------------------------------------- + +describe('rewriteStagedSkillBodies', () => { + test('rewrites .md files in-place for cursor runtime', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + try { + // Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo + // NOTE: the rewrite engine handles path replacement and attribution only. + // Bash→Shell conversion is done by the stage-1 skill converter, not the engine. + const skillDir = path.join(stagedDir, 'gsd-test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n'; + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + + // Call with injected homedir + platform for determinism + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + // cursor rewrites ~/.claude/ → pathPrefix + // configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/' + // Mirror the engine's backslash→slash normalization so the assertion holds on Windows. + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`); + // cursor also rewrites ~/.cursor/ → pathPrefix + assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`); + } finally { + cleanup(stagedDir); + cleanup(configDir); + } + }); + + test('with injected homedir: global under home uses $HOME prefix', () => { + // Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive. + // The dir need not exist — the engine only string-processes it. + const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home'); + const configDir = path.join(HOME, '.cursor'); + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-')); + try { + const skillDir = path.join(stagedDir, 'gsd-help'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n'); + + conversion.rewriteStagedSkillBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => HOME, + platform: process.platform, + }); + + const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir'); + } finally { + cleanup(stagedDir); + } + }); + + test('non-existent stagedDir is a no-op', () => { + assert.doesNotThrow(() => { + conversion.rewriteStagedSkillBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// rewriteStagedCommandBodies — returns temp dir, does not mutate source +// --------------------------------------------------------------------------- + +describe('rewriteStagedCommandBodies', () => { + test('returns a temp dir (not the source dir) with rewritten content', () => { + const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-')); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-')); + let tempDir; + try { + // NOTE: rewrite engine handles path replacement + attribution, NOT tool renames. + fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n'); + + tempDir = conversion.rewriteStagedCommandBodies(stagedDir, { + runtime: 'cursor', + configDir, + scope: 'global', + homedir: () => '/home/u', + platform: 'linux', + }); + + assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source'); + assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist'); + + const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8'); + // Source dir should be unchanged + const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8'); + assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated'); + // configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/' + const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied'); + // ~/.cursor/ also rewrites to prefix + assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too'); + } finally { + cleanup(stagedDir); + cleanup(configDir); + if (tempDir && tempDir !== stagedDir) { + cleanup(tempDir); + } + } + }); + + test('non-existent stagedDir returns stagedDir unchanged (safe)', () => { + const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', { + runtime: 'cursor', + configDir: '/tmp/fake', + scope: 'global', + }); + assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir'); + }); +}); + +// --------------------------------------------------------------------------- +// Guard: runtime-artifact-layout no longer exports getInstallExports +// --------------------------------------------------------------------------- + +describe('layout module no longer exports getInstallExports', () => { + test('getInstallExports is not on the layout module export', () => { + process.env['GSD_TEST_MODE'] = '1'; + const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + assert.equal( + typeof layout.getInstallExports, + 'undefined', + 'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511) +// Proves install.js binds to the conversion module's implementation, not a +// duplicate local copy. If these fail, a duplicate body was re-introduced. +// --------------------------------------------------------------------------- + +describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => { + let install; + let conversionCjs; + before(() => { + process.env['GSD_TEST_MODE'] = '1'; + install = require('../bin/install.js'); + conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + }); + + test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => { + assert.strictEqual( + install.computePathPrefix, + conversionCjs._computePathPrefix, + 'install.js must bind computePathPrefix from conversion (not a duplicate body)', + ); + }); + + test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesInPlace, + conversionCjs.applyRuntimeContentRewritesInPlace, + 'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)', + ); + }); + + test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => { + assert.strictEqual( + install.applyRuntimeContentRewritesForCommandsInPlace, + conversionCjs.applyRuntimeContentRewritesForCommandsInPlace, + 'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)', + ); + }); + + test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => { + assert.strictEqual( + install._applyRuntimeRewrites, + conversionCjs._applyRuntimeRewrites, + 'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)', + ); + }); +});