refactor(#1734): extract install engine from bin/install.js (ADR-1239 Phase B deep move) (#1735)

* refactor(#1734): extract install engine from bin/install.js (ADR-1239 Phase B deep move)

Relocate the runtime-artifact install cluster out of the 12,490-line
bin/install.js into a dedicated src/install-engine.cts -> install-engine.cjs:
installRuntimeArtifacts, uninstallRuntimeArtifacts, installOpencodeFamilySkills,
and their cluster helpers (_copyStaged, snapshot/restore, legacy migration,
GSD-entry pruning, preserve/restoreUserArtifacts, OpenCode-family converters,
USER_OWNED_ARTIFACTS).

- bin/install.js imports the engine and re-exports the moved symbols for
  back-compat; getCommitAttribution STAYS in install.js (impure config I/O +
  argv explicitConfigDir global) and is injected via a resolveAttribution param.
- 17 test files migrated to import the moved symbols from the engine.
- Bookkeeping: eslint built-artifact ignore, .gitignore, INVENTORY manifest+row,
  CONTEXT.md Install Engine Module glossary seam.

Behaviour-preserving: install output is byte-identical for all 16 runtimes
(golden-parity harness #1730) — the only delta is the new install-engine.cjs
file shipping in the installed gsd-core/bin/lib/ tree.

Closes #1734

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1734): backfill changeset PR number (#1735)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: review-bot <review-bot@gsd>
This commit is contained in:
Tom Boucher
2026-06-25 21:16:47 -04:00
committed by GitHub
parent 6414249d25
commit b307c4cfde
42 changed files with 936 additions and 3128 deletions

View File

@@ -376,6 +376,31 @@ const {
updateCacheFileName,
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
// ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs.
// getCommitAttribution STAYS here (impure install-time config I/O); it is injected
// into the engine functions via the resolveAttribution parameter at each call site.
const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
const {
installRuntimeArtifacts,
uninstallRuntimeArtifacts,
installOpencodeFamilySkills,
_copyStaged,
hasExistingSymlinkBetween,
preserveUserArtifacts,
restoreUserArtifacts,
migrateLegacyDevPreferencesToSkill,
applyOpencodeFamilyPathPrefix,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
USER_OWNED_ARTIFACTS,
_runLegacyInstallMigrations,
_runLegacyUninstallCleanup,
_removeGsdEntries,
_snapshotDir,
_restoreDir,
_removeHermesBareStemDirs,
} = installEngine;
// Parse args
const args = process.argv.slice(2);
const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -6267,69 +6292,9 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
return `---\n${newFrontmatter}\n---${body}`;
}
/**
* Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo),
* which share a config schema (Kilo derives from OpenCode). OpenCode discovers
* skills as `skills/<name>/SKILL.md` and Kilo follows the same layout
* (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills).
*
* The skill body reuses the runtime's command-frontmatter converter for tool,
* path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill
* frontmatter: only `name` (lowercase-hyphen, must match the containing
* directory) and `description` (1–1024 chars) are emitted, per the OpenCode
* skill spec. The command's `tools:`/`permission:` block is intentionally
* dropped — OpenCode skills are loaded on-demand via the native skill tool and
* inherit the calling agent's permissions.
*
* @param {string} content - Claude command markdown (with YAML frontmatter)
* @param {string} skillName - Skill directory name (e.g. gsd-help)
* @param {(content: string) => string} frontmatterConverter - runtime command converter
* @returns {string} SKILL.md content
*/
function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) {
const converted = frontmatterConverter(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
// OpenCode skill descriptions must be 1–1024 characters.
if (description.length > 1024) {
description = `${description.slice(0, 1021)}...`;
}
// `name` must be lowercase alphanumeric with single-hyphen separators and
// match the containing directory name (the staged dir is `${skillName}/`).
const name = yamlIdentifier(skillName);
return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`;
}
/**
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
* Thin wrapper over the shared OpenCode-family writer.
*/
function convertClaudeCommandToOpencodeSkill(content, skillName) {
return convertClaudeCommandToOpencodeFamilySkill(
content,
skillName,
(c) => convertClaudeToOpencodeFrontmatter(c),
);
}
/**
* Convert a Claude command (.md) to a Kilo skill (SKILL.md).
* Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
*/
function convertClaudeCommandToKiloSkill(content, skillName) {
return convertClaudeCommandToOpencodeFamilySkill(
content,
skillName,
(c) => convertClaudeToKiloFrontmatter(c),
);
}
// convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
// convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
/**
* Convert Claude Code markdown command to Gemini TOML format
@@ -6412,30 +6377,8 @@ function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
* @param {string} pathPrefix - Path prefix for file references
* @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
*/
/**
* Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a
* RAW Claude command/skill body, BEFORE the frontmatter converter runs.
*
* This is the single source of truth shared by copyFlattenedCommands (commands)
* and installOpencodeFamilySkills (skills) so the two surfaces produce identical
* path references. Applying pathPrefix pre-conversion (rather than rewriting an
* already-converted body) is what avoids the converter's hardcoded default
* config dir leaking into --local / --config-dir installs, and the
* prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784)
*
* @param {string} content - raw Claude command markdown
* @param {string} runtime - 'opencode' or 'kilo'
* @param {string} pathPrefix - trailing-slash install-target prefix
* @returns {string}
*/
function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
content = content.replace(/~\/\.opencode\//g, pathPrefix);
content = content.replace(/~\/\.kilo\//g, pathPrefix);
return content;
}
// applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
if (!fs.existsSync(srcDir)) {
@@ -6574,734 +6517,22 @@ function writeHermesCategoryDescription(categoryDir) {
* @param {boolean} isGlobal - Whether this is a global install
*/
/**
* Single source of truth for user-owned artifacts inside gsd-core/.
*
* These files are created/refreshed by user-facing workflows (e.g.
* /gsd-profile-user) and must be preserved across reinstalls. Critically, they
* MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
* will compare a refreshed file against a stale manifest hash and emit a
* spurious "locally modified GSD file" warning (bug #2771).
*
* Invariant: a file is either distribution (manifest-tracked, diff'd against
* manifest) or user artifact (preserved across installs, never diff'd). Never
* both. Both preserveUserArtifacts call sites and writeManifest must agree on
* this list, which is why it lives here as a single constant.
*
* Paths are relative to the gsd-core/ directory.
*/
const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
/**
* Save user-generated files from destDir to an in-memory map before a wipe.
*
* @param {string} destDir - Directory that is about to be wiped
* @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
* @returns {Map<string, string>} Map of fileName → file content (only entries that existed)
*/
function preserveUserArtifacts(destDir, fileNames) {
const saved = new Map();
for (const name of fileNames) {
const fullPath = path.join(destDir, name);
if (fs.existsSync(fullPath)) {
try {
saved.set(name, fs.readFileSync(fullPath, 'utf8'));
} catch { /* skip unreadable files */ }
}
}
return saved;
}
/**
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
*
* @param {string} destDir - Directory that was wiped and recreated
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
*/
function restoreUserArtifacts(destDir, saved) {
for (const [name, content] of saved) {
const fullPath = path.join(destDir, name);
try {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
} catch { /* skip unwritable paths */ }
}
}
/**
* Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
* runtime-aware SKILL.md location used by the writer after #2973.
*
* For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
* the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
* For runtimes with a flat skills layout (prefix='gsd-'), the target is
* <configDir>/skills/gsd-dev-preferences/SKILL.md.
*
* Skips silently if no legacy file was preserved, or if a SKILL.md already
* exists at the new location (don't clobber user-customized skill content
* — they may have edited the new file directly). Returns true on actual
* migration so callers can log a one-line confirmation.
*
* @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude)
* @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
* @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
* @param {'global'|'local'} [scope] - install scope
* @returns {boolean} - true if a file was migrated, false otherwise
*/
function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
if (!saved || !saved.has('dev-preferences.md')) return false;
let skillDir;
if (runtime) {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
skillDir = path.join(assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
} else {
// Legacy fallback for callers that have not yet been updated to pass runtime
skillDir = path.join(assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
}
const skillFile = path.join(skillDir, 'SKILL.md');
if (fs.existsSync(skillFile)) return false;
// Symlink-escape guard: reject if any path component between targetDir and
// skillDir is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) {
throw new Error(
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
);
}
try {
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
return true;
} catch {
return false;
}
}
// USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts,
// migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries,
// _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir,
// _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts,
// installOpencodeFamilySkills, uninstallRuntimeArtifacts:
// ALL moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
// ---------------------------------------------------------------------------
// Phase 2 — Layout-driven install/uninstall orchestrators
// Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
// _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for
// call sites in copyWithPathReplacement (not moved).
// ---------------------------------------------------------------------------
/**
* Apply per-runtime content rewrites in place across every SKILL.md inside a
* staged directory. Reproduces the rewrite scaffolding that the old
* copyCommandsAs<Runtime>Skills functions applied between read-content and
* converter-call. Applied AFTER stage (which already called the converter);
* rewrites target stable path patterns the converter doesn't touch.
*
* For Qwen/Hermes, branding rewrites (.claude/ → .qwen/ / .hermes/) run
* AFTER the slash-form path replacements but they only catch bare `.claude/`
* patterns (skill-body relative refs) that the slash forms didn't consume.
* This mirrors the exact ordering in the legacy copyCommandsAsClaudeSkills body.
*
* @param {string} stagedDir
* @param {string} runtime
* @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
*/
// 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.
* Used for runtimes that have a commandsKind in their layout and need content rewrites
* (e.g. augment — replaces ~/.claude/ paths and applies branding conversions).
*
* IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged
* on a full/default profile (skills === '*'). This function MUST NOT mutate that source
* directory. It always copies to a temp dir first, rewrites there, and returns the new
* path so the caller installs from the temp copy, not the source.
*
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
* @param {string} runtime
* @param {string} pathPrefix
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
*/
// 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.
* Extracted so it can be unit-tested independently of the filesystem walk.
*
* @param {string} content
* @param {string} runtime
* @param {string} pathPrefix trailing-slash string
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
* @returns {string}
*/
// _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;
const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
/**
* Copy a staged directory's contents into destDir.
* Additive — does not prune (surface.cjs handles pruning).
*
* For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
* the whole dir into destDir.
* For commands/agents kind: iterate .md files and write them into destDir.
* - commands: write as `${prefix}${stem}.md` unless destSubpath already
* encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
* which case write as `${stem}.md` (directory IS the namespace).
* - agents: write as-is (files already carry their own `gsd-` prefix).
* For kimi-agents kind: recursively copy generated YAML/prompt files.
*/
function _copyStaged(stagedDir, destDir, kind, configDir) {
// Defense-in-depth: verify destDir is within the install root even if the
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
// the actual write site against any future call-site drift.
// Fail-closed: every _copyStaged write must declare its install root so the gate
// can confine it. All callers pass configDir; an omitted root is a bug, not a copy.
if (configDir === undefined) {
throw new Error(
'_copyStaged: configDir (install root) is required to confine writes — refusing to write',
);
}
// Strict-subpath + NUL containment via the canonical gate (shared with the
// layout-driven install plan); throws if destDir escapes the install root.
// destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
const resolvedDest = assertDestWithinConfigHome(configDir, destDir);
// Symlink-escape guard: reject if any path component between configDir and
// destDir is a symlink that would redirect writes outside configDir.
if (hasExistingSymlinkBetween(path.resolve(configDir), resolvedDest)) {
throw new Error(
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`,
);
}
// Use the validated absolute path for the actual writes below.
destDir = resolvedDest;
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
if (kind.kind === 'skills') {
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const src = path.join(stagedDir, entry.name);
const dest = path.join(destDir, entry.name);
fs.cpSync(src, dest, { recursive: true });
}
return;
}
if (kind.kind === 'kimi-agents') {
fs.cpSync(stagedDir, destDir, { recursive: true });
return;
}
// commands or agents
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
// For commands: apply prefix unless the destSubpath's last segment already
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
const destLast = path.basename(kind.destSubpath);
const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3); // strip .md
let destName;
if (kind.kind === 'agents') {
// Agent files already carry the gsd- prefix in the source dir
destName = entry.name;
} else if (namespacedByDir) {
// Directory is the namespace; don't double-prefix the filename
destName = entry.name;
} else {
// Flat commands directory (e.g. command/ for opencode/kilo)
destName = `${kind.prefix}${stem}.md`;
}
fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
}
}
/**
* Remove GSD-prefixed entries from destDir matching kind.prefix.
* For the prefix='' case: the destSubpath IS the namespace — remove the entire
* destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
* as a defensive guard for future runtimes.)
*/
function _removeGsdEntries(destDir, kind) {
if (!fs.existsSync(destDir)) return;
if (kind.kind === 'kimi-agents') {
for (const fileName of ['gsd.yaml', 'gsd.md']) {
fs.rmSync(path.join(destDir, fileName), { force: true });
}
const subagentsDir = path.join(destDir, 'subagents');
if (fs.existsSync(subagentsDir)) {
for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('gsd-')) continue;
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
}
}
return;
}
if (kind.prefix === '') {
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
// The directory itself is the GSD namespace, so remove it entirely.
fs.rmSync(destDir, { recursive: true, force: true });
return;
}
for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) {
if (!entry.name.startsWith(kind.prefix)) continue;
fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
}
}
/**
* Run legacy install migrations that must execute BEFORE the layout-driven
* copy so stale artifacts are cleaned up before new ones are written.
*
* - Claude/Qwen/Hermes: migrate legacy commands/gsd/dev-preferences.md →
* skills/gsd-dev-preferences/SKILL.md if the old file is present.
* Also removes the legacy commands/gsd/ directory.
* - Hermes: remove flat skills/gsd-STAR directories (pre-2841 layout) before
* writing the new nested skills/gsd/ layout.
*
* @param {string} runtime
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} [scope]
*/
function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
// Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
// for migration. The actual migration call is deferred to after all layout cleanup so
// that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
// created skills/gsd-dev-preferences/ skill dir.
let savedLegacyArtifacts = null;
if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
if (fs.existsSync(legacyCommandsGsd)) {
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
fs.rmSync(legacyCommandsGsd, { recursive: true });
}
}
// Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
// the new skills/gsd/ nested layout.
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
// layout-driven install loop in installRuntimeArtifacts, where the exact set
// of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
// require readGsdCommandNames() which misses skills like 'dev-preferences'
// that are not in the commands directory. See _removeHermesBareStemDirs().
}
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
// newly created skill dir. No-op if skill file already exists.
if (savedLegacyArtifacts) {
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
}
}
/**
* Run legacy uninstall cleanup that must execute BEFORE the layout-driven
* removal so old-format entries are also cleaned up.
*
* - Claude global/Qwen: remove legacy commands/gsd/ directory if present.
* For Claude LOCAL, commands/gsd/ is the current primary location (not
* legacy), so we skip removal here and let _removeGsdEntries handle it
* with gsd- prefix filtering (preserving user files like dev-preferences.md).
* - Hermes: remove pre-2841 flat skills/gsd-STAR entries.
*
* @param {string} runtime
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} [scope]
*/
function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
// After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
// block (1c) handles removal of flat files; this function handles the legacy
// commands/gsd/ directory for all Claude scopes (global was already included,
// local is now added since that layout is also legacy post-#1367).
// #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
// before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
// is deferred and returned so the caller can apply it AFTER layout-driven
// removal — this prevents the layout's gsd-* prefix removal from wiping the
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
let savedLegacyArtifacts = null;
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
// (which would redirect to skills/) conflicts with the test contract for local installs.
const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
if (isLegacyCommandsGsd) {
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsGsd)) {
savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
fs.rmSync(legacyCommandsGsd, { recursive: true });
}
}
// Hermes: pre-#2841 flat skills/gsd-*/ entries
if (runtime === 'hermes') {
const flatSkillsDir = path.join(configDir, 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
}
}
}
// Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
// start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
// had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
if (fs.existsSync(nestedGsdDirForUninstall)) {
for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
}
}
}
}
// Return saved artifacts so the caller can migrate after layout-driven removal.
return savedLegacyArtifacts;
}
/**
* Layout-driven install orchestrator.
* Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
* determine what artifact kinds to write and where.
*
* @param {string} runtime canonical runtime ID
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} scope
* @param {Object} resolvedProfile from resolveProfile() / resolveEffectiveProfile()
*/
/**
* Deep-snapshot a directory tree into a Map<relPath, Buffer>.
* Returns an empty Map if the directory doesn't exist.
* @param {string} dir
* @returns {Map<string, Buffer>}
*/
function _snapshotDir(dir) {
const files = new Map();
if (!fs.existsSync(dir)) return files;
const walk = (relPath, absPath) => {
for (const e of fs.readdirSync(absPath, { withFileTypes: true })) {
const childRel = relPath ? path.join(relPath, e.name) : e.name;
const childAbs = path.join(absPath, e.name);
if (e.isDirectory()) walk(childRel, childAbs);
else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs));
}
};
walk('', dir);
return files;
}
/**
* Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
* @param {string} dir
* @param {Map<string, Buffer>} snapshot
*/
function _restoreDir(dir, snapshot) {
for (const [relPath, buf] of snapshot) {
const absPath = path.join(dir, relPath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, buf);
}
}
/**
* After the layout-driven install loop writes new gsd-<stem>/ dirs to
* skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
* that correspond to the newly installed gsd-<stem> entries.
*
* The removal set is derived from the ACTUAL installed skill dirs (every
* entry starting with 'gsd-' that is a directory), so it covers ALL shipped
* GSD skills — including 'dev-preferences' and future additions — without
* relying on readGsdCommandNames() which only enumerates the commands source
* tree and can miss skills that ship outside that directory.
*
* Safety: a bare dir is ONLY removed when a corresponding gsd-<stem>/ dir was
* installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no
* matching 'skills/gsd/gsd-my-workflow/' is never touched.
*
* @param {string} nestedGsdDir absolute path to skills/gsd/ category dir
*/
function _removeHermesBareStemDirs(nestedGsdDir) {
if (!fs.existsSync(nestedGsdDir)) return;
const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
// Collect the set of stems that were installed as gsd-<stem>/ this run.
const installedStems = new Set();
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
}
}
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
}
}
}
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
const planResult = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile,
homedir: () => os.homedir(),
platform: process.platform,
resolveAttribution: getCommitAttribution,
});
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
try {
if (!planResult.ok) {
throw new Error(planResult.message);
}
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of planResult.plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
const dest = item.destDir;
// Symlink-escape guard: reject before mkdir if dest (or any component
// between configDir and dest) is a symlink pointing outside configDir.
// mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
if (hasExistingSymlinkBetween(path.resolve(configDir), dest)) {
throw new Error(
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`,
);
}
fs.mkdirSync(dest, { recursive: true });
if (kind.kind === 'skills' && fs.existsSync(dest)) {
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
// then restore after. This preserves user dirs across a wipe-and-replace
// install (#2973 / #3664).
//
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
// untouched. Preserve the explicit user-owned GSD-prefixed skill
// gsd-dev-preferences, which GSD does not reinstall from source but must
// survive the prune (#2973).
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
{
// Preserve explicitly user-owned GSD-prefixed skill dirs.
// gsd-dev-preferences is the sole user-customisable skill in this category.
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
for (const dirName of USER_OWNED_SKILL_DIRS) {
const skillDir = path.join(dest, dirName);
if (!fs.existsSync(skillDir)) continue;
const snap = _snapshotDir(skillDir);
if (snap.size > 0) toPreserve.set(dirName, snap);
}
}
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
// Restore user-owned dirs after the prune+copy
for (const [dirName, snap] of toPreserve) {
_restoreDir(path.join(dest, dirName), snap);
}
} else {
// For non-skills kinds (commands, agents): no user content to preserve;
// just prune stale gsd-* entries and copy new ones.
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
}
}
} finally {
for (const dir of cleanupDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
// correspond to the newly installed gsd-<stem> entries. This is the robust
// replacement for the readGsdCommandNames()-based pre-install cleanup that
// missed skills like 'dev-preferences' (#947 adversarial review).
//
// We run this AFTER the install loop so the installed set is authoritative:
// every gsd-<stem>/ present now was written this run (or was there before
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
// are untouched.
if (runtime === 'hermes') {
const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
}
}
/**
* Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
*
* These runtimes do NOT go through installRuntimeArtifacts (their commands use a
* bespoke flattened-command writer), so this writes ONLY the skills kind
* alongside their existing command/ + agents/ surfaces. Uninstall is already
* layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
* skills/ dir is cleaned up automatically once the layout declares it.
*
* `rawCommandsDir` MUST be the SAME staged command directory the flattened
* command writer consumes (the caller passes its `_stageSkills()` output) so the
* command/ and skills/ surfaces always cover the identical, profile-resolved set
* — including the `--minimal`/`--core-only` alias path, which stages differently
* from a plain `--profile=core`.
*
* Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite →
* attribution → command→skill conversion — guaranteeing command/ and skills/
* bodies match byte-for-byte for global, --local, and --config-dir installs.
* We deliberately do NOT use skillsKindEntry.stage(): that converts before any
* pathPrefix is known, so its bodies would carry the converter's hardcoded
* default config dir. (#784)
*
* @param {string} runtime - 'opencode' or 'kilo'
* @param {string} targetDir - resolved runtime config directory
* @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
* @param {string} pathPrefix - computed config-path prefix for body rewrites
* @returns {number} number of gsd-* skill directories written
*/
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) {
const layout = resolveRuntimeArtifactLayout(runtime, targetDir);
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
if (!skillsKindEntry) return 0;
const rawDir = rawCommandsDir;
if (!rawDir || !fs.existsSync(rawDir)) return 0;
const converter = runtime === 'kilo'
? convertClaudeCommandToKiloSkill
: convertClaudeCommandToOpencodeSkill;
const dest = assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
// Symlink-escape guard: reject if any path component between targetDir and
// dest is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) {
throw new Error(
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
);
}
fs.mkdirSync(dest, { recursive: true });
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
// would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
// (#2973).
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
for (const dirName of USER_OWNED_SKILL_DIRS) {
const skillDir = path.join(dest, dirName);
if (!fs.existsSync(skillDir)) continue;
const snap = _snapshotDir(skillDir);
if (snap.size > 0) toPreserve.set(dirName, snap);
}
_removeGsdEntries(dest, skillsKindEntry);
let count = 0;
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
const skillName = `${skillsKindEntry.prefix}${stem}`;
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, getCommitAttribution(runtime));
content = converter(content, skillName);
const skillDir = path.join(dest, skillName);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
count++;
}
// Restore user-owned dirs after the prune+copy.
for (const [dirName, snap] of toPreserve) {
_restoreDir(path.join(dest, dirName), snap);
}
return count;
}
/**
* Layout-driven uninstall orchestrator.
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
* determine which GSD-owned entries to remove.
*
* @param {string} runtime canonical runtime ID
* @param {string} configDir resolved runtime config directory
* @param {'global'|'local'} scope
*/
function uninstallRuntimeArtifacts(runtime, configDir, scope) {
// Legacy cleanup before layout-driven removal (scope-aware to avoid
// removing Claude local commands/gsd/ which is the primary install dir).
// Returns saved user artifacts so we can migrate AFTER layout removal
// (the layout's gsd-* prefix pass would wipe a skill dir created here).
const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
const plan = createRuntimeArtifactUninstallPlan(layout);
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) {
throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
}
_removeGsdEntries(item.destDir, kind);
}
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
// the GSD-managed DESCRIPTION.md and then the category dir itself if it
// contains no user content (#947). _removeGsdEntries removed gsd-* dirs
// but left the category container and DESCRIPTION.md intact.
if (runtime === 'hermes') {
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
if (fs.existsSync(nestedGsdDir)) {
// Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
// Remove the category dir if empty (no user content remaining)
const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
if (remaining.length === 0) {
fs.rmSync(nestedGsdDir, { recursive: true, force: true });
}
}
}
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
// runtime-aware SKILL.md location after all layout-driven removal is
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
if (savedLegacyArtifacts) {
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
}
}
/**
* Recursively copy directory, replacing paths in .md files
* Deletes existing destDir first to remove orphaned files from previous versions
@@ -8681,28 +7912,8 @@ function resolveInstallRelativePath(baseDir, relPath) {
return { relPath: normalized, fullPath };
}
function hasExistingSymlinkBetween(root, fullPath) {
const resolvedRoot = path.resolve(root);
const resolvedFullPath = path.resolve(fullPath);
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
return true;
}
let cursor = resolvedRoot;
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
return true;
}
const relative = path.relative(resolvedRoot, resolvedFullPath);
for (const segment of relative.split(path.sep)) {
if (!segment) continue;
cursor = path.join(cursor, segment);
if (!fs.existsSync(cursor)) return false;
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
}
return false;
}
// hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B).
// Imported from installEngine above.
/**
* Write file manifest after installation for future modification detection
@@ -9676,7 +8887,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
// gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -9836,7 +9047,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Kilo support native, on-demand skills in addition to flat commands — see
// resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
// the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
if (_skillCount > 0) {
console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
} else {