Merge pull request #3667 from gsd-build/feat/3663-runtime-artifact-layout-module-phase-1-m

Runtime Artifact Layout Module — Phase 1: module + surface.cjs migration
This commit is contained in:
Tom Boucher
2026-05-17 01:46:22 -04:00
committed by GitHub
14 changed files with 1356 additions and 352 deletions

View File

@@ -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)

View File

@@ -11436,6 +11436,7 @@ if (process.env.GSD_TEST_MODE) {
rewriteLegacyManagedNodeHookCommands,
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
readGsdCommandNames,
};
} else {

View File

@@ -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 `<name>`. 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 `<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 `<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.

View File

@@ -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",

View File

@@ -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-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (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) |

View File

@@ -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-<stem>/SKILL.md`, so `gsd-surface profile <name>` 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.

View File

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

View File

@@ -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 <runtimeConfigDir>/.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 <runtimeConfigDir>/.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 };

View File

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

View File

@@ -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 <stem>/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 <prefix><stem>/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`);
}
}
});

View File

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

View File

@@ -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');
});
});

View File

@@ -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-<stem>/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-<stem>
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}`);
}
});
});

View File

@@ -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');
});
});