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:
@@ -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.
|
||||
|
||||
289
bin/install.js
289
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++;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
296
tests/enh-1511-rewrite-engine-relocation.test.cjs
Normal file
296
tests/enh-1511-rewrite-engine-relocation.test.cjs
Normal 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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user