refactor(#1511): move content-rewrite engine to conversion module, delete the install.js relay (#1513)

* refactor(#1511): move content-rewrite engine to conversion module, delete the install.js relay

Phase 2 of epic #1507 (ADR-1508). Behavior-preserving: makes the Runtime
Artifact Conversion Module the single owner of per-runtime content rewriting and
removes the last upward .cts -> bin/install.js dependency.

- src/runtime-artifact-conversion.cts now owns the engine (_applyRuntimeRewrites,
  5-arg with INJECTED attribution), the staged-content walkers
  (applyRuntimeContentRewritesInPlace / ...ForCommandsInPlace), computePathPrefix
  (private, exported as _computePathPrefix for tests), and the deep public seam
  rewriteStagedSkillBodies / rewriteStagedCommandBodies({runtime, configDir,
  scope, homedir?, platform?, resolveAttribution?}).
- src/surface.cts:applySurface calls rewriteStagedSkillBodies directly (no
  resolveAttribution -> undefined). Co-Authored-By is absent from ALL rewritten
  content, so processAttribution is vacuous there and undefined is provably
  behavior-identical. surface no longer imports getInstallExports.
- src/runtime-artifact-layout.cts: deleted getInstallExports / loadInstallExports
  / InstallExports + the GSD_TEST_MODE require('bin/install.js') relay.
- bin/install.js: binds computePathPrefix / the two walkers / _applyRuntimeRewrites
  from the conversion module (single implementation, exports preserved for Hyrum);
  install callsites pass getCommitAttribution(runtime) as the injected attribution.
  getCommitAttribution stays here (impure install-time config I/O).
- DEFECT.GENERATIVE-FIX guard: tests assert install.X === conversion.X reference
  identity for computePathPrefix + both walkers (no drift).

New tests/enh-1511-*.test.cjs (engine, attribution injection, deep seam, prefix,
layout-no-relay guard, reference-identity). 316 affected-suite tests green; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187qgypdy1wkWRpdaf2hRuD

* test(#1511): make rewrite-engine path assertions Windows-robust

The deep seam normalizes paths as path.resolve(configDir).replace(/\\/g,'/')
and compares homedir().replace(/\\/g,'/'). Three assertions in the new test
rebuilt expected paths without that normalization, so they passed on Mac/Linux
but failed on Windows CI (PR #1513):

- two absolute-branch asserts rebuilt resolvedTarget via path.resolve(configDir)
  without the backslash→slash replace → mismatch on Windows.
- the $HOME-branch test fed a POSIX-literal /home/testuser, which Windows
  path.resolve re-roots onto the cwd drive (D:/home/...), so the
  resolvedTarget.startsWith(homeDir) check failed and the $HOME shorthand was
  never produced.

Fix is test-only (engine unchanged, still behavior-preserving): mirror the
engine's .replace(/\\/g,'/') in the two absolute-branch asserts, and use a real
absolute path (path.resolve(os.tmpdir(), ...)) + platform: process.platform for
the $HOME-branch test so the comparison holds on all platforms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187qgypdy1wkWRpdaf2hRuD

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-20 23:59:50 -04:00
committed by GitHub
parent cadc944781
commit eb81faaeab
6 changed files with 688 additions and 314 deletions

View File

@@ -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 `<runtimeConfigDir>/.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 `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` 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 `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` 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.

View File

@@ -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++;

View File

@@ -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,
};

View File

@@ -34,39 +34,10 @@ const conversionExports = runtimeArtifactConversion as Record<string, unknown> &
// 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 };

View File

@@ -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<st
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
// so SKILL.md bodies reference the install target (pathPrefix), not the
// converter's default ~/.claude paths (#813). Computed lazily so command-only
// runtimes do not trigger the install.js require.
let pathPrefix: string | null = null;
// converter's default ~/.claude paths (#813). Delegated to the conversion
// module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
// needed here (proven: Co-Authored-By never appears in staged content; see
// brief PROVEN KEY FACT). No getInstallExports() call required.
for (const kind of layout.kinds) {
const staged = kind.stage(resolved);
if (kind.kind === 'skills') {
const installExports = getInstallExports();
if (pathPrefix === null) {
const scope = layout.scope ?? 'global';
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const homeDir = os.homedir().replace(/\\/g, '/');
pathPrefix = installExports.computePathPrefix({
isGlobal: scope === 'global',
isOpencode: layout.runtime === 'opencode',
isWindowsHost: process.platform === 'win32',
resolvedTarget,
homeDir,
});
}
installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
configDir: layout.configDir,
scope: layout.scope ?? 'global',
});
}
const dest = path.join(layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, skillManifest);

View File

@@ -0,0 +1,296 @@
'use strict';
/**
* Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion.
* Issue #1511 — verifies the deep public seam signatures and behavior.
*
* Tests are behavioral (no source-grep). All filesystem operations use tmp dirs.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
let conversion;
before(() => {
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 <new@example.com>');
assert.ok(result.includes('Co-Authored-By: NewName <new@example.com>'), '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)',
);
});
});