diff --git a/.changeset/swift-otter-pebble.md b/.changeset/swift-otter-pebble.md new file mode 100644 index 000000000..7eff59099 --- /dev/null +++ b/.changeset/swift-otter-pebble.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3663 +--- +**`gsd-surface profile` no longer leaves stale skill directories on disk** — `applySurface` migrates to a typed per-runtime artifact layout, so switching profiles now correctly prunes `skills/gsd-*/` directories across every runtime that materializes them (Claude global, Codex, Cursor, Windsurf, Trae, CodeBuddy, Copilot, Antigravity, Hermes, Qwen). Closes the structural gap behind #3659. Internal: introduces the Runtime Artifact Layout Module (`get-shit-done/bin/lib/runtime-artifact-layout.cjs`); `applySurface(runtimeConfigDir, layout, manifest, clusterMap)` is the new signature for internal callers. (#3663) diff --git a/bin/install.js b/bin/install.js index 6f50db6eb..ea070b47b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -11436,6 +11436,7 @@ if (process.env.GSD_TEST_MODE) { rewriteLegacyManagedNodeHookCommands, buildCodexHookBlock, rewriteLegacyCodexHookBlock, + readGsdCommandNames, }; } else { diff --git a/commands/gsd/surface.md b/commands/gsd/surface.md index 1da042762..93aa3c45a 100644 --- a/commands/gsd/surface.md +++ b/commands/gsd/surface.md @@ -64,7 +64,11 @@ Install profile: standard (from .gsd-profile) 1. Read current surface: `readSurface(runtimeConfigDir)` → if null, seed from `readActiveProfile(runtimeConfigDir)`. 2. Set `surfaceState.baseProfile = name`. 3. `writeSurface(runtimeConfigDir, surfaceState)`. -4. Resolve and re-apply: `applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS)`. +4. Resolve and re-apply: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 5. Confirm: "Surface updated to profile ``. N skills enabled." --- @@ -77,7 +81,11 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 1. Validate cluster name against `Object.keys(CLUSTERS)`. 2. Read or initialize surface state. 3. Add cluster to `surfaceState.disabledClusters` (deduplicate). -4. `writeSurface` → `applySurface`. +4. `writeSurface` → resolve layout → `applySurface`: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 5. Confirm: "Disabled cluster ``. N skills removed from surface." --- @@ -86,7 +94,11 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 1. Read surface state; if null, nothing to enable — print "No surface delta active." 2. Remove cluster from `surfaceState.disabledClusters`. -3. `writeSurface` → `applySurface`. +3. `writeSurface` → resolve layout → `applySurface`: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 4. Confirm: "Enabled cluster ``. N skills added back to surface." --- @@ -106,9 +118,9 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` # Claude Code RUNTIME_CONFIG_DIR=~/.claude/skills -# Resolve commandsDir and agentsDir -COMMANDS_DIR=~/.claude/commands/gsd -AGENTS_DIR=~/.claude/agents +# Artifact destinations are derived from runtime layout +# via resolveRuntimeArtifactLayout(runtime, RUNTIME_CONFIG_DIR, scope) +# then applySurface(RUNTIME_CONFIG_DIR, layout, manifest, CLUSTERS) ``` All paths can be overridden by reading the `CLAUDE_CONFIG_DIR` env var if set. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 7c662d9cd..d68b5dca8 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-15", + "generated": "2026-05-17", "families": { "agents": [ "gsd-advisor-researcher", @@ -305,6 +305,7 @@ "review-reviewer-selection.cjs", "roadmap-command-router.cjs", "roadmap.cjs", + "runtime-artifact-layout.cjs", "runtime-homes.cjs", "runtime-slash.cjs", "schema-detect.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 85dfd6d0e..b5b25061a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (70 shipped) +## CLI Modules (71 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -413,6 +413,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | +| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | | `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) | | `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | | `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) | diff --git a/docs/adr/3660-runtime-artifact-layout-module.md b/docs/adr/3660-runtime-artifact-layout-module.md index 541610710..b3001090d 100644 --- a/docs/adr/3660-runtime-artifact-layout-module.md +++ b/docs/adr/3660-runtime-artifact-layout-module.md @@ -1,8 +1,9 @@ # Runtime Artifact Layout Module owns per-runtime artifact placement -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-05-17 - **Issue:** #3660 +- **Implementation:** #3663 (Phase 1), feat/3663-runtime-artifact-layout-module-phase-1-m The **Runtime Surface Module** (`get-shit-done/bin/lib/surface.cjs`, introduced by ADR-0011 Phase 2) re-materializes a resolved Skill Surface profile to disk via `applySurface`. It currently hardcodes two artifact kinds (`commands`, `agents`) and re-derives their source directories via `_findInstallSource` / `_findAgentsSource` walk-up heuristics. The install and uninstall pipelines in `bin/install.js` each encode the same per-runtime artifact layout independently across ~14 install sites and ~6 uninstall sites. Bug #3659 surfaced the resulting drift: `applySurface` omits the `skills` kind for runtimes whose canonical layout is `skills/gsd-/SKILL.md`, so `gsd-surface profile ` leaves ~67 skill directories on disk under the install-time profile's footprint when the resolved profile should have pruned them — roughly 2.7k tokens per session on a measured workstation. @@ -17,7 +18,7 @@ The root problem is the absence of a typed seam for "where does runtime R put ar - The `kinds` array is empty for runtimes with no GSD surface (a hypothetical future runtime with no integration). The `skills` kind is **absent** for runtimes that don't materialize skill directories (Cline; Gemini today). The `commands` kind is **absent** for runtimes that consume only the skills/agents layout (Claude global, Codex, etc.). - Per-runtime quirks live in the layout's record fields, not in caller branches: - **Hermes**: `{ kind: 'skills', destSubpath: 'skills/gsd', prefix: '' }` — preserves the nested namespace from #2841. - - **Cline**: `kinds: [ { kind: 'commands', … } ]` — no skills kind in the array. + - **Cline**: `kinds: []` — Cline resolves to zero kinds in Phase 1 (no `commands` kind). - **Gemini**: `kinds: [ { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' } ]` — no agents, no skills. - `applySurface` migrates from `(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)` to `(runtimeConfigDir, layout, manifest, clusterMap)`. Body collapses to `for (const kind of layout.kinds) _syncGsdDir(kind.stage(resolved), path.join(layout.configDir, kind.destSubpath), kind.kind)`. - `_findInstallSource` and `_findAgentsSource` in `surface.cjs` are removed. The layout owns source resolution. @@ -134,3 +135,14 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { - Existing canonical sibling: `get-shit-done/bin/lib/runtime-homes.cjs` - Per-runtime skill converters this module references: `bin/install.js:1622` (Copilot), `:1681` (Claude), `:1792` (Antigravity), `:2534` (Codex) - Hermes nested-skills layout rationale: `#2841` + +## Implementation status + +Phase 1 implementation landed on `feat/3663-runtime-artifact-layout-module-phase-1-m`: +- `get-shit-done/bin/lib/runtime-artifact-layout.cjs` — 15-runtime layout table (grok intentionally excluded), `resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout`, walk-up `findInstallSourceRoot` helper. +- Clarification: in this Phase 1 implementation, **Cline resolves to zero kinds** (`kinds: []`), so it carries no `commands` kind in the layout table. +- `get-shit-done/bin/lib/install-profiles.cjs` — new `stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) → stagedDir` helper. +- `get-shit-done/bin/lib/surface.cjs` — `applySurface(runtimeConfigDir, layout, manifest, clusterMap)` signature migration; `_findInstallSource` + `_findAgentsSource` deleted; `_syncGsdDir` extended to handle the `skills` kind via directory iteration. +- Tests: `runtime-artifact-layout-resolve.test.cjs` (16), `runtime-artifact-layout-edge-cases.test.cjs` (10), `runtime-artifact-layout-stage.test.cjs` (5), `install-profiles-stage.test.cjs` (+7 new), `surface-apply.test.cjs` (updated 5 call sites + new skills-kind test). + +Phase 2 (separate issue #3664 — `bin/install.js` install/uninstall pipeline migration) is blocked on Phase 1 merge. diff --git a/get-shit-done/bin/lib/install-profiles.cjs b/get-shit-done/bin/lib/install-profiles.cjs index 68c961eae..8e6035c57 100644 --- a/get-shit-done/bin/lib/install-profiles.cjs +++ b/get-shit-done/bin/lib/install-profiles.cjs @@ -373,6 +373,33 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) { return stageDir; } +function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) { + if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir; + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-')); + try { + const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + if (resolvedProfile.skills !== '*' && !resolvedProfile.skills.has(stem)) continue; + const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'); + const skillName = `${prefix}${stem}`; + const converted = converter(content, skillName); + const destDir = path.join(stageDir, skillName); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted); + } + } catch (err) { + try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} + throw err; + } + STAGED_DIRS.add(stageDir); + ensureExitCleanup(); + return stageDir; +} + // --------------------------------------------------------------------------- // Profile marker persistence // --------------------------------------------------------------------------- @@ -560,6 +587,8 @@ module.exports = { mostRestrictiveProfile, stageSkillsForProfile, stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, + STAGED_DIRS, readActiveProfile, writeActiveProfile, // Shared internals diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs new file mode 100644 index 000000000..f87eb4cdd --- /dev/null +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -0,0 +1,301 @@ +'use strict'; + +/** + * Runtime artifact layout module — resolves the artifact directory shapes + * (commands, agents, skills) for each supported runtime. + * + * grok is intentionally absent: it is in runtime-homes.cjs but not wired + * here. The TypeError on unknown runtime is the loud-fail signal that a + * runtime was added to the homes list without a layout entry. + */ + +const path = require('path'); +const fs = require('fs'); + +const { + stageSkillsForProfile, + stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, +} = require('./install-profiles.cjs'); + +// --------------------------------------------------------------------------- +// Lazy installer exports (avoids GSD_TEST_MODE env mutation at module load) +// --------------------------------------------------------------------------- + +/** + * Load bin/install.js exports in a test-safe way. + * Sets GSD_TEST_MODE only for the duration of the require() call and only if + * it was not already set, restoring the original value in a finally block so + * the module-level environment is never permanently mutated. + */ +function loadInstallExports() { + const savedTestMode = process.env.GSD_TEST_MODE; + if (savedTestMode === undefined) process.env.GSD_TEST_MODE = '1'; + try { + // eslint-disable-next-line global-require -- lazy import to avoid running installer at module load + return require('../../../bin/install.js'); + } finally { + if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = savedTestMode; + } +} + +/** Cache after first successful load. */ +let _installExports = null; +function getInstallExports() { + if (!_installExports) _installExports = loadInstallExports(); + return _installExports; +} + +/** + * @typedef {'commands'|'agents'|'skills'} ArtifactKindName + * @typedef {Object} ArtifactKind + * @property {ArtifactKindName} kind + * @property {string} destSubpath + * @property {string} prefix + * @property {(resolvedProfile: Object) => string} stage + * @typedef {Object} Layout + * @property {string} runtime + * @property {string} configDir + * @property {ArtifactKind[]} kinds + */ + +// --------------------------------------------------------------------------- +// Source root finders +// --------------------------------------------------------------------------- + +/** + * Locate the GSD commands/gsd source directory. + * + * Resolution order: + * 1. If runtimeConfigDir provided, check /.gsd-source marker. + * 2. Walk up from __dirname using path.dirname (no literal .. segments). + * 3. Throw a descriptive error if neither succeeds. + * + * @param {string} [runtimeConfigDir] optional runtime config directory + * @returns {string} + */ +function findInstallSourceRoot(runtimeConfigDir) { + // Step 1: marker check + if (runtimeConfigDir) { + const markerPath = path.join(runtimeConfigDir, '.gsd-source'); + if (fs.existsSync(markerPath)) { + try { + const src = fs.readFileSync(markerPath, 'utf8').trim(); + if (src && fs.existsSync(src)) return src; + } catch { /* fall through */ } + } + } + + // Step 2: walk up from __dirname + let dir = __dirname; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'commands', 'gsd'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + throw new Error(`findInstallSourceRoot: could not locate commands/gsd from ${__dirname}`); +} + +/** + * Locate the GSD agents source directory. + * + * Resolution order: + * 1. If runtimeConfigDir provided, check /.gsd-source marker. + * 2. Walk up from __dirname using path.dirname (no literal .. segments). + * 3. Throw a descriptive error if neither succeeds. + * + * @param {string} [runtimeConfigDir] optional runtime config directory + * @returns {string} + */ +function findAgentsSourceRoot(runtimeConfigDir) { + // Step 1: marker check + if (runtimeConfigDir) { + const markerPath = path.join(runtimeConfigDir, '.gsd-source'); + if (fs.existsSync(markerPath)) { + try { + const src = fs.readFileSync(markerPath, 'utf8').trim(); + if (src && fs.existsSync(src)) { + // Marker points to commands/gsd; agents/ is a sibling of commands/ + const agentsCandidate = path.resolve(path.dirname(src), '..', 'agents'); + if (fs.existsSync(agentsCandidate)) return agentsCandidate; + } + } catch { /* fall through */ } + } + } + + // Step 2: walk up from __dirname + let dir = __dirname; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'agents'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`); +} + +// --------------------------------------------------------------------------- +// Allowlisted runtimes +// --------------------------------------------------------------------------- + +const ALLOWED_RUNTIMES = new Set([ + 'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity', + 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', + 'cline', 'opencode', 'kilo', +]); + +// --------------------------------------------------------------------------- +// Layout table builders +// --------------------------------------------------------------------------- + +function commandsKind(destSubpath, prefix, configDir) { + return { + kind: 'commands', + destSubpath, + prefix, + stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(configDir), resolved), + }; +} + +function agentsKind(destSubpath, prefix, configDir) { + return { + kind: 'agents', + destSubpath, + prefix, + stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved), + }; +} + +/** + * Build a skills kind descriptor. + * + * @param {string} destSubpath + * @param {string} prefix + * @param {string} converterName name of converter function in bin/install.js exports + * @param {string} runtime canonical runtime ID (gates Hermes/Qwen branding in converter) + * @param {string} configDir runtime config dir (for .gsd-source marker resolution) + */ +function skillsKind(destSubpath, prefix, converterName, runtime, configDir) { + return { + kind: 'skills', + destSubpath, + prefix, + stage: (resolved) => { + const installExports = getInstallExports(); + const realConverter = installExports[converterName]; + // Compute cmdNames once per stage call for performance (#3583). + // Extra args are ignored by converters that don't need runtime/cmdNames. + const cmdNames = installExports.readGsdCommandNames(); + const wrappedConverter = (content, skillName) => + realConverter(content, skillName, runtime, cmdNames); + return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix); + }, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolve the artifact layout for a given runtime and config directory. + * + * @param {string} runtime + * @param {string} configDir + * @param {'local'|'global'} [scope] + * @returns {Layout} + */ +function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { + if (typeof configDir !== 'string' || configDir === '') { + throw new TypeError('configDir must be a non-empty string'); + } + if (scope !== 'local' && scope !== 'global') { + throw new TypeError('scope must be "local" or "global"'); + } + if (!ALLOWED_RUNTIMES.has(runtime)) { + throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); + } + + let kinds; + switch (runtime) { + case 'claude': + if (scope === 'local') { + kinds = [ + commandsKind('commands/gsd', 'gsd-', configDir), + agentsKind('agents', 'gsd-', configDir), + ]; + } else { + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)]; + } + break; + + case 'cursor': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)]; + break; + + case 'gemini': + kinds = [commandsKind('commands/gsd', 'gsd-', configDir)]; + break; + + case 'codex': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', 'codex', configDir)]; + break; + + case 'copilot': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', 'copilot', configDir)]; + break; + + case 'antigravity': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)]; + break; + + case 'windsurf': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', 'windsurf', configDir)]; + break; + + case 'augment': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)]; + break; + + case 'trae': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)]; + break; + + case 'qwen': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)]; + break; + + case 'hermes': + kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)]; + break; + + case 'codebuddy': + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir)]; + break; + + case 'cline': + kinds = []; + break; + + case 'opencode': + kinds = [commandsKind('command', 'gsd-', configDir)]; + break; + + case 'kilo': + kinds = [commandsKind('command', 'gsd-', configDir)]; + break; + + default: + throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); + } + + return { runtime, configDir, kinds }; +} + +module.exports = { resolveRuntimeArtifactLayout, findInstallSourceRoot }; diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index 00168c5ce..3ca70d71d 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -13,8 +13,8 @@ * readSurface(runtimeConfigDir) * writeSurface(runtimeConfigDir, surfaceState) * resolveSurface(runtimeConfigDir, manifest, clusterMap) - * applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) - * listSurface(runtimeConfigDir, manifest, clusterMap) + * applySurface(runtimeConfigDir, layout, manifest, clusterMap) + * listSurface(runtimeConfigDir, layout, manifest, clusterMap) */ const fs = require('fs'); @@ -31,6 +31,7 @@ const { PROFILES, } = require('./install-profiles.cjs'); const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs'); +const { findInstallSourceRoot } = require('./runtime-artifact-layout.cjs'); const SURFACE_FILE_NAME = '.gsd-surface.json'; @@ -194,146 +195,125 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) { // --------------------------------------------------------------------------- /** - * Re-stage the active surface to commandsDir and agentsDir in-place. - * Only touches files matching `gsd-` prefix or `*.md` in commandsDir. - * Never touches non-`gsd-*` files. - * - * Steps: - * 1. Resolve surface → active skill/agent sets - * 2. Stage to temp dirs via stageSkillsForProfile / stageAgentsForProfile - * 3. Find the install source (where skill files live) - * 4. Sync: copy missing, delete superseded (gsd-only) + * Re-stage the active surface using the resolved layout. + * Iterates layout.kinds and syncs each artifact kind to its destination. * * @param {string} runtimeConfigDir - * @param {string} commandsDir runtime commands/gsd dir (resolved per-runtime by callers) - * @param {string} agentsDir runtime agents dir (resolved per-runtime by callers) + * @param {import('./runtime-artifact-layout.cjs').Layout} layout * @param {Map} manifest * @param {Object} [clusterMap] */ -function applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) { - const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap); - - // Find install source - const srcCommandsDir = _findInstallSource(runtimeConfigDir); - - // Stage skills - const stagedSkills = stageSkillsForProfile(srcCommandsDir, resolved); - - // Sync commandsDir from stagedSkills - _syncGsdDir(stagedSkills, commandsDir, 'commands'); - - // Stage and sync agents - if (agentsDir && fs.existsSync(agentsDir)) { - const srcAgentsDir = _findAgentsSource(runtimeConfigDir); - if (srcAgentsDir) { - const stagedAgents = stageAgentsForProfile(srcAgentsDir, resolved); - _syncGsdDir(stagedAgents, agentsDir, 'agents'); - } +function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { + if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) { + throw new TypeError('applySurface runtimeConfigDir must match layout.configDir'); } + const resolved = resolveSurface(layout.configDir, manifest, clusterMap); + for (const kind of layout.kinds) { + const staged = kind.stage(resolved); + const dest = path.join(layout.configDir, kind.destSubpath); + _syncGsdDir(staged, dest, kind, manifest); + } + return resolved; } /** * Sync destination directory from staged source. - * Adds files present in staged but missing in dest. - * Removes gsd-prefixed .md files in dest not present in staged. - * Never touches non-gsd files. + * + * For 'commands' kind: iterate *.md files in destDir, remove if not in staged set. + * For 'agents' kind: same, but only remove files starting with 'gsd-' prefix. + * For 'skills' kind: iterate directories in destDir matching kind.prefix; add missing + * by copying recursively; remove dirs not in staged set. Preserves dirs not matching + * the prefix (user-owned skills). + * + * For Hermes (empty prefix): uses manifest membership to discriminate GSD-owned vs + * user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND + * not in staged set. User-owned (not in manifest) are always preserved. * * @param {string} stagedDir source (staged temp dir or original) * @param {string} destDir runtime destination - * @param {'commands'|'agents'} context + * @param {import('./runtime-artifact-layout.cjs').ArtifactKind|'commands'|'agents'} kind + * @param {Map} [manifest] optional; required for Hermes empty-prefix removal */ -function _syncGsdDir(stagedDir, destDir, context) { +function _syncGsdDir(stagedDir, destDir, kind, manifest) { if (!fs.existsSync(stagedDir)) return; fs.mkdirSync(destDir, { recursive: true }); - const stagedFiles = new Set( - fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')) - ); + // Normalize: allow legacy string context for backward-compat with internal callers + const kindName = (typeof kind === 'string') ? kind : kind.kind; + const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-'; - // Copy missing files from staged to dest - for (const file of stagedFiles) { - const destFile = path.join(destDir, file); - if (!fs.existsSync(destFile)) { - fs.copyFileSync(path.join(stagedDir, file), destFile); - } else { - // Overwrite to ensure content is current - fs.copyFileSync(path.join(stagedDir, file), destFile); - } - } + if (kindName === 'skills') { + // Skills kind: work with directories, not files. + // Each staged entry is a directory named ${prefix}${stem}. + const stagedDirs = new Set( + fs.readdirSync(stagedDir).filter(entry => { + return fs.statSync(path.join(stagedDir, entry)).isDirectory(); + }) + ); - // Remove gsd-only files from dest that aren't in staged set - // For commands dir: all .md files are gsd skills - // For agents dir: only gsd-* files - const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md')); - for (const file of destEntries) { - if (context === 'agents' && !file.startsWith('gsd-')) continue; - if (!stagedFiles.has(file)) { - try { fs.unlinkSync(path.join(destDir, file)); } catch {} - } - } -} - -/** - * Find the install source commands/gsd directory. - * Checks the runtime's `.gsd-source` marker (sibling of the surface state file), - * then walks up from __dirname to find the installed package source. - * - * @param {string} runtimeConfigDir - * @returns {string} path to install source commands/gsd - */ -function _findInstallSource(runtimeConfigDir) { - // Check for .gsd-source marker - const sourceMarker = path.join(runtimeConfigDir, '.gsd-source'); - if (fs.existsSync(sourceMarker)) { - try { - const src = fs.readFileSync(sourceMarker, 'utf8').trim(); - if (src && fs.existsSync(src)) return src; - } catch {} - } - - // Walk up from this module's dir to find commands/gsd - let dir = __dirname; - for (let i = 0; i < 6; i++) { - const candidate = path.join(dir, 'commands', 'gsd'); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - - // Fallback: the runtimeConfigDir itself - return path.join(runtimeConfigDir, '..', 'commands', 'gsd'); -} - -/** - * Find the install source agents directory. - * - * @param {string} runtimeConfigDir - * @returns {string|null} - */ -function _findAgentsSource(runtimeConfigDir) { - // Prefer .gsd-source sibling marker (commands/gsd) and derive agents from it. - const sourceMarker = path.join(runtimeConfigDir, '.gsd-source'); - if (fs.existsSync(sourceMarker)) { - try { - const commandsSrc = fs.readFileSync(sourceMarker, 'utf8').trim(); - if (commandsSrc && fs.existsSync(commandsSrc)) { - const commandsParent = path.dirname(commandsSrc); // .../commands - const candidate = path.resolve(commandsParent, '..', 'agents'); - if (fs.existsSync(candidate)) return candidate; + // Copy missing dirs from staged to dest + for (const dirName of stagedDirs) { + const destSubDir = path.join(destDir, dirName); + if (!fs.existsSync(destSubDir)) { + fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true }); + } else { + // Overwrite to ensure content is current + fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true }); } - } catch {} - } + } - let dir = __dirname; - for (let i = 0; i < 6; i++) { - const candidate = path.join(dir, 'agents'); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; + // Removal: discriminator depends on prefix shape. + // Non-empty prefix: GSD namespace IS the prefix; remove prefix-matching dirs not in staged set. + // Empty prefix (Hermes): GSD-owned = stem in manifest (i.e. canonically-shipped GSD skill). + // User-owned skills not in manifest are preserved. + // No manifest available: be conservative, don't remove anything. + const canonicalStems = manifest + ? new Set([...manifest.keys()].filter(k => !k.startsWith('_calls_agents_'))) + : null; + + const destEntries = fs.readdirSync(destDir); + for (const entry of destEntries) { + const entryPath = path.join(destDir, entry); + if (!fs.statSync(entryPath).isDirectory()) continue; + + let isGsdOwned; + if (kindPrefix !== '') { + isGsdOwned = entry.startsWith(kindPrefix); + } else if (canonicalStems) { + // Hermes: empty prefix, destSubpath is the namespace. + // GSD-owned iff the directory name (stem) appears in the canonical manifest. + isGsdOwned = canonicalStems.has(entry); + } else { + // No manifest available: be conservative, don't remove anything. + continue; + } + + if (!isGsdOwned) continue; // preserve user-owned + if (stagedDirs.has(entry)) continue; // current GSD-owned, keep + try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} + } + } else { + // commands / agents kind: work with .md files + const stagedFiles = new Set( + fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')) + ); + + // Copy files from staged to dest (overwrite to keep content current) + for (const file of stagedFiles) { + fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, file)); + } + + // Remove gsd-only files from dest that aren't in staged set + // For commands dir: all .md files are gsd skills + // For agents dir: only gsd-* files + const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md')); + for (const file of destEntries) { + if (kindName === 'agents' && !file.startsWith('gsd-')) continue; + if (!stagedFiles.has(file)) { + try { fs.unlinkSync(path.join(destDir, file)); } catch {} + } + } } - return null; } // --------------------------------------------------------------------------- @@ -344,7 +324,7 @@ function _findAgentsSource(runtimeConfigDir) { * List the currently enabled and disabled skills with token cost. * * Token cost = sum of description lengths ÷ 4 (mirrors audit script). - * Descriptions are read from the installed commandsDir skill files. + * Descriptions are read from the install source (findInstallSourceRoot). * * @param {string} runtimeConfigDir * @param {Map} manifest @@ -366,7 +346,7 @@ function listSurface(runtimeConfigDir, manifest, clusterMap) { const disabled = allStems.filter(s => !enabledSet.has(s)).sort(); // Compute token cost by reading descriptions from the install source - const srcCommandsDir = _findInstallSource(runtimeConfigDir); + const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir); let tokenCost = 0; for (const stem of enabled) { const filePath = path.join(srcCommandsDir, `${stem}.md`); @@ -393,6 +373,5 @@ module.exports = { applySurface, listSurface, // Exported for testing - _findInstallSource, _syncGsdDir, }; diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 861cced6d..3cbe32e94 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -7,21 +7,23 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { stageSkillsForProfile, stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, cleanupStagedSkills, resolveProfile, loadSkillsManifest, + STAGED_DIRS, } = require('../get-shit-done/bin/lib/install-profiles.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); function createFixtureSkillsDir() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-profile-')); + const tmp = createTempDir('gsd-stage-profile-'); for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) { fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); } @@ -29,120 +31,209 @@ function createFixtureSkillsDir() { } function createFixtureAgentsDir() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agents-profile-')); + const tmp = createTempDir('gsd-agents-profile-'); for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) { fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); } return tmp; } -describe('stageSkillsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', () => { - const src = createFixtureSkillsDir(); - try { - const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - } +describe('stageSkillsForRuntimeAsSkills', () => { + test('is exported as a function', () => { + assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); - test('profile with Set copies only member files', () => { - const src = createFixtureSkillsDir(); - let staged; - try { - const skills = new Set(['plan-phase', 'help', 'phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - assert.notStrictEqual(staged, src); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - if (staged) cleanupStagedSkills(); - } + test('registers stagedDir in STAGED_DIRS after staging', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + cleanupStagedSkills(); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); }); - test('preserves file content byte-for-byte', () => { - const src = createFixtureSkillsDir(); - const content = '# plan-phase special content\n\nsome body\n'; - fs.writeFileSync(path.join(src, 'plan-phase.md'), content); - let staged; - try { - const skills = new Set(['plan-phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); - assert.strictEqual(copied, content); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - if (staged) cleanupStagedSkills(); - } - }); - - test('non-existent srcDir returns srcDir unchanged', () => { - const ghost = path.join(os.tmpdir(), 'gsd-no-exist-' + Date.now()); - const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() }); + test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { + const ghost = path.join(require('os').tmpdir(), 'gsd-rta-no-exist-' + Date.now()); + const converter = (content, _skillName) => content; + const result = stageSkillsForRuntimeAsSkills(ghost, { skills: '*' }, converter, 'gsd-'); assert.strictEqual(result, ghost); }); - test('empty skills Set produces empty staged dir', () => { - const src = createFixtureSkillsDir(); - let staged; - try { - staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, []); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - if (staged) cleanupStagedSkills(); + test('empty prefix produces /SKILL.md without prefix segment', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); + const entries = fs.readdirSync(stagedDir); + assert.deepStrictEqual(entries, ['phase']); + const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); + assert.strictEqual(content, '# phase\n'); + }); + + test('converter is called with (content, skillName) for each kept skill', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); + const calls = []; + const converter = (content, skillName) => { + calls.push([content, skillName]); + return content; + }; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); + assert.strictEqual(calls.length, 2); + const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); + assert.strictEqual(callMap['x-alpha'], '# alpha\n'); + assert.strictEqual(callMap['x-beta'], '# beta\n'); + }); + + test('skills Set filters: only matching stems land in stagedDir', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'phase']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-phase']); + }); + + test('skills === "*" stages all md files as /SKILL.md', (t) => { + const src = createTempDir('gsd-rta-src-'); + let stagedDir; + t.after(() => { + cleanup(src); + if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'gamma']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); + for (const name of ['alpha', 'beta', 'gamma']) { + const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); + assert.strictEqual(content, `# ${name}\n`); } }); }); +describe('stageSkillsForProfile', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { + const src = createFixtureSkillsDir(); + t.after(() => cleanup(src)); + const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); + }); + + test('profile with Set copies only member files', (t) => { + const src = createFixtureSkillsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + const skills = new Set(['plan-phase', 'help', 'phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + assert.notStrictEqual(staged, src); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); + }); + + test('preserves file content byte-for-byte', (t) => { + const src = createFixtureSkillsDir(); + const content = '# plan-phase special content\n\nsome body\n'; + fs.writeFileSync(path.join(src, 'plan-phase.md'), content); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + const skills = new Set(['plan-phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); + assert.strictEqual(copied, content); + }); + + test('non-existent srcDir returns srcDir unchanged', () => { + const ghost = path.join(require('os').tmpdir(), 'gsd-no-exist-' + Date.now()); + const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() }); + assert.strictEqual(result, ghost); + }); + + test('empty skills Set produces empty staged dir', (t) => { + const src = createFixtureSkillsDir(); + let staged; + t.after(() => { + cleanup(src); + if (staged) cleanupStagedSkills(); + }); + staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, []); + }); +}); + describe('stageAgentsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { const src = createFixtureAgentsDir(); - try { - const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - } + t.after(() => cleanup(src)); + const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); }); - test('non-full profile with empty agents Set produces empty staged dir', () => { + test('non-full profile with empty agents Set produces empty staged dir', (t) => { const src = createFixtureAgentsDir(); let staged; - try { - staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); }); - test('non-full profile with agents Set copies only member agent files', () => { + test('non-full profile with agents Set copies only member agent files', (t) => { const src = createFixtureAgentsDir(); let staged; - try { - const agents = new Set(['gsd-planner']); - staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['gsd-planner.md']); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + const agents = new Set(['gsd-planner']); + staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['gsd-planner.md']); }); test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { - const ghost = path.join(os.tmpdir(), 'gsd-agents-no-exist-' + Date.now()); + const ghost = path.join(require('os').tmpdir(), 'gsd-agents-no-exist-' + Date.now()); const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() }); assert.strictEqual(result, ghost); }); - test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', () => { + test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', (t) => { // Uses the real agents dir and commands dir if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return; const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); @@ -150,24 +241,23 @@ describe('stageAgentsForProfile', () => { assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0, 'standard profile must have >0 agents (plan-phase calls gsd-planner etc)'); let staged; - try { - staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); - const stagedFiles = new Set( - fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) - ); - // Every file staged must be in resolved.agents - for (const stem of stagedFiles) { - assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); - } - // Every agent in resolved.agents that exists in the real dir must be staged - for (const agentStem of resolved.agents) { - const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); - if (exists) { - assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); - } - } - } finally { + t.after(() => { if (staged) cleanupStagedSkills(); + }); + staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); + const stagedFiles = new Set( + fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) + ); + // Every file staged must be in resolved.agents + for (const stem of stagedFiles) { + assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); + } + // Every agent in resolved.agents that exists in the real dir must be staged + for (const agentStem of resolved.agents) { + const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); + if (exists) { + assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); + } } }); diff --git a/tests/runtime-artifact-layout-edge-cases.test.cjs b/tests/runtime-artifact-layout-edge-cases.test.cjs new file mode 100644 index 000000000..c14b25c7e --- /dev/null +++ b/tests/runtime-artifact-layout-edge-cases.test.cjs @@ -0,0 +1,94 @@ +'use strict'; +/** + * Edge-case tests for resolveRuntimeArtifactLayout. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + +describe('hermes nested layout', () => { + test('hermes has destSubpath skills/gsd and empty prefix', () => { + const layout = resolveRuntimeArtifactLayout('hermes', '/tmp/x'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); + assert.strictEqual(layout.kinds[0].prefix, ''); + }); +}); + +describe('cline empty kinds', () => { + test('cline has no kinds', () => { + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 0); + }); +}); + +describe('gemini commands layout', () => { + test('gemini has one commands kind', () => { + const layout = resolveRuntimeArtifactLayout('gemini', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + }); +}); + +describe('claude scope=local has commands and agents', () => { + test('claude local has both commands and agents kinds', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'local'); + const kindNames = layout.kinds.map(k => k.kind); + assert.ok(kindNames.includes('commands'), 'should have commands kind'); + assert.ok(kindNames.includes('agents'), 'should have agents kind'); + }); +}); + +describe('claude scope=global has only skills', () => { + test('claude global has only skills kind', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); +}); + +describe('unknown runtime throws TypeError', () => { + test('grok throws TypeError containing "grok"', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('grok', '/tmp/x'), + (err) => { + assert.ok(err instanceof TypeError); + assert.ok(err.message.includes('grok'), 'error message must contain the runtime name'); + return true; + } + ); + }); + + test('xyzunknown throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('xyzunknown', '/tmp/x'), + TypeError + ); + }); +}); + +describe('invalid configDir throws TypeError', () => { + test('empty configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', ''), + TypeError + ); + }); + + test('non-string configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', null), + TypeError + ); + }); +}); + +describe('invalid scope throws TypeError', () => { + test('bad scope throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', '/x', 'invalid'), + TypeError + ); + }); +}); diff --git a/tests/runtime-artifact-layout-resolve.test.cjs b/tests/runtime-artifact-layout-resolve.test.cjs new file mode 100644 index 000000000..d5eeccbf4 --- /dev/null +++ b/tests/runtime-artifact-layout-resolve.test.cjs @@ -0,0 +1,219 @@ +'use strict'; +/** + * Tests for resolveRuntimeArtifactLayout — structural shape per runtime. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + +const FAKE_DIR = '/tmp/fake-config-dir'; + +describe('resolveRuntimeArtifactLayout — claude local', () => { + test('returns correct layout for claude scope=local', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); + assert.strictEqual(layout.runtime, 'claude'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — claude global', () => { + test('returns correct layout for claude scope=global', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + assert.strictEqual(layout.runtime, 'claude'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — cursor', () => { + test('returns correct layout for cursor', () => { + const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); + assert.strictEqual(layout.runtime, 'cursor'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — gemini', () => { + test('returns correct layout for gemini', () => { + const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR); + assert.strictEqual(layout.runtime, 'gemini'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — codex', () => { + test('returns correct layout for codex', () => { + const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR); + assert.strictEqual(layout.runtime, 'codex'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — copilot', () => { + test('returns correct layout for copilot', () => { + const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR); + assert.strictEqual(layout.runtime, 'copilot'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — antigravity', () => { + test('returns correct layout for antigravity', () => { + const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR); + assert.strictEqual(layout.runtime, 'antigravity'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — windsurf', () => { + test('returns correct layout for windsurf', () => { + const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR); + assert.strictEqual(layout.runtime, 'windsurf'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — augment', () => { + test('returns correct layout for augment', () => { + const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR); + assert.strictEqual(layout.runtime, 'augment'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — trae', () => { + test('returns correct layout for trae', () => { + const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR); + assert.strictEqual(layout.runtime, 'trae'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — qwen', () => { + test('returns correct layout for qwen', () => { + const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR); + assert.strictEqual(layout.runtime, 'qwen'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — hermes', () => { + test('returns correct layout for hermes', () => { + const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR); + assert.strictEqual(layout.runtime, 'hermes'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); + assert.strictEqual(layout.kinds[0].prefix, ''); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — codebuddy', () => { + test('returns correct layout for codebuddy', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR); + assert.strictEqual(layout.runtime, 'codebuddy'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — cline', () => { + test('returns correct layout for cline', () => { + const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR); + assert.strictEqual(layout.runtime, 'cline'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 0); + }); +}); + +describe('resolveRuntimeArtifactLayout — opencode', () => { + test('returns correct layout for opencode', () => { + const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); + assert.strictEqual(layout.runtime, 'opencode'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'command'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — kilo', () => { + test('returns correct layout for kilo', () => { + const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR); + assert.strictEqual(layout.runtime, 'kilo'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'command'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); diff --git a/tests/runtime-artifact-layout-stage.test.cjs b/tests/runtime-artifact-layout-stage.test.cjs new file mode 100644 index 000000000..b55cdb293 --- /dev/null +++ b/tests/runtime-artifact-layout-stage.test.cjs @@ -0,0 +1,109 @@ +'use strict'; +/** + * Tests for resolveRuntimeArtifactLayout kind.stage() invocations. + * Verifies each kind type produces the expected staged directory structure. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// A small resolved profile selecting known real skills +const CORE_SKILLS = new Set(['help', 'phase', 'new-project']); +const CORE_AGENTS = new Set(['gsd-planner']); +const PROFILE_CORE = { skills: CORE_SKILLS, agents: CORE_AGENTS }; +const PROFILE_FULL = { skills: '*', agents: new Set() }; + +const FAKE_DIR = '/tmp/fake-config-dir-stage'; + +describe('commands kind — stage (gemini)', () => { + test('stage returns a directory containing only the selected skill .md files', () => { + const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + // stagedDir is managed by stageSkillsForProfile — just verify, no manual cleanup needed + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + // All staged files must come from the selected skill set + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + // At least one file must be present (help.md exists in real commands/gsd) + assert.ok(entries.length >= 1, 'at least one skill file should be staged'); + }); +}); + +describe('agents kind — stage (claude local)', () => { + test('stage returns a directory containing only the selected agent .md files', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'should have an agents kind'); + + const stagedDir = agentsKind.stage(PROFILE_CORE); + // stagedDir may be empty (agents Set from core profile may be empty set) + // But it must be a valid directory + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + assert.ok(fs.statSync(stagedDir).isDirectory(), 'stagedDir must be a directory'); + }); +}); + +describe('skills kind — stage (claude global)', () => { + test('stage returns a directory containing gsd-/SKILL.md entries', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_CORE); + // managed by stageSkillsForRuntimeAsSkills — no manual cleanup needed + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + // Each entry should be a directory named gsd- + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + } + assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); + }); +}); + +describe('skills kind — stage with full profile', () => { + test('stage with skills="*" stages all commands/gsd/*.md as skills', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_FULL); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + // Full profile: should have many skills + assert.ok(entries.length > 10, `full profile should have many skills, got ${entries.length}`); + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + } + }); +}); + +describe('opencode commands kind — stage', () => { + test('opencode stage returns directory with .md files for selected skills', () => { + const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + }); +}); diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index 05152c6c7..eab1d488e 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -7,159 +7,310 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-apply-')); -} - /** - * Create a minimal fixture install dir structure. - * Returns { runtimeConfigDir, commandsDir, agentsDir }. - * runtimeConfigDir has a .gsd-source marker pointing to REAL_COMMANDS_DIR. + * Create a minimal fixture install dir structure for claude/local layout. + * runtimeConfigDir is the layout configDir. + * commandsDir = runtimeConfigDir/commands/gsd + * agentsDir = runtimeConfigDir/agents */ function createFixtureRuntime() { - const base = tmpDir(); - const runtimeConfigDir = path.join(base, 'config'); - const commandsDir = path.join(base, 'commands', 'gsd'); - const agentsDir = path.join(base, 'agents'); - fs.mkdirSync(runtimeConfigDir, { recursive: true }); + const base = createTempDir('gsd-surface-apply-'); + const runtimeConfigDir = base; + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + const agentsDir = path.join(runtimeConfigDir, 'agents'); fs.mkdirSync(commandsDir, { recursive: true }); fs.mkdirSync(agentsDir, { recursive: true }); - // Write source marker so surface.cjs can find the install source - fs.writeFileSync(path.join(runtimeConfigDir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); return { base, runtimeConfigDir, commandsDir, agentsDir }; } describe('applySurface', () => { - test('core profile: only core skills appear in commandsDir', () => { + test('core profile: only core skills appear in commandsDir', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - // Every file should be a real stem we know about - for (const file of files) { - assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); - } - // At minimum core skills should be present - const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; - for (const stem of coreStems) { - assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + // Every file should be a real stem we know about + for (const file of files) { + assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); } + // Core profile should materialize exactly the resolved core command set. + const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...files].sort(), + expectedCore, + 'commandsDir should contain exactly core commands' + ); }); - test('removes superseded files when profile shrinks', () => { + test('removes superseded files when profile shrinks', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Start with standard: put some skill files in commandsDir - writeActiveProfile(runtimeConfigDir, 'standard'); - writeSurface(runtimeConfigDir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + t.after(() => cleanup(base)); + // Start with standard: put some skill files in commandsDir + writeActiveProfile(runtimeConfigDir, 'standard'); + writeSurface(runtimeConfigDir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // Now switch to core: skills not in core should be removed - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + // Now switch to core: skills not in core should be removed + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // core should be a subset of standard - assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); + // core should be a subset of standard + assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - // Files removed should not be in core set - const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); - for (const file of afterCore) { - const stem = file.slice(0, -3); - assert.ok( - fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), - `file in commandsDir not a real skill: ${file}` - ); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); + // Core profile should materialize exactly the resolved core command set. + const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...afterCore].sort(), + expectedCore, + 'afterCore should contain exactly core commands' + ); - test('leaves non-gsd .md files alone in agentsDir', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Place a non-gsd agent file in agentsDir - const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); - fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); - - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); - - // Non-gsd file should still be there - assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('adds missing skill files from install source', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // commandsDir starts empty - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); - - // Core skills should now be present + // All files should still map to known real skills. + for (const file of afterCore) { assert.ok( - fs.existsSync(path.join(commandsDir, 'help.md')), - 'help.md should be copied from install source' + fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), + `file in commandsDir not a real skill: ${file}` ); - assert.ok( - fs.existsSync(path.join(commandsDir, 'new-project.md')), - 'new-project.md should be copied from install source' - ); - } finally { - fs.rmSync(base, { recursive: true, force: true }); } }); + + test('leaves non-gsd .md files alone in agentsDir', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // Place a non-gsd agent file in agentsDir + const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); + fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); + + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Non-gsd file should still be there + assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); + }); + + test('adds missing skill files from install source', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // commandsDir starts empty + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Core skills should now be present + assert.ok( + fs.existsSync(path.join(commandsDir, 'help.md')), + 'help.md should be copied from install source' + ); + assert.ok( + fs.existsSync(path.join(commandsDir, 'new-project.md')), + 'new-project.md should be copied from install source' + ); + }); + + test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + const { stageSkillsForRuntimeAsSkills } = require('../get-shit-done/bin/lib/install-profiles.cjs'); + const { findInstallSourceRoot } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + // Minimal converter that produces SKILL.md with given stem + function converter(stem, content) { + return [ + '---', + `name: ${stem}`, + '---', + content, + ].join('\n'); + } + + const base = createTempDir('gsd-surface-skills-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md + const stem1 = 'gsd-help'; + const stem2 = 'gsd-update'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); + + // In destDir: stale gsd- dir + foreign user dir + const staleDir = path.join(destDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); + + const foreignDir = path.join(destDir, 'my-custom-skill'); + fs.mkdirSync(foreignDir, { recursive: true }); + fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); + + const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, skillsKind); + + // staged dirs copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); + assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); + + // stale gsd- dir removed + assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); + + // foreign dir preserved + assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); + }); + + test('applySurface recreates missing destination directories', (t) => { + // Fixture: layout configDir exists but the dest subdirectory for the kinds does NOT. + const base = createTempDir('gsd-surface-missing-dest-'); + t.after(() => cleanup(base)); + const runtimeConfigDir = base; + // Do NOT pre-create commands/gsd or agents — they are intentionally absent. + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // commands/gsd must have been created and populated + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent'); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + assert.ok(files.length > 0, 'commands/gsd should contain staged skill files'); + assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); + }); + + test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-hermes-shrink-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Staged: only gsd-executor (profile shrunk — gsd-planner no longer in profile) + fs.mkdirSync(path.join(stagedDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + + // Dest already has: gsd-executor (keep), gsd-planner (stale GSD), user-skill (user-owned) + fs.mkdirSync(path.join(destDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'gsd-planner'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-planner', 'SKILL.md'), '# planner\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'user-skill'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'user-skill', 'SKILL.md'), '# user\n', 'utf8'); + + // Manifest contains gsd-executor and gsd-planner as canonical GSD skills. + // user-skill is NOT in manifest (user-owned). + const manifest = new Map([ + ['gsd-executor', []], + ['gsd-planner', []], + ]); + + // Hermes kind: empty prefix, destSubpath = skills/gsd + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, hermesKind, manifest); + + assert.ok( + fs.existsSync(path.join(destDir, 'gsd-executor', 'SKILL.md')), + 'gsd-executor should be kept (in staged set)' + ); + assert.ok( + !fs.existsSync(path.join(destDir, 'gsd-planner')), + 'gsd-planner should be removed (in manifest but not in staged set — stale GSD skill)' + ); + assert.ok( + fs.existsSync(path.join(destDir, 'user-skill', 'SKILL.md')), + 'user-skill should be preserved (not in manifest — user-owned)' + ); + }); + + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-hermes-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) + const stem1 = 'help'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + + // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) + const userDir = path.join(destDir, 'user-custom-skill'); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); + + // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, hermesKind); + + // The user's custom skill dir must be preserved — it's not in staged but should not be removed + // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) + assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); + + // The staged skill must still be copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); + }); });