* fix(#2911): resolve surface re-stage destinations the way the installer does Two writers computed the same destination differently. The installer honors a skills-kind home override; the surface re-stage ignored it and always resolved against configDir. For a global Codex install that override points at $HOME/.agents, so every re-stage built a second GSD-managed skill tree under $CODEX_HOME alongside the correct one, with nothing indicating which was live. Honors the override as a fallback, never a replacement -- runtimes without one still resolve against configDir, which is most of them. The real deliverable is the parity test, not the one-expression fix: it walks every runtime in the registry across both scopes, computes the installer and surface destinations, and fails naming the runtime if they ever disagree. Today only Codex global carries an override, so it discriminates on exactly one runtime -- stating that plainly rather than implying broader coverage -- but it is derived from the registry, so a newly-added runtime is covered without anyone remembering to add it. Two further defects fixed rather than deferred: - The legacy dev-preferences migration carried the identical defect, which the issue flagged as a latent instance of the same shape. - Fixing it exposed a symlink-escape guard confined against the wrong root: it checked the span between configDir and the skill dir, but a home override moves the skill dir outside configDir entirely, so the span was meaningless and threw a false-positive escape. Now confined against the install root the destination actually resolves under. The guard is unchanged in strength and still honors its opt-in; only the root it measures from is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2911): honor the home override in the fourth destination writer too Adversarial review found a writer the fix had missed: the opencode-family skills installer resolved its destination and its symlink guard against targetDir, never consulting the skills-kind home override, while its three siblings all already honored it. Pre-existing and currently dormant -- it is reachable only for the combined-family runtimes, and none of them declares an override today, so no user is affected right now. Fixed anyway rather than left as a latent instance of the same shape, which is exactly what this issue asked for in the case of the legacy migration. Mirrors the shape used for the other three: a single installRoot local that both the destination and the guard derive from, so the two cannot drift apart. The guard's message now names the root it actually confined against. Coverage extended to this writer and proven non-theatre: reverting the change in a scratch build makes it fail for both combined-family runtimes. Enumerated every remaining site that computes a destination from destSubpath or calls the confinement helper -- install and uninstall paths, the surface module, the read-side skills-root reporter. All honor the override or structurally cannot express one. No fifth defect. The one adjacent shape, the flat command directory, reads a different descriptor field that no kind declares an override for in the current schema; noted rather than papered over with a fallback for a field that cannot exist. Verified no behavior change for the affected runtimes today: normalized file-tree hashes before and after are identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2911): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1409 lines
69 KiB
TypeScript
1409 lines
69 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any,
|
|
@typescript-eslint/no-unsafe-assignment,
|
|
@typescript-eslint/no-unsafe-member-access,
|
|
@typescript-eslint/no-unsafe-return,
|
|
@typescript-eslint/no-unsafe-call,
|
|
@typescript-eslint/no-unsafe-argument,
|
|
@typescript-eslint/no-require-imports */
|
|
// Mechanical extraction from bin/install.js; keep behavior parity before typing.
|
|
'use strict';
|
|
|
|
/**
|
|
* Install Engine Module — ADR-1239 Phase B.
|
|
*
|
|
* Runtime-artifact install/uninstall cluster extracted from bin/install.js.
|
|
* bin/install.js imports this module for the layout-driven install/uninstall
|
|
* orchestrators and their private helpers. getCommitAttribution STAYS in
|
|
* bin/install.js (impure install-time config I/O); it is injected via the
|
|
* `resolveAttribution` parameter at each call site.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
|
|
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
|
|
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
|
|
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
|
import installProfiles = require('./install-profiles.cjs');
|
|
import installerMigrations = require('./installer-migrations.cjs');
|
|
import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
|
|
import { posixNormalize } from './shell-command-projection.cjs';
|
|
import { isPathConfined } from './external-descriptor-trust.cjs';
|
|
import { ensureCommonJsMarker } from './commonjs-marker.cjs';
|
|
|
|
const { processAttribution } = runtimeArtifactConversion;
|
|
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
|
// test stubs that monkeypatch the module's exports are seen at call time.
|
|
const { getDirName } = runtimeNamePolicy;
|
|
// assertDestWithinConfigHome: must be accessed via module ref at call time for
|
|
// test-stub compatibility (monkeypatching the module property works; a local
|
|
// const binding from destructure would capture the pre-stub value).
|
|
// These are only called from functions that are not stubbed, but we use the
|
|
// module ref pattern consistently for correctness.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types (loose — minimal annotations for strict mode compliance)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ResolveAttribution = (runtime: string) => any;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// USER_OWNED_ARTIFACTS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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: string[] = ['USER-PROFILE.md'];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Host-behavior helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Host-specific install behaviors declared on the runtime descriptor
|
|
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
|
|
* Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
|
|
* {} for runtimes that declare none or if the registry fails to load, so
|
|
* every behavior branch degrades to the generic path by default.
|
|
*/
|
|
function _hostBehaviors(runtime: string): any {
|
|
try {
|
|
const reg = require('./capability-registry.cjs');
|
|
return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conversion helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Apply per-runtime path-prefix rewrites for OpenCode-family skill bodies.
|
|
* Replaces ~/.claude/, $HOME/.claude/, ./.claude/ and OpenCode-variant paths
|
|
* with the computed pathPrefix for the install.
|
|
*/
|
|
function applyOpencodeFamilyPathPrefix(content: string, runtime: string, pathPrefix: string): string {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
|
|
* The canonical OpenCode-family writer lives in runtime-artifact-conversion.cjs
|
|
* (single source of truth — avoids a duplicate writer drifting per
|
|
* DEFECT.GENERATIVE-FIX); this thin wrapper delegates to it.
|
|
*/
|
|
function convertClaudeCommandToOpencodeSkill(content: string, skillName: string): string {
|
|
return (runtimeArtifactConversion as any).convertClaudeCommandToOpencodeSkill(content, skillName);
|
|
}
|
|
|
|
/**
|
|
* 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: string, skillName: string): string {
|
|
return (runtimeArtifactConversion as any).convertClaudeCommandToKiloSkill(content, skillName);
|
|
}
|
|
|
|
/**
|
|
* Converter-name registry for the OpenCode-family combined skills installer
|
|
* (ADR-1239 / #2093). Maps the `converter` string declared on each runtime's
|
|
* artifactLayout skills-kind descriptor (capabilities/<runtime>/capability.json)
|
|
* to the actual conversion function, so `installOpencodeFamilySkills` dispatches
|
|
* off the descriptor instead of a `frontmatterDialect === 'kilo'` runtime check.
|
|
*/
|
|
const SKILLS_CONVERTER_REGISTRY: Record<string, (content: string, skillName: string) => string> = {
|
|
convertClaudeCommandToOpencodeSkill,
|
|
convertClaudeCommandToKiloSkill,
|
|
convertClaudeCommandToKimiCodeSkill: runtimeArtifactConversion.convertClaudeCommandToKimiCodeSkill,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User-artifact preservation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Save user-generated files from destDir to an in-memory map before a wipe.
|
|
*
|
|
* @param destDir - Directory that is about to be wiped
|
|
* @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
|
* @returns Map of fileName → file content (only entries that existed)
|
|
*/
|
|
function preserveUserArtifacts(destDir: string, fileNames: string[]): Map<string, string> {
|
|
const saved = new Map<string, string>();
|
|
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 destDir - Directory that was wiped and recreated
|
|
* @param saved - Map returned by preserveUserArtifacts
|
|
*/
|
|
function restoreUserArtifacts(destDir: string, saved: Map<string, string>): void {
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Symlink-escape guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Opt-in for intentional symlinked-dest layouts (#2393). When the env var is
|
|
* set to "1" or "true", `hasExistingSymlinkBetween` follows symlinks instead of
|
|
* refusing them, EXCEPT for two load-bearing cases that always refuse regardless
|
|
* of opt-in (preserving ADR-1239 Phase B's threat model):
|
|
*
|
|
* (a) The `fullPath` itself, before any symlink resolution, escapes `root`
|
|
* via `..`-traversal — protects against untrusted `destSubpath` strings
|
|
* like `../../etc`. This is the line `resolvedFullPath !== resolvedRoot
|
|
* && !resolvedFullPath.startsWith(resolvedRoot + path.sep)` below.
|
|
* (b) A symlink's resolved real path equals the install root itself — this
|
|
* would let `_removeGsdEntries` (the prune pass) wipe the install root,
|
|
* which is the config-root-wipe threat from #1704 threat model item (b).
|
|
*
|
|
* What opt-in RELAXES specifically: the "pre-existing symlink that points
|
|
* outside configHome" refusal — threat (c) in #1704. The user has asserted
|
|
* they own and trust the symlink target. The default (no env var) keeps all
|
|
* three refusals, exactly the pre-#2393 behavior.
|
|
*
|
|
* Cross-platform note: on Windows, `fs.lstatSync().isSymbolicLink()` returns
|
|
* true for both symbolic links and NTFS junctions (Node ≥ 16), so Mamiki's
|
|
* Junction case (#2393 comment) is handled by the same code path as POSIX
|
|
* symlinks.
|
|
*
|
|
* @returns true when the caller MUST refuse; false when writes may proceed.
|
|
*/
|
|
function isSymlinkedDestOptIn(): boolean {
|
|
const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
|
|
return v === '1' || v === 'true';
|
|
}
|
|
|
|
/**
|
|
* Returns true if any path component between `root` and `fullPath` is a
|
|
* symbolic link that would redirect writes outside the install root in a way
|
|
* the caller must refuse.
|
|
*
|
|
* When `options.allowOptInFollow` is true (caller checked `isSymlinkedDestOptIn`),
|
|
* symlinks are followed instead of refused, except for the two always-refuse
|
|
* cases documented on `isSymlinkedDestOptIn` — (a) path-traversal in `fullPath`
|
|
* itself, (b) a resolved symlink target that equals the install root (would let
|
|
* the prune pass wipe it).
|
|
*/
|
|
function hasExistingSymlinkBetween(
|
|
root: string,
|
|
fullPath: string,
|
|
options: { allowOptInFollow?: boolean } = {},
|
|
): boolean {
|
|
const resolvedRoot = path.resolve(root);
|
|
const resolvedFullPath = path.resolve(fullPath);
|
|
// (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
|
|
// destSubpath string that escapes the install root via '..' is rejected
|
|
// regardless of user opt-in state (ADR-1239 Phase B threat (a)).
|
|
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
|
|
return true;
|
|
}
|
|
|
|
// #2393 (security-review finding): realpathSync fully resolves all symlink
|
|
// components, path.resolve only normalizes lexically. On macOS, /var is a
|
|
// symlink to /private/var — so resolvedRoot='/var/foo/.claude' but its real
|
|
// path is '/private/var/foo/.claude'. A symlink whose real target equals the
|
|
// install root (the threat-(b) wipe case) would compare unequal without this
|
|
// normalization, defeating the guard exactly in the reporter's case (Azd325,
|
|
// nix-darwin: ~/.claude is itself a symlink). Compute realRoot once; fall
|
|
// back to the lexical form on any realpath failure (broken/missing/exotic FS)
|
|
// — threat (a) above still confines regardless.
|
|
let realRoot: string;
|
|
try {
|
|
realRoot = fs.existsSync(resolvedRoot) ? fs.realpathSync(resolvedRoot) : resolvedRoot;
|
|
} catch {
|
|
realRoot = resolvedRoot;
|
|
}
|
|
|
|
const allowFollow = options.allowOptInFollow === true;
|
|
|
|
// #2393: when root itself is a symlink (e.g. nix-darwin manages ~/.claude as a
|
|
// symlink to a dotfiles repo — Azd325's #2393 report), the pre-#2393 guard
|
|
// refused unconditionally via an early return before the component loop. The
|
|
// wipe threat (b) does NOT apply to the root itself being a symlink: destDir is
|
|
// a CHILD of root, and resolving root gives root's target — there is no
|
|
// circular back-reference to root from a path that descends from a resolved
|
|
// root. So under opt-in, just follow the root symlink and continue the walk.
|
|
// Default behavior (no opt-in) preserves the pre-#2393 refuse.
|
|
let cursor = resolvedRoot;
|
|
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
|
|
if (!allowFollow) return true;
|
|
try {
|
|
cursor = fs.realpathSync(cursor);
|
|
} catch {
|
|
// realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
|
|
// matching fail-closed posture.
|
|
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()) {
|
|
if (!allowFollow) return true;
|
|
// Opt-in active: follow the symlink. Refuse if the resolved target is the
|
|
// install root itself (threat (b) — would let _removeGsdEntries wipe the
|
|
// root). Other targets are acceptable per the user's explicit opt-in. A
|
|
// broken symlink (realpathSync throws) is still refused.
|
|
//
|
|
// Threat (b) check uses BOTH lexical and real forms of root to defend
|
|
// against macOS /var ↔ /private/var-style normalization gaps: realpathSync
|
|
// fully resolves, path.resolve only normalizes lexically, so a root path
|
|
// containing a symlink component would compare unequal to a realtarget
|
|
// that matches by real path. Compare both.
|
|
//
|
|
// Transitivity note: once followed, the walk continues from the resolved
|
|
// real path WITHOUT re-checking that further segments stay inside any
|
|
// confining boundary. The user's opt-in asserts trust in the target dir
|
|
// AND any further symlinks reachable through it — transitive and unbounded
|
|
// by design (one opt-in trusts the whole reachable tree). This is the
|
|
// documented opt-in semantics; do not add a "follow one symlink only"
|
|
// expectation here without revisiting the threat model.
|
|
try {
|
|
const realTarget = fs.realpathSync(cursor);
|
|
if (realTarget === realRoot || realTarget === resolvedRoot) return true; // (b)
|
|
cursor = realTarget;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// migrateLegacyDevPreferencesToSkill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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 targetDir - Resolved runtime config directory (e.g. ~/.claude)
|
|
* @param saved - Map returned by preserveUserArtifacts
|
|
* @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
|
|
* @param scope - install scope
|
|
* @returns true if a file was migrated, false otherwise
|
|
*/
|
|
function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string, string>, runtime?: string, scope: string = 'global'): boolean {
|
|
if (!saved || !saved.has('dev-preferences.md')) return false;
|
|
let skillDir: string;
|
|
// #2911: the actual install root the skill dir resolves under — defaults to
|
|
// targetDir, but a skills-kind `home` override (e.g. Codex -> $HOME/.agents)
|
|
// moves it entirely outside targetDir. Every confinement/guard check below
|
|
// must confine against installRoot, not targetDir, or it would flag the
|
|
// legitimate override destination as an escape.
|
|
let installRoot: string = targetDir;
|
|
if (runtime) {
|
|
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as any);
|
|
const skillsKindEntry = layout.kinds.find((k: any) => 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';
|
|
// #2911: same destination-root defect as _copyStaged/applySurface — honor
|
|
// skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
|
|
// -> $HOME/.agents) instead of always resolving against targetDir, so a
|
|
// legacy dev-preferences migration lands in the SAME tree the installer
|
|
// and surface-apply use. Runtimes with no `home` override are unaffected.
|
|
installRoot = skillsKindEntry.home ?? targetDir;
|
|
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath), stemName);
|
|
} else {
|
|
// Legacy fallback for callers that have not yet been updated to pass runtime
|
|
skillDir = path.join(runtimeArtifactInstallPlan.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 installRoot and
|
|
// skillDir is a symlink that would redirect writes outside the install root.
|
|
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
if (hasExistingSymlinkBetween(path.resolve(installRoot), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
throw new Error(
|
|
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
|
);
|
|
}
|
|
try {
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(skillFile, saved.get('dev-preferences.md')!, 'utf8');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _copyStaged
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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: string, destDir: string, kind: any, configDir: string, runtime?: string): void {
|
|
// 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',
|
|
);
|
|
}
|
|
// The install root is normally configDir, but a kind may declare an alternate
|
|
// `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
|
|
// that case this defense-in-depth check must confine against the resolved
|
|
// alternate root instead, matching the upstream gate's own root selection in
|
|
// createRuntimeArtifactInstallPlan.
|
|
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
// 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(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
|
|
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
|
|
// Symlink-escape guard: reject if any path component between the install root and
|
|
// destDir is a symlink that would redirect writes outside the install root.
|
|
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
throw new Error(
|
|
`_copyStaged: destDir "${destDir}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
|
);
|
|
}
|
|
// 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: string;
|
|
if (kind.kind === 'agents') {
|
|
// Agent files already carry the gsd- prefix in the source dir.
|
|
// #2099: descriptor-driven via hostBehaviors.agentFileExtension (was
|
|
// hardcoded `runtime === 'copilot'`). copilot declares '.agent.md';
|
|
// every other runtime's descriptor leaves this unset, so destName falls
|
|
// back to entry.name unchanged (byte-parity, #1575 origin comment).
|
|
const _agentExt = runtime ? _hostBehaviors(runtime).agentFileExtension : undefined;
|
|
destName = _agentExt
|
|
? entry.name.replace(/\.md$/, _agentExt)
|
|
: 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));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _removeGsdEntries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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: string, kind: any): void {
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _snapshotDir / _restoreDir
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Deep-snapshot a directory tree into a Map<relPath, Buffer>.
|
|
* Returns an empty Map if the directory doesn't exist.
|
|
*/
|
|
function _snapshotDir(dir: string): Map<string, Buffer> {
|
|
const files = new Map<string, Buffer>();
|
|
if (!fs.existsSync(dir)) return files;
|
|
const walk = (relPath: string, absPath: string) => {
|
|
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.
|
|
*/
|
|
function _restoreDir(dir: string, snapshot: Map<string, Buffer>): void {
|
|
for (const [relPath, buf] of snapshot) {
|
|
const absPath = path.join(dir, relPath);
|
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
fs.writeFileSync(absPath, buf);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _removeHermesBareStemDirs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param nestedGsdDir absolute path to skills/gsd/ category dir
|
|
*/
|
|
function _removeHermesBareStemDirs(nestedGsdDir: string): void {
|
|
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<string>();
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy migration helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Run legacy install migrations that must execute BEFORE the layout-driven
|
|
* copy so stale artifacts are cleaned up before new ones are written.
|
|
*
|
|
* @param runtime
|
|
* @param configDir resolved runtime config directory
|
|
* @param scope
|
|
*/
|
|
function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: string = 'global'): void {
|
|
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: Map<string, string> | null = null;
|
|
if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
|
|
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.
|
|
*
|
|
* @param runtime
|
|
* @param configDir resolved runtime config directory
|
|
* @param scope
|
|
* @returns saved legacy artifacts for post-removal migration, or null
|
|
*/
|
|
function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): Map<string, string> | null {
|
|
// 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: Map<string, string> | null = 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 _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
|
|
const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && 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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// installRuntimeArtifacts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Layout-driven install orchestrator.
|
|
* Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
|
|
* determine what artifact kinds to write and where.
|
|
*
|
|
* @param runtime canonical runtime ID
|
|
* @param configDir resolved runtime config directory
|
|
* @param scope
|
|
* @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
|
|
* @param resolveAttribution injection: (runtime) => attribution string | undefined
|
|
* @param capabilityRegistry #2322: optional composed capability registry
|
|
* (capabilityClusters view) — threaded into resolveRuntimeArtifactLayout so
|
|
* the skills kind can materialize installed third-party capability skills
|
|
* bound to their declaring capId. Absent -> no third-party skills staged
|
|
* (fail closed), matching the layout resolver's own optional-registry contract.
|
|
*/
|
|
function installRuntimeArtifacts(
|
|
runtime: string,
|
|
configDir: string,
|
|
scope: string,
|
|
resolvedProfile: any,
|
|
resolveAttribution: ResolveAttribution = () => undefined,
|
|
capabilityRegistry?: any,
|
|
): void {
|
|
// A removed descriptor kind is no longer visited by the layout loop, so it
|
|
// cannot prune its own previous output. Clean manifest-proven retired files
|
|
// before materializing the current layout (#2644).
|
|
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
|
|
|
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
|
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
|
// generic layout-driven loop below, mirroring the bespoke install path that
|
|
// previously lived inline in bin/install.js.
|
|
const behaviors = _hostBehaviors(runtime);
|
|
if (behaviors.combinedFamilyInstall) {
|
|
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
|
|
// _runLegacyInstallMigrations below entirely (early return), so their
|
|
// legacy-directory cleanup needs its own pre-materialization hook here.
|
|
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
|
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
|
return;
|
|
}
|
|
|
|
// Legacy cleanup before layout-driven writes
|
|
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
|
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local', capabilityRegistry);
|
|
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
|
// `Layout` is structurally identical across the layout/install-plan .cjs
|
|
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
|
layout: layout as any,
|
|
resolvedProfile,
|
|
homedir: () => os.homedir(),
|
|
platform: process.platform,
|
|
resolveAttribution,
|
|
});
|
|
|
|
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
|
try {
|
|
if (!planResult.ok) {
|
|
throw new Error(planResult.message);
|
|
}
|
|
|
|
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
|
for (const item of planResult.plan.items) {
|
|
const kind: any = 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 the install root and dest) is a symlink pointing outside that
|
|
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
|
// call. The install root is normally configDir, but a kind may declare
|
|
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
|
// $HOME/.agents) — in that case the guard must check against the
|
|
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
|
// own root selection in createRuntimeArtifactInstallPlan.
|
|
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
|
|
// resolved-target-equals-root still refuse regardless of opt-in.
|
|
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
throw new Error(
|
|
`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
|
);
|
|
}
|
|
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<string, Map<string, Buffer>>(); // 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, runtime);
|
|
|
|
// 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, runtime);
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
|
|
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
|
|
// artifactLayout is empty and which never sets combinedFamilyInstall) still
|
|
// need their declared hostBehaviors.nativePlugin file copied into configDir.
|
|
// findInstallSourceRoot resolves the repo/package root independent of
|
|
// configDir contents (marker check, then a walk-up from __dirname), so this
|
|
// is safe even when configDir has no .gsd-source marker (artifactLayout: []).
|
|
if (behaviors.nativePlugin) {
|
|
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
|
const src = path.dirname(path.dirname(commandsGsdDir));
|
|
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// installOpencodeFamilySkills
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param runtime - 'opencode' or 'kilo'
|
|
* @param targetDir - resolved runtime config directory
|
|
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
|
* @param pathPrefix - computed config-path prefix for body rewrites
|
|
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
* @param resolvedProfile - #2362: from resolveProfile()/resolveEffectiveProfile(); only
|
|
* `.skills` is consulted (either the `'*'` full-profile sentinel or a concrete Set
|
|
* of stems), and only to gate which THIRD-PARTY capability stems are candidates for
|
|
* staging below. Absent -> no third-party skills staged (fail closed).
|
|
* @param capabilityRegistry - #2362: optional composed capability registry
|
|
* (capabilityClusters view). When present, installed third-party capability
|
|
* skills bound to their declaring capId are unioned into the staged output —
|
|
* the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills)
|
|
* this bespoke OpenCode/Kilo writer never called. Absent -> no third-party
|
|
* skills staged (fail closed), matching the seam's own optional-registry
|
|
* contract.
|
|
* @returns number of gsd-* skill directories written
|
|
*/
|
|
function installOpencodeFamilySkills(
|
|
runtime: string,
|
|
targetDir: string,
|
|
rawCommandsDir: string,
|
|
pathPrefix: string,
|
|
resolveAttribution: ResolveAttribution = () => undefined,
|
|
resolvedProfile?: any,
|
|
capabilityRegistry?: any,
|
|
): number {
|
|
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
|
|
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
|
|
if (!skillsKindEntry) return 0;
|
|
const rawDir = rawCommandsDir;
|
|
if (!rawDir || !fs.existsSync(rawDir)) return 0;
|
|
|
|
// #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
|
|
// string (capabilities/<runtime>/capability.json artifactLayout) via the
|
|
// SKILLS_CONVERTER_REGISTRY, instead of a `frontmatterDialect === 'kilo'`
|
|
// runtime check. Fail loud if the descriptor names an unregistered converter
|
|
// (mirrors the converter=null throw in runtime-artifact-layout.cts).
|
|
const converterName: string | undefined = skillsKindEntry.converter;
|
|
const converter = converterName ? SKILLS_CONVERTER_REGISTRY[converterName] : undefined;
|
|
if (!converter) {
|
|
throw new TypeError(
|
|
`installOpencodeFamilySkills: unknown skills converter '${String(converterName)}' for runtime '${runtime}'`,
|
|
);
|
|
}
|
|
|
|
// #2911: same destination-root defect as _copyStaged/migrateLegacyDevPreferencesToSkill
|
|
// — honor skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
|
|
// -> $HOME/.agents) instead of always resolving against targetDir, so this bespoke
|
|
// OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
|
|
// Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
|
|
// in lockstep with the sibling writers — the destination-parity test enforces it.
|
|
const installRoot: string = skillsKindEntry.home ?? targetDir;
|
|
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
|
|
// Symlink-escape guard: reject if any path component between installRoot and
|
|
// dest is a symlink that would redirect writes outside the install root.
|
|
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
throw new Error(
|
|
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
|
);
|
|
}
|
|
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<string, Map<string, Buffer>>(); // 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;
|
|
const firstPartyStems = new Set<string>();
|
|
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
const stem = entry.name.slice(0, -3);
|
|
firstPartyStems.add(stem);
|
|
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
|
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
content = processAttribution(content, resolveAttribution(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++;
|
|
}
|
|
|
|
// #2362: materialize installed THIRD-PARTY capability skills, bound to their
|
|
// DECLARING capability via the registry's capabilityClusters view — mirrors
|
|
// install-profiles.cts stageSkillsForRuntimeAsSkills's third-party fill-in
|
|
// (the actual #2322 seam), reusing its exported security-reviewed helpers
|
|
// rather than hand-rolling a second scan (DEFECT.GENERATIVE-FIX guard).
|
|
// First-party always wins on stem collision. The full/'*' sentinel resolves
|
|
// through capabilityClusterStems (BLOCKER-2 parity: `resolveProfile`
|
|
// short-circuits `full` to `'*'` before consulting a registry, so a bare
|
|
// `resolvedProfile.skills !== '*'` gate would silently skip this pass for
|
|
// the default full install). No registry in scope -> stage NOTHING
|
|
// third-party (fail closed — never fall back to scanning).
|
|
//
|
|
// Unlike the seam (which stages third-party bodies as-is and relies on a
|
|
// later applySurface rewrite pass), this install path has no such later
|
|
// pass — so third-party bodies get the SAME inline path-prefix/attribution
|
|
// rewrite as first-party ones for on-disk parity. They do NOT go through
|
|
// `converter`: an installed capability skill is already a complete
|
|
// SKILL.md, not a Claude-command body awaiting frontmatter conversion.
|
|
if (capabilityRegistry) {
|
|
const candidateStems: Iterable<string> =
|
|
resolvedProfile && resolvedProfile.skills === '*'
|
|
? installProfiles.capabilityClusterStems(capabilityRegistry)
|
|
: (resolvedProfile && resolvedProfile.skills) || [];
|
|
for (const stem of candidateStems) {
|
|
if (firstPartyStems.has(stem)) continue; // first-party always wins
|
|
const found = installProfiles.readInstalledCapabilitySkill(stem, capabilityRegistry);
|
|
if (found === null) continue; // absent/malformed/unowned -> skip gracefully
|
|
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
if (!isPathConfined(skillName, dest)) continue; // defense-in-depth
|
|
let content = found.content;
|
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
content = processAttribution(content, resolveAttribution(runtime));
|
|
const skillDir = path.join(dest, skillName);
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
|
// #2322 HIGH-3 parity: persist the capability-owned marker so a later
|
|
// prune pass can identify this directory even once the owning
|
|
// capability is uninstalled/unsurfaced and no longer appears in any
|
|
// registry view.
|
|
fs.writeFileSync(path.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
|
count++;
|
|
}
|
|
}
|
|
|
|
// Restore user-owned dirs after the prune+copy.
|
|
for (const [dirName, snap] of toPreserve) {
|
|
_restoreDir(path.join(dest, dirName), snap);
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// installOpencodeFamilyCommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Install the flattened commands surface for an OpenCode-family runtime
|
|
* (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
|
|
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
|
|
*
|
|
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
|
|
* #2087), except attribution is resolved via the injected
|
|
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
|
|
*
|
|
* @param runtime - 'opencode' or 'kilo'
|
|
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
|
|
* @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
|
|
* @param pathPrefix - computed config-path prefix for body rewrites
|
|
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
|
|
*/
|
|
function installOpencodeFamilyCommands(
|
|
runtime: string,
|
|
destDir: string,
|
|
srcDir: string,
|
|
pathPrefix: string,
|
|
resolveAttribution: ResolveAttribution = () => undefined,
|
|
prefix: string = 'gsd',
|
|
): void {
|
|
if (!fs.existsSync(srcDir)) return;
|
|
|
|
// Remove old gsd-*.md files before copying new ones
|
|
if (fs.existsSync(destDir)) {
|
|
for (const file of fs.readdirSync(destDir)) {
|
|
if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) fs.unlinkSync(path.join(destDir, file));
|
|
}
|
|
} else {
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
}
|
|
|
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
const srcPath = path.join(srcDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
|
|
} else if (entry.name.endsWith('.md')) {
|
|
const baseName = entry.name.replace('.md', '');
|
|
const destName = `${prefix}-${baseName}.md`;
|
|
let content = fs.readFileSync(srcPath, 'utf8');
|
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
content = processAttribution(content, resolveAttribution(runtime));
|
|
// #2093: this commands-kind entry's descriptor `converter` field is
|
|
// intentionally `null` (see capabilities/{kilo,opencode}/capability.json —
|
|
// the flattened-command writer above applies its own path/attribution
|
|
// rewrites and has no per-file converter slot to key on), so there is no
|
|
// descriptor string to dispatch through here. `frontmatterDialect` is the
|
|
// documented, intentional dispatch key for frontmatter-shape selection —
|
|
// it is itself descriptor-driven (not a `runtime === 'kilo'` check), so it
|
|
// already satisfies the fold-to-descriptor requirement. Only the SKILLS
|
|
// converter site above (installOpencodeFamilySkills) has a real
|
|
// `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
|
|
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
|
? (runtimeArtifactConversion as any).convertClaudeToKiloFrontmatter(content)
|
|
: (runtimeArtifactConversion as any).convertClaudeToOpencodeFrontmatter(content);
|
|
fs.writeFileSync(path.join(destDir, destName), content);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _installNativePluginIfDeclared
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Copy a runtime's declared native-extension/plugin file (hostBehaviors.nativePlugin)
|
|
* into its resolved config dir, when the runtime descriptor declares one.
|
|
*
|
|
* Extracted (ADR-1239 / #2102 Stage 1) from the body previously inlined in
|
|
* installOpencodeFamilyArtifacts so a runtime that is NOT part of the
|
|
* OpenCode/Kilo combined-family install (e.g. pi, whose artifactLayout is
|
|
* empty and which never sets combinedFamilyInstall) can still get its
|
|
* nativePlugin file staged via the generic installRuntimeArtifacts branch.
|
|
* Behavior for opencode/kilo is unchanged — same source resolution, same
|
|
* mkdir + copyFileSync call, same silent no-op when the source is missing.
|
|
*
|
|
* @param runtime - canonical runtime id (only used for the assertDestWithinConfigHome guard)
|
|
* @param configDir - resolved runtime config directory
|
|
* @param behaviors - the runtime's hostBehaviors descriptor
|
|
* @param src - repo/package root (two levels up from the commands/gsd source dir)
|
|
*/
|
|
function _installNativePluginIfDeclared(
|
|
runtime: string,
|
|
configDir: string,
|
|
behaviors: any,
|
|
src: string,
|
|
): void {
|
|
const np = behaviors.nativePlugin;
|
|
if (np && np.source) {
|
|
const pluginSrc = path.join(src, np.source);
|
|
if (fs.existsSync(pluginSrc)) {
|
|
// Confine the FULL dest path (dir + file), not just the dir. Previously
|
|
// only `np.dir` was validated and `np.file` was joined on unchecked, so a
|
|
// descriptor whose `file` carried `..`, an absolute path, or a NUL byte
|
|
// would have written outside configHome. Not reachable today — descriptors
|
|
// are first-party and compiled into the capability registry at build time —
|
|
// but `np.file` is exactly the field #2470 changes, and the guard costs
|
|
// nothing. For a well-formed descriptor this resolves identically to the
|
|
// previous mkdir(dir) + join(dir, file).
|
|
const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(
|
|
configDir,
|
|
path.join(np.dir, np.file),
|
|
);
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
fs.copyFileSync(pluginSrc, destPath);
|
|
// #2544: the staged adapter is a `.js` file, so Node decides its module
|
|
// type by walking up for the nearest package.json. It used to find the
|
|
// marker the installer wrote at the config root — the write that
|
|
// clobbered user-authored files. Pin it from the plugin's own directory
|
|
// instead, leaving the config root alone. The marker cannot disturb
|
|
// plugin discovery: OpenCode auto-discovers `plugins/*.{ts,js}` and pi's
|
|
// isExtensionFile() accepts only `.ts`/`.js` (see installer-migration
|
|
// 006), so a package.json here is never treated as a plugin. Never
|
|
// written over a package.json GSD does not own — but when one is already
|
|
// there, say so: the adapter is CommonJS and will not load under a
|
|
// foreign `"type": "module"`, and a silent no-op would leave every guard
|
|
// the adapter spawns dead with no diagnostic (the #2305 failure shape).
|
|
const markerOutcome = ensureCommonJsMarker(path.dirname(destPath));
|
|
if (markerOutcome === 'preserved-foreign') {
|
|
console.warn(
|
|
` ⚠ ${np.dir}/package.json is not GSD's CommonJS marker — left untouched. `
|
|
+ `If it declares "type": "module", ${np.file} will not load.`,
|
|
);
|
|
} else if (markerOutcome === 'failed') {
|
|
// Best-effort, never fatal: an unwritable plugin dir must not abort the
|
|
// install. Same warn-and-continue posture as the foreign-marker branch —
|
|
// the adapter is staged either way, it just may not resolve as CommonJS.
|
|
console.warn(
|
|
` ⚠ Could not write ${np.dir}/package.json (CommonJS marker) — install continued. `
|
|
+ `If the config root declares "type": "module", ${np.file} will not load.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// _migrateLegacyOpencodeCommandDir
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* #2329: migrate a pre-fix OpenCode install's legacy singular `command/`
|
|
* command directory into the current descriptor-driven destination (plural
|
|
* `commands/` for OpenCode — the dir OpenCode actually discovers slash
|
|
* commands from; unaffected for Kilo, whose descriptor still declares
|
|
* `command`, so `currentName === LEGACY_NAME` short-circuits below).
|
|
*
|
|
* Runs BEFORE materialization writes the fresh command set to the new
|
|
* location (mirroring `_runLegacyInstallMigrations`'s ordering for the
|
|
* generic branch, which combined-family runtimes otherwise skip entirely).
|
|
*
|
|
* Ownership safety mirrors installer-migrations 003
|
|
* (rename-get-shit-done-to-gsd-core): only files present, and unchanged or
|
|
* locally modified, in the PRIOR install manifest under the legacy
|
|
* `command/<file>` key are removed here — the materialization call
|
|
* immediately following writes the current command set fresh into the new
|
|
* location, so removing the stale copies is safe. Anything not proven
|
|
* manifest-managed (unrelated user content someone dropped into `command/`)
|
|
* is left untouched, never deleted. The emptied legacy directory is removed
|
|
* only once nothing else is left inside it.
|
|
*
|
|
* Implemented as inline pre-materialization cleanup rather than a
|
|
* `src/installer-migrations/*.cts` record: the formal migrations framework
|
|
* only ever DELETES individual files (never directories, and never a
|
|
* relocate/move primitive — see docs/installer-migrations.md's Action
|
|
* Types), so the empty-directory removal below would need this same
|
|
* hand-written glue regardless. It also intentionally is NOT reachable via
|
|
* combinedFamilyInstall's early return above `_runLegacyInstallMigrations`,
|
|
* matching the existing precedent that OpenCode/Kilo's bespoke install path
|
|
* owns its own legacy cleanup rather than routing through the generic
|
|
* layout-driven migrations hook.
|
|
*/
|
|
function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, behaviors: any): void {
|
|
const LEGACY_NAME = 'command';
|
|
const currentName = behaviors.flatCommandDir || LEGACY_NAME;
|
|
if (currentName === LEGACY_NAME) return; // e.g. Kilo — legacy IS the current location; nothing to migrate
|
|
const legacyDir = path.join(configDir, LEGACY_NAME);
|
|
if (!fs.existsSync(legacyDir)) return;
|
|
// Never follow a symlinked legacy dir out of configDir.
|
|
if (fs.lstatSync(legacyDir).isSymbolicLink()) return;
|
|
|
|
const manifest = installerMigrations.readInstallManifest(configDir);
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(legacyDir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
// command/ is a flat directory of gsd-*.md files; skip anything that
|
|
// isn't a plain file (nested dirs, symlinks) rather than guess intent.
|
|
if (!entry.isFile()) continue;
|
|
const relPath = `${LEGACY_NAME}/${entry.name}`;
|
|
const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
|
|
if (classification === 'managed-pristine' || classification === 'managed-modified') {
|
|
try { fs.unlinkSync(path.join(legacyDir, entry.name)); } catch { /* best-effort */ }
|
|
}
|
|
// 'unknown' (not manifest-tracked) is left untouched — GSD cannot prove
|
|
// ownership, so it must never be deleted as collateral damage.
|
|
}
|
|
|
|
try {
|
|
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
|
|
} catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// installOpencodeFamilyArtifacts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087,
|
|
* #2093). Stages the flattened commands surface + skills surface + (any
|
|
* runtime whose hostBehaviors declares `nativePlugin` — OpenCode and, since
|
|
* #2093, Kilo) native plugin adapter, mirroring the bespoke `else if (isOpencode ||
|
|
* isKilo)` block previously inlined in bin/install.js.
|
|
*
|
|
* @param runtime - 'opencode' or 'kilo'
|
|
* @param configDir - resolved runtime config directory
|
|
* @param scope - install scope ('global' | 'local')
|
|
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
|
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
|
|
* @param capabilityRegistry - #2362: optional composed capability registry
|
|
* (capabilityClusters view), threaded straight through to
|
|
* installOpencodeFamilySkills so an installed third-party capability skill
|
|
* materializes for this combined-family (OpenCode/Kilo) install path too.
|
|
* Absent -> no third-party skills staged (fail closed).
|
|
*/
|
|
function installOpencodeFamilyArtifacts(
|
|
runtime: string,
|
|
configDir: string,
|
|
scope: string,
|
|
resolvedProfile: any,
|
|
resolveAttribution: ResolveAttribution = () => undefined,
|
|
behaviors: any = {},
|
|
capabilityRegistry?: any,
|
|
): void {
|
|
const isGlobal = scope === 'global';
|
|
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
|
// (via the .gsd-source marker or a walk-up from __dirname) — every other
|
|
// call site in runtime-artifact-layout.cts feeds its return value straight
|
|
// into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
|
|
// root (needed below for the native plugin source) is two levels up.
|
|
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
|
const src = path.dirname(path.dirname(commandsGsdDir));
|
|
const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
|
|
|
|
const pathPrefix = (runtimeArtifactConversion as any)._computePathPrefix({
|
|
isGlobal,
|
|
isOpencode: behaviors.skipHomePrefixSubstitution === true,
|
|
isWindowsHost: process.platform === 'win32',
|
|
resolvedTarget: posixNormalize(path.resolve(configDir)),
|
|
homeDir: posixNormalize(os.homedir()),
|
|
});
|
|
|
|
// #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir
|
|
// descriptor value read by writeManifest's manifest-key prefix and by
|
|
// resolveRuntimeArtifactLayout's commands-kind destSubpath — a hardcoded
|
|
// literal here would silently diverge from the descriptor the moment either
|
|
// is edited (Generative Fix Divergence guard). OpenCode uses 'commands'
|
|
// (plural, the dir OpenCode actually discovers slash commands from); Kilo
|
|
// keeps its own descriptor value ('command', singular) unchanged.
|
|
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(
|
|
configDir,
|
|
behaviors.flatCommandDir || 'command',
|
|
);
|
|
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
|
|
|
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// uninstallRuntimeArtifacts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Layout-driven uninstall orchestrator.
|
|
* Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
|
|
* determine which GSD-owned entries to remove.
|
|
*
|
|
* @param runtime canonical runtime ID
|
|
* @param configDir resolved runtime config directory
|
|
* @param scope
|
|
*/
|
|
function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: string): void {
|
|
// A retired descriptor kind is absent from the current uninstall plan, just
|
|
// as it is absent from the install plan. Sweep manifest-proven output from
|
|
// retired kinds before removing the current layout so a direct uninstall
|
|
// cannot leave stale runtime surfaces behind (#2644).
|
|
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
|
|
|
// 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: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as any);
|
|
const plan: any = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
|
|
const kindsByName = new Map<string, any>(layout.kinds.map((kind: any) => [kind.kind as string, kind]));
|
|
for (const item of plan.items) {
|
|
const kind: any = 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);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export = {
|
|
installRuntimeArtifacts,
|
|
uninstallRuntimeArtifacts,
|
|
installOpencodeFamilySkills,
|
|
installOpencodeFamilyCommands,
|
|
installOpencodeFamilyArtifacts,
|
|
_installNativePluginIfDeclared,
|
|
_hostBehaviors,
|
|
_copyStaged,
|
|
hasExistingSymlinkBetween,
|
|
isSymlinkedDestOptIn,
|
|
preserveUserArtifacts,
|
|
restoreUserArtifacts,
|
|
migrateLegacyDevPreferencesToSkill,
|
|
applyOpencodeFamilyPathPrefix,
|
|
convertClaudeCommandToOpencodeSkill,
|
|
convertClaudeCommandToKiloSkill,
|
|
USER_OWNED_ARTIFACTS,
|
|
_runLegacyInstallMigrations,
|
|
_runLegacyUninstallCleanup,
|
|
_removeGsdEntries,
|
|
_snapshotDir,
|
|
_restoreDir,
|
|
_removeHermesBareStemDirs,
|
|
};
|