feat(setup,gen-skill-docs): prune renders of skills that no longer exist

setup gains _prune_stale_generated for every host tree and the doc generator removes gstack-* output dirs it did not write, so a skill removed from the source tree can never linger in an install.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sina
2026-09-05 16:48:38 -04:00
co-authored by Claude Fable 5.1
parent 48d2b98059
commit 993692cd54
5 changed files with 231 additions and 4 deletions
+2 -2
View File
@@ -73,7 +73,7 @@ cd ~/.claude/skills/gstack && ./setup --team
``` ```
Skills like /qa, /ship, /review, /investigate, and /browse become available after install. Skills like /qa, /ship, /review, /investigate, and /browse become available after install.
Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.' Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback). Use ~/.claude/skills/gstack/... for gstack file paths.'
else else
SNIPPET='## gstack (REQUIRED — global install) SNIPPET='## gstack (REQUIRED — global install)
@@ -100,7 +100,7 @@ If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
Do not skip skills, ignore gstack errors, or work around missing gstack. Do not skip skills, ignore gstack errors, or work around missing gstack.
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
and /browse are available. Use /browse for all web browsing. and /browse are available. Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback).
Use the resolved install path above for gstack file paths Use the resolved install path above for gstack file paths
(default: ~/.claude/skills/gstack).' (default: ~/.claude/skills/gstack).'
fi fi
+26
View File
@@ -170,6 +170,12 @@ function parsePathFlag(flag: string): string | null {
} }
const OUT_DIR: string | null = parsePathFlag('--out-dir'); const OUT_DIR: string | null = parsePathFlag('--out-dir');
// External-host outputs rendered in THIS run, keyed by host. Used after the
// render to prune `gstack-*` output dirs whose skill no longer exists: the
// generator never deleted, so a retired skill stayed rendered (and linked by
// setup) forever, still reading config keys the DEFAULTS table had dropped.
const RENDERED_EXTERNAL: Map<string, Set<string>> = new Map();
// #2692: callers that render into a TMP dir and atomically swap it into place // #2692: callers that render into a TMP dir and atomically swap it into place
// (bin/gstack-config gbrain-refresh, setup — the #2569 pattern) must pass the // (bin/gstack-config gbrain-refresh, setup — the #2569 pattern) must pass the
// FINAL directory here, or rewriteSectionBase bakes the tmp path // FINAL directory here, or rewriteSectionBase bakes the tmp path
@@ -799,6 +805,8 @@ function processExternalHost(
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
// --out-dir mirrors the host tree (outputs only; inputs read from ROOT). // --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name); const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name);
if (!RENDERED_EXTERNAL.has(host)) RENDERED_EXTERNAL.set(host, new Set());
RENDERED_EXTERNAL.get(host)!.add(name);
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md'); const outputPath = path.join(outputDir, 'SKILL.md');
@@ -1166,6 +1174,24 @@ if (!DRY_RUN) {
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
} }
// Prune stale external-host outputs. A run always renders every skill for the
// chosen host(s) (there is no per-skill filter), so any `gstack-*` directory
// left in <host>/skills/ that this run did not write belongs to a skill that
// no longer exists. Symlinks (the `gstack` sidecar) and non-prefixed entries
// are never touched.
if (!DRY_RUN) {
for (const [host, names] of RENDERED_EXTERNAL) {
const skillsRoot = path.join(OUT_DIR ?? ROOT, getHostConfig(host as Host).hostSubdir, 'skills');
let entries: fs.Dirent[] = [];
try { entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
if (e.isSymbolicLink() || !e.isDirectory() || !e.name.startsWith('gstack-') || names.has(e.name)) continue;
fs.rmSync(path.join(skillsRoot, e.name), { recursive: true, force: true });
console.log(` pruned stale ${host} render: ${e.name}`);
}
}
}
// Regenerate gstack/llms.txt — single-file capability index for AI agents. // Regenerate gstack/llms.txt — single-file capability index for AI agents.
// Runs after SKILL.md generation so it sees current skill descriptions and // Runs after SKILL.md generation so it sees current skill descriptions and
// browse command list. Wrapped in an IIFE so the await-import doesn't make // browse command list. Wrapped in an IIFE so the await-import doesn't make
+52 -2
View File
@@ -134,6 +134,28 @@ _owned_for_windows_refresh() {
grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
} }
# ─── Helper: prune generated skill dirs whose source is gone ─────────────────
# gen-skill-docs writes .agents/.factory/.opencode/.cursor/skills but never
# deletes, so a skill removed from the source tree stays rendered — and the
# link loops below would re-link it into every host on every run.
# $1 = install root, $2 = generated tree, $3 = host skills dir (optional): the
# host's entry for a pruned name goes too,
# gated by _owned_for_windows_refresh so a user's own dir is never touched.
_prune_stale_generated() {
local gstack_dir="$1" gen_dir="$2" skills_dir="${3:-}" d n
for d in "$gen_dir"/gstack-*/; do
[ -d "$d" ] || continue
n="$(basename "$d")"
[ -f "$gstack_dir/${n#gstack-}/SKILL.md.tmpl" ] && continue
[ -f "$gstack_dir/$n/SKILL.md.tmpl" ] && continue
rm -rf "$d"
if [ -n "$skills_dir" ] && { [ -e "$skills_dir/$n" ] || [ -L "$skills_dir/$n" ]; } && _owned_for_windows_refresh "$skills_dir/$n"; then
rm -rf "$skills_dir/$n"
fi
echo " pruned retired skill: $n"
done
}
# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is # A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
# a real dir whose SKILL.md exists but lacks the generated banner — a # a real dir whose SKILL.md exists but lacks the generated banner — a
# hand-written skill squatting on the canonical name. The sidecar installers # hand-written skill squatting on the canonical name. The sidecar installers
@@ -175,6 +197,16 @@ _print_windows_copy_note_once() {
QUIET=0 QUIET=0
log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; } log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
# Aside (aside.com, macOS 15+) is the primary browser; the compiled browse
# binary is the fallback. Best-effort hint only — no probe of a running app.
_browser_hint() {
if command -v aside >/dev/null 2>&1; then
log " browser: Aside (primary) — gstack browser is the fallback"
else
log " browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)"
fi
}
# ─── Parse flags ────────────────────────────────────────────── # ─── Parse flags ──────────────────────────────────────────────
HOST="claude" HOST="claude"
LOCAL_INSTALL=0 LOCAL_INSTALL=0
@@ -617,11 +649,15 @@ if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt" ]; then
log "Source: $CODEX_GENERATION_MODEL_SOURCE" log "Source: $CODEX_GENERATION_MODEL_SOURCE"
fi fi
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) # 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock).
# One `bun run build` produces every binary (browse, design, make-pdf), so a
# missing or stale one of any of them triggers the whole build.
_EXE=""
if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi
NEEDS_BUILD=0 NEEDS_BUILD=0
if [ ! -x "$BROWSE_BIN" ]; then if [ ! -x "$BROWSE_BIN" ]; then
NEEDS_BUILD=1 NEEDS_BUILD=1
elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" "$SOURCE_GSTACK_DIR/make-pdf/src" "$SOURCE_GSTACK_DIR/design/src" "$SOURCE_GSTACK_DIR/lib/aside-render.ts" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then
NEEDS_BUILD=1 NEEDS_BUILD=1
elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then
NEEDS_BUILD=1 NEEDS_BUILD=1
@@ -729,6 +765,7 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL"
) )
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR"
fi fi
# 1c. Generate .factory/ Factory Droid skill docs # 1c. Generate .factory/ Factory Droid skill docs
@@ -1701,6 +1738,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
log "gstack ready (claude)." log "gstack ready (claude)."
fi fi
log " browse: $BROWSE_BIN" log " browse: $BROWSE_BIN"
_browser_hint
else else
# Not inside a skills/ directory — would symlink the source into # Not inside a skills/ directory — would symlink the source into
# ~/.claude/skills/gstack/ and register from there. # ~/.claude/skills/gstack/ and register from there.
@@ -1736,6 +1774,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
log "" log ""
log "gstack built (claude registration skipped)." log "gstack built (claude registration skipped)."
log " browse: $BROWSE_BIN" log " browse: $BROWSE_BIN"
_browser_hint
else else
mkdir -p "$CLAUDE_SKILLS_DIR" mkdir -p "$CLAUDE_SKILLS_DIR"
_link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK" _link_or_copy "$SOURCE_GSTACK_DIR" "$CLAUDE_GSTACK_LINK"
@@ -1769,6 +1808,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
_install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME" _install_alias_skill_md "$SOURCE_GSTACK_DIR/open-gstack-browser/SKILL.md" "$_OGB_LINK" "$_OGB_ALIAS_NAME"
log "gstack ready (claude)." log "gstack ready (claude)."
log " browse: $BROWSE_BIN" log " browse: $BROWSE_BIN"
_browser_hint
fi fi
fi fi
fi fi
@@ -1787,10 +1827,12 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then
create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK" create_codex_runtime_root "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
fi fi
# Install generated Codex-format skills (not Claude source dirs) # Install generated Codex-format skills (not Claude source dirs)
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.agents/skills" "$CODEX_SKILLS"
link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS" link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"
log "gstack ready (codex)." log "gstack ready (codex)."
log " browse: $BROWSE_BIN" log " browse: $BROWSE_BIN"
_browser_hint
log " codex skills: $CODEX_SKILLS" log " codex skills: $CODEX_SKILLS"
log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)" log " model profile: $CODEX_GENERATION_MODEL ($CODEX_GENERATION_MODEL_SOURCE)"
log " model changes: rerun ./setup --host codex" log " model changes: rerun ./setup --host codex"
@@ -1859,6 +1901,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
if [ ! -d "$AGENTS_DIR" ]; then if [ ! -d "$AGENTS_DIR" ]; then
echo " warning: no .agents/skills/ directory found — run 'bun run build' first" >&2 echo " warning: no .agents/skills/ directory found — run 'bun run build' first" >&2
else else
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$KIRO_SKILLS"
for skill_dir in "$AGENTS_DIR"/gstack*/; do for skill_dir in "$AGENTS_DIR"/gstack*/; do
[ -f "$skill_dir/SKILL.md" ] || continue [ -f "$skill_dir/SKILL.md" ] || continue
skill_name="$(basename "$skill_dir")" skill_name="$(basename "$skill_dir")"
@@ -1890,6 +1933,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
done done
echo "gstack ready (kiro)." echo "gstack ready (kiro)."
echo " browse: $BROWSE_BIN" echo " browse: $BROWSE_BIN"
_browser_hint
echo " kiro skills: $KIRO_SKILLS" echo " kiro skills: $KIRO_SKILLS"
fi fi
@@ -1904,9 +1948,11 @@ fi
if [ "$INSTALL_FACTORY" -eq 1 ]; then if [ "$INSTALL_FACTORY" -eq 1 ]; then
mkdir -p "$FACTORY_SKILLS" mkdir -p "$FACTORY_SKILLS"
create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK" create_factory_runtime_root "$SOURCE_GSTACK_DIR" "$FACTORY_GSTACK"
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.factory/skills" "$FACTORY_SKILLS"
link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS" link_factory_skill_dirs "$SOURCE_GSTACK_DIR" "$FACTORY_SKILLS"
echo "gstack ready (factory)." echo "gstack ready (factory)."
echo " browse: $BROWSE_BIN" echo " browse: $BROWSE_BIN"
_browser_hint
echo " factory skills: $FACTORY_SKILLS" echo " factory skills: $FACTORY_SKILLS"
fi fi
@@ -1914,9 +1960,11 @@ fi
if [ "$INSTALL_OPENCODE" -eq 1 ]; then if [ "$INSTALL_OPENCODE" -eq 1 ]; then
mkdir -p "$OPENCODE_SKILLS" mkdir -p "$OPENCODE_SKILLS"
create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK" create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.opencode/skills" "$OPENCODE_SKILLS"
link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS" link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
echo "gstack ready (opencode)." echo "gstack ready (opencode)."
echo " browse: $BROWSE_BIN" echo " browse: $BROWSE_BIN"
_browser_hint
echo " opencode skills: $OPENCODE_SKILLS" echo " opencode skills: $OPENCODE_SKILLS"
fi fi
@@ -1926,10 +1974,12 @@ if [ "$INSTALL_CURSOR" -eq 1 ]; then
create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK" create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK"
# Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which # Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which
# would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op. # would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op.
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/.cursor/skills" "$CURSOR_SKILLS"
link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS" link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS"
create_cursor_sidecar "$SOURCE_GSTACK_DIR" create_cursor_sidecar "$SOURCE_GSTACK_DIR"
echo "gstack ready (cursor)." echo "gstack ready (cursor)."
echo " browse: $BROWSE_BIN" echo " browse: $BROWSE_BIN"
_browser_hint
echo " cursor skills: $CURSOR_SKILLS" echo " cursor skills: $CURSOR_SKILLS"
fi fi
+36
View File
@@ -0,0 +1,36 @@
/**
* gen-skill-docs prunes stale external-host renders.
*
* The generator only ever wrote outputs, so a skill deleted from the source
* tree stayed rendered under every host's skills/ dir (and setup kept linking
* it). Now a render removes `gstack-*` dirs it did not write.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
describe('gen-skill-docs stale-render prune', () => {
test('a gstack-* dir for a skill that no longer exists is removed; the sidecar symlink and real skills stay', () => {
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prune-'));
const skills = path.join(out, '.agents', 'skills');
fs.mkdirSync(path.join(skills, 'gstack-retired-zzz'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack-retired-zzz', 'SKILL.md'), '---\nname: gstack-retired-zzz\n---\nstale\n');
fs.mkdirSync(path.join(skills, 'not-ours'), { recursive: true });
fs.symlinkSync(ROOT, path.join(skills, 'gstack'));
try {
const r = spawnSync('bun', ['run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--out-dir', out], { cwd: ROOT, encoding: 'utf-8', timeout: 180_000 });
expect(r.status).toBe(0);
expect(r.stdout).toContain('pruned stale codex render: gstack-retired-zzz');
expect(fs.existsSync(path.join(skills, 'gstack-retired-zzz'))).toBe(false);
expect(fs.existsSync(path.join(skills, 'not-ours'))).toBe(true);
expect(fs.lstatSync(path.join(skills, 'gstack')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(skills, 'gstack-ship', 'SKILL.md'))).toBe(true);
} finally {
fs.rmSync(out, { recursive: true, force: true });
}
}, 200_000);
});
+115
View File
@@ -0,0 +1,115 @@
/**
* setup: _prune_stale_generated — generated skill dirs whose source template
* is gone are pruned from the per-host render tree AND from the host's skills
* dir, so a skill removed from the source tree can't stay live on
* Codex/Factory/OpenCode/Cursor/Kiro after `./setup` re-links.
*
* gen-skill-docs never deletes stale out-dir entries; setup is the one place
* every host install passes through. Behavior fixture: extract the helper and
* its gate from setup, run it against a temp tree.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start, end + 2);
}
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n';
function mk(t: string) {
const src = path.join(t, 'src');
const gen = path.join(t, 'gen');
const host = path.join(t, 'host');
// Source templates: a flat skill and the one prefixed source (gstack-upgrade).
for (const s of ['qa', 'gstack-upgrade']) {
fs.mkdirSync(path.join(src, s), { recursive: true });
fs.writeFileSync(path.join(src, s, 'SKILL.md.tmpl'), 'x');
}
// Generated tree: live renders + two retired ones + the gstack sidecar.
for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack']) {
fs.mkdirSync(path.join(gen, g), { recursive: true });
fs.writeFileSync(path.join(gen, g, 'SKILL.md'), `${BANNER}# ${g}\n`);
}
fs.mkdirSync(host, { recursive: true });
// Host entries: symlink (Unix), bannered real copy (Windows/Kiro), user's own dir.
fs.symlinkSync(path.join(gen, 'gstack-qa') + '/', path.join(host, 'gstack-qa'));
fs.symlinkSync(path.join(gen, 'gstack-oldskill') + '/', path.join(host, 'gstack-oldskill'));
fs.mkdirSync(path.join(host, 'gstack-gone'));
fs.writeFileSync(path.join(host, 'gstack-gone', 'SKILL.md'), `${BANNER}copy\n`);
fs.mkdirSync(path.join(host, 'gstack-mine'));
fs.writeFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), '---\nname: mine\n---\nuser skill\n');
return { src, gen, host };
}
function runPrune(src: string, gen: string, host?: string) {
const script = [
'set -e',
extractFn('_owned_for_windows_refresh'),
extractFn('_prune_stale_generated'),
`_prune_stale_generated "${src}" "${gen}" ${host ? `"${host}"` : ''}`,
].join('\n');
return spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
}
describe('setup: _prune_stale_generated', () => {
test('call sites: every host link + the always-run codex render are pruned', () => {
for (const site of [
'"$SOURCE_GSTACK_DIR/.agents/skills"',
'"$SOURCE_GSTACK_DIR/.agents/skills" "$CODEX_SKILLS"',
'"$SOURCE_GSTACK_DIR/.factory/skills" "$FACTORY_SKILLS"',
'"$SOURCE_GSTACK_DIR/.opencode/skills" "$OPENCODE_SKILLS"',
'"$SOURCE_GSTACK_DIR/.cursor/skills" "$CURSOR_SKILLS"',
'"$AGENTS_DIR" "$KIRO_SKILLS"',
]) {
expect(SETUP_SRC).toContain(`_prune_stale_generated "$SOURCE_GSTACK_DIR" ${site}`);
}
});
test('retired renders go; live, prefixed-source, and sidecar dirs stay; host entries follow provenance', () => {
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-'));
try {
const { src, gen, host } = mk(t);
const r = runPrune(src, gen, host);
expect(r.status).toBe(0);
expect(r.stdout).toContain('pruned retired skill: gstack-oldskill');
expect(r.stdout).toContain('pruned retired skill: gstack-gone');
expect(fs.readdirSync(gen).sort()).toEqual(['gstack', 'gstack-qa', 'gstack-upgrade']);
// Symlink to a retired render + bannered copy of one: removed.
expect(fs.existsSync(path.join(host, 'gstack-oldskill'))).toBe(false);
expect(fs.lstatSync(path.join(host, 'gstack-oldskill'), { throwIfNoEntry: false })).toBeUndefined();
expect(fs.existsSync(path.join(host, 'gstack-gone'))).toBe(false);
// Live link and the user's own (unbannered) dir: untouched.
expect(fs.lstatSync(path.join(host, 'gstack-qa')).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), 'utf-8')).toContain('user skill');
} finally {
fs.rmSync(t, { recursive: true, force: true });
}
});
test('no host dir → prunes the render tree only; missing render tree → no-op', () => {
const t = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-'));
try {
const { src, gen, host } = mk(t);
expect(runPrune(src, gen).status).toBe(0);
expect(fs.existsSync(path.join(gen, 'gstack-oldskill'))).toBe(false);
expect(fs.lstatSync(path.join(host, 'gstack-oldskill')).isSymbolicLink()).toBe(true); // dangling, but not ours to touch here
const r = runPrune(src, path.join(t, 'nope'), host);
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
} finally {
fs.rmSync(t, { recursive: true, force: true });
}
});
});