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:
5
.changeset/swift-otter-pebble.md
Normal file
5
.changeset/swift-otter-pebble.md
Normal 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)
|
||||
@@ -11436,6 +11436,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
rewriteLegacyManagedNodeHookCommands,
|
||||
buildCodexHookBlock,
|
||||
rewriteLegacyCodexHookBlock,
|
||||
readGsdCommandNames,
|
||||
};
|
||||
} else {
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
301
get-shit-done/bin/lib/runtime-artifact-layout.cjs
Normal file
301
get-shit-done/bin/lib/runtime-artifact-layout.cjs
Normal 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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
94
tests/runtime-artifact-layout-edge-cases.test.cjs
Normal file
94
tests/runtime-artifact-layout-edge-cases.test.cjs
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
219
tests/runtime-artifact-layout-resolve.test.cjs
Normal file
219
tests/runtime-artifact-layout-resolve.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
109
tests/runtime-artifact-layout-stage.test.cjs
Normal file
109
tests/runtime-artifact-layout-stage.test.cjs
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user