From 9472f343dbb2476053b1f85d81f57f8a39d7488b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 27 Apr 2026 00:13:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(#2762):=20--minimal=20install=20profile=20?= =?UTF-8?q?(=E2=89=A594%=20cold-start=20token=20reduction)=20(#2764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2762): add --minimal install profile to cut cold-start token cost Eager system-prompt load from 86 gsd-* skill descriptions plus 33 subagent descriptions costs ~12k tokens per turn even in directories with no .planning/. Frontier models (Sonnet 4.6 / Opus 4.7) with 200K-1M context don't feel it; local LLMs with 32K-128K do. --minimal (alias --core-only) installs only the main GSD loop: new-project, discuss-phase, plan-phase, execute-phase, plus help/update. Zero gsd-* subagents are written. Re-running gsd update without --minimal expands to the full surface. Default install behavior is unchanged. DRY: a single stageSkillsForMode() helper filters the source dir; all 13 runtime-specific copy fns are unchanged because they recurse the staged dir. Allowlist + helpers live in get-shit-done/bin/lib/install- profiles.cjs as the single source of truth. Manifest now records mode: 'minimal' | 'full' so future commands can detect install profile. Tested end-to-end: --minimal yields 6 skill folders + 0 agents; default yields 86 + 33 (unchanged). * docs(#2762): document --minimal install in README Adds a collapsible 'Minimal Install' section under Getting Started covering: who it's for (local LLMs, token-billed APIs), what you get (6 skills, 0 subagents, ~700 token floor vs ~12k), and the critical caveat that re-installing without --minimal restores the full surface and erases the savings. Includes a comparison table, the manifest inspection one-liner, and the use-case decision matrix. * fix(#2762): address CodeRabbit review + CI failures CodeRabbit findings: 1. Temp dir leak (Minor): stageSkillsForMode created tmp dirs that were never cleaned up. Added a module-level Set tracking every staged dir plus a process.on('exit') handler that rm -rf's them. Also wrap the copy loop in try/catch to remove a partially-populated tmp dir on mid-flight failure. Verified end-to-end: 0 leaked dirs in /tmp after a real install. 2. Codex full -> minimal stale state (Major): a previous full Codex install left agents/gsd-*.toml files plus [agents.gsd-*] sections in config.toml. The original cleanup only removed .md files, so a switch to --minimal would leave Codex still advertising the full agent surface. Cleanup now also handles .toml under isCodex, and minimal mode strips GSD sections from config.toml via the existing stripGsdFromCodexConfig helper (same path used by --uninstall). 3. Nitpick — Codex downgrade regression test: added a spawnSync-based end-to-end test that fakes a previous full install (stale gsd-*.md + gsd-*.toml + GSD-marked config.toml + a user-owned agent/setting), runs install.js --codex --minimal, and asserts stale GSD files + sections are gone while user content is preserved. CI failures (inventory parity): - docs/INVENTORY.md CLI Modules table now lists install-profiles.cjs with the correct headline count (30 -> 31). - docs/INVENTORY-MANIFEST.json regenerated via gen-inventory-manifest.cjs. Test count: 149 pass (was 116 in last commit; +14 new install-minimal + all previously-failing inventory tests now green). * test(#2762): expand install-minimal test coverage for future-proofing Each new test pins a specific guarantee that closes off a future regression class — turning every CodeRabbit finding (including the nitpicky one) into a permanent guard. cleanupStagedSkills suite (+3 tests): - 'full mode does not register a staged dir' — catches a future regression where someone forgets the early-return in stageSkillsForMode and starts polluting STAGED_DIRS in default installs. - 'exit handler registers exactly once across many calls' — catches removal of the exitHandlerRegistered guard. install.js has 13 dispatch sites, so a missing guard would attach 13 listeners. - 'mid-copy failure removes partial staged dir and re-throws' — intercepts fs.copyFileSync to throw mid-loop and asserts the staged dir count in /tmp is unchanged after the throw. Pins the exact CodeRabbit-flagged leak. Claude full -> minimal downgrade (+1 test): - Mirrors the Codex downgrade test for the .md-only path that the other 12 runtimes share. Asserts user-owned agents are preserved. Manifest mode round-trip (+3 tests): - Default install -> mode: 'full' with >6 skills and >0 agents - --minimal -> mode: 'minimal' with exactly 6 skills and 0 agents - --core-only alias produces identical manifest to --minimal Allowlist scope guards (+3 tests): - Every main-loop command IS in allowlist (positive) - Off-loop commands (autonomous, ship, do, progress, next, fast, quick, debug, code-review, verify-work) are NOT (guards against silent scope creep — future contributor adds 'autonomous' to core and the floor erodes) - Unknown mode strings fall through to full behavior — pre-emptive guard for future 'compact'/'tier2' modes that might forget to update the predicate. Total: 25 tests in this file (was 15), 159/159 passing across the install + inventory suites. * fix(#2762): clean up staged tmp dirs on SIGINT/SIGTERM/SIGHUP CodeRabbit follow-up review on c727bf5f flagged that process.on('exit') does not fire on signal-driven termination. An installer is exactly the kind of process users abort mid-run with Ctrl+C, so without explicit signal handlers the staged tmp dirs in STAGED_DIRS would be left behind until the OS reaps tmpdir. Fix: ensureExitCleanup now also registers process.once handlers for SIGINT, SIGTERM, SIGHUP. Each handler runs cleanupStagedSkills then re-raises the same signal via process.kill(pid, sig) so the OS-default handler takes over and the parent shell sees the correct exit code (130 for SIGINT, etc.) — CI scripts and interactive users see the abort the way they expect. Test: spawns a child that stages a tmp dir then blocks; parent captures the staged path from stdout, sends SIGINT, asserts (a) the staged dir is gone after child exit, (b) child exits via the signal not via code 0. Skipped on Windows (signal semantics differ; the natural-exit cleanup test covers the Windows CI matrix). Total: 26 tests in install-minimal.test.cjs (was 25). --- CHANGELOG.md | 8 + README.md | 51 ++ bin/install.js | 98 +++- docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- get-shit-done/bin/lib/install-profiles.cjs | 132 +++++ tests/install-minimal.test.cjs | 579 +++++++++++++++++++++ 7 files changed, 844 insertions(+), 30 deletions(-) create mode 100644 get-shit-done/bin/lib/install-profiles.cjs create mode 100644 tests/install-minimal.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbccad3a..f0b169c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.38.5...HEAD) +### Added +- `--minimal` install flag (alias `--core-only`) writes only the main-loop core skills + (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) and + zero `gsd-*` subagents. Cuts cold-start system-prompt overhead from ~12k tokens to + ~700, useful for local LLMs with 32K–128K context (Sonnet 4.6 / Opus 4.7 don't need + it). Re-run `gsd update` without `--minimal` to expand to the full surface. The + install manifest now records `mode: "minimal" | "full"`. (#2762) + ## [1.38.5] - 2026-04-25 ### Fixed diff --git a/README.md b/README.md index 03e606f63..f6a4b5427 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,57 @@ The GSD SDK CLI (`gsd-sdk`) is installed automatically (required by `/gsd-*` com +
+Minimal Install (local LLMs and token-billed APIs) + +GSD ships 86 skills and 33 subagents. Every runtime (Claude Code, OpenCode, etc.) eagerly enumerates skill descriptions and subagent descriptions into the system prompt on **every turn** — about **~12k tokens** of fixed overhead before you've typed anything. Frontier models with large context (Sonnet 4.6, Opus 4.7 — 200K to 1M ctx) absorb that without a noticeable hit. **Local LLMs with 32K–128K context, and any model where you're paying per token, will feel it.** + +Pass `--minimal` (alias `--core-only`) to install only the **main GSD loop**: + +```bash +npx get-shit-done-cc --claude --global --minimal +# or any other runtime — works the same +npx get-shit-done-cc --opencode --global --minimal +``` + +What you get: + +| Surface | Default install | `--minimal` install | +|---|---|---| +| Skills | 86 (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, …82 more) | **6** (`new-project`, `discuss-phase`, `plan-phase`, `execute-phase`, `help`, `update`) | +| Subagents | 33 `gsd-*` agents | **0** | +| Cold-start system-prompt overhead | ~12k tokens | **~700 tokens** (≥94% reduction) | +| Manifest mode field | `"full"` | `"minimal"` | + +The 6 core skills are exactly the ones you need to drive a project from zero: `new-project` to bootstrap, then the `discuss → plan → execute` loop, plus `help` for discovery and `update` to upgrade later. + +**This is a hard floor, not a ceiling.** Each `/gsd-*` command you start using and each subagent it dispatches loads its body content into the conversation for that turn — that's normal token use, not eager overhead. But: + +> [!IMPORTANT] +> **The savings disappear the moment you re-install without `--minimal`.** Running `npx get-shit-done-cc@latest` (or `gsd update` from inside a session) without the flag puts the full 86-skill / 33-agent surface back on disk, and every subsequent session pays the full ~12k-token floor again. If you want to stay minimal, **always pass `--minimal` when updating**: +> +> ```bash +> npx get-shit-done-cc@latest --claude --global --minimal +> ``` +> +> Need a specific skill that isn't in the core set (e.g., `gsd-autonomous`, `gsd-ship`, `gsd-debug`)? You have two options: +> 1. **Permanent expand:** re-install without `--minimal` to get the full surface (and the full token floor). +> 2. **One-shot:** run the slash command's underlying logic by reading the source from `commands/gsd/.md` in the GSD package and executing it manually — no install change needed. +> +> Tip: `cat ~/.claude/get-shit-done/.gsd-manifest.json | jq .mode` (or `gsd-file-manifest.json` depending on layout) confirms which mode you're in. + +When to use `--minimal`: +- Local model with 32K–128K context (Qwen3, Llama, Mistral, etc.) +- Token-metered API where every turn matters +- Throwaway directory or non-GSD project where you want `/gsd-new-project` available without paying for the rest +- CI runners or ephemeral containers where install footprint matters + +When **not** to use `--minimal`: +- Active GSD project where you regularly invoke the broader command set (`autonomous`, `ship`, `code-review`, `debug`, etc.) — re-installing each time is friction without payoff. +- Frontier models with 200K–1M context — the savings are noise. + +
+
Development Installation diff --git a/bin/install.js b/bin/install.js index 8f91430e2..f18c25c06 100755 --- a/bin/install.js +++ b/bin/install.js @@ -71,6 +71,12 @@ const { resolveTierEntry: gsdResolveTierEntry, } = require(path.join(_gsdLibDir, 'core.cjs')); +const { + MINIMAL_SKILL_ALLOWLIST, + isMinimalMode, + stageSkillsForMode, +} = require(path.join(_gsdLibDir, 'install-profiles.cjs')); + // Parse args const args = process.argv.slice(2); const hasGlobal = args.includes('--global') || args.includes('-g'); @@ -94,6 +100,8 @@ const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; +const hasMinimal = args.includes('--minimal') || args.includes('--core-only'); +const installMode = hasMinimal ? 'minimal' : 'full'; const hasSdk = args.includes('--sdk'); const hasNoSdk = args.includes('--no-sdk'); @@ -461,7 +469,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--minimal${reset} Install only the main-loop skills (new-project,\n discuss-phase, plan-phase, execute-phase, help, update)\n and zero gsd-* subagents. Cuts cold-start system-prompt\n overhead from ~12k tokens to ~700 — useful for local LLMs\n with 32K–128K context. Re-run \`gsd update\` (without --minimal)\n to expand to the full surface. Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -5654,7 +5662,7 @@ function generateManifest(dir, baseDir) { /** * Write file manifest after installation for future modification detection */ -function writeManifest(configDir, runtime = 'claude') { +function writeManifest(configDir, runtime = 'claude', options = {}) { const isOpencode = runtime === 'opencode'; const isKilo = runtime === 'kilo'; const isGemini = runtime === 'gemini'; @@ -5670,7 +5678,12 @@ function writeManifest(configDir, runtime = 'claude') { const opencodeCommandDir = path.join(configDir, 'command'); const codexSkillsDir = path.join(configDir, 'skills'); const agentsDir = path.join(configDir, 'agents'); - const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} }; + const manifest = { + version: pkg.version, + timestamp: new Date().toISOString(), + mode: options.mode === 'minimal' ? 'minimal' : 'full', + files: {}, + }; const gsdHashes = generateManifest(gsdDir); for (const [rel, hash] of Object.entries(gsdHashes)) { @@ -5901,7 +5914,7 @@ function install(isGlobal, runtime = 'claude') { fs.mkdirSync(commandDir, { recursive: true }); // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure) - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime); if (verifyInstalled(commandDir, 'command/gsd-*')) { const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length; @@ -5911,7 +5924,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isCodex) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -5921,7 +5934,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isCopilot) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsCopilotSkills(gsdSrc, skillsDir, 'gsd', isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -5936,7 +5949,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isAntigravity) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsAntigravitySkills(gsdSrc, skillsDir, 'gsd', isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -5951,7 +5964,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isCursor) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsCursorSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure if (installedSkillNames.length > 0) { @@ -5961,7 +5974,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isWindsurf) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsWindsurfSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure if (installedSkillNames.length > 0) { @@ -5971,7 +5984,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isAugment) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -5981,7 +5994,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isTrae) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsTraeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -5991,7 +6004,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isQwen) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -6014,7 +6027,7 @@ function install(isGlobal, runtime = 'claude') { } } else if (isCodebuddy) { const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsCodebuddySkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); const installedSkillNames = listCodexSkillNames(skillsDir); if (installedSkillNames.length > 0) { @@ -6029,7 +6042,7 @@ function install(isGlobal, runtime = 'claude') { } else if (isGemini) { const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); if (verifyInstalled(gsdDest, 'commands/gsd')) { @@ -6040,7 +6053,7 @@ function install(isGlobal, runtime = 'claude') { } else if (isGlobal) { // Claude Code global: skills/ format (2.1.88+ compatibility) const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); if (fs.existsSync(skillsDir)) { const count = fs.readdirSync(skillsDir, { withFileTypes: true }) @@ -6068,7 +6081,7 @@ function install(isGlobal, runtime = 'claude') { // commands from .claude/commands/gsd/, not .claude/skills/ const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = path.join(src, 'commands', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); const gsdDest = path.join(commandsDir, 'gsd'); copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); if (verifyInstalled(gsdDest, 'commands/gsd')) { @@ -6105,20 +6118,48 @@ function install(isGlobal, runtime = 'claude') { failures.push('get-shit-done'); } - // Copy agents to agents directory + // Copy agents to agents directory. + // Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded + // into the runtime's Agent tool schema, costing ~6k tokens per turn even + // when no GSD workflow is active. See gsd-build/get-shit-done#2762. const agentsSrc = path.join(src, 'agents'); - if (fs.existsSync(agentsSrc)) { - const agentsDest = path.join(targetDir, 'agents'); - fs.mkdirSync(agentsDest, { recursive: true }); + const agentsDest = path.join(targetDir, 'agents'); - // Remove old GSD agents (gsd-*.md) before copying new ones - if (fs.existsSync(agentsDest)) { - for (const file of fs.readdirSync(agentsDest)) { - if (file.startsWith('gsd-') && file.endsWith('.md')) { - fs.unlinkSync(path.join(agentsDest, file)); + // Always remove stale gsd-* agents first so re-installing with + // `--minimal` actually shrinks a previously-full install. + // For Codex this also covers per-agent `.toml` files alongside the `.md` + // sources so a full → minimal switch doesn't leave stale registrations. + if (fs.existsSync(agentsDest)) { + for (const file of fs.readdirSync(agentsDest)) { + if ( + file.startsWith('gsd-') && + (file.endsWith('.md') || (isCodex && file.endsWith('.toml'))) + ) { + fs.unlinkSync(path.join(agentsDest, file)); + } + } + } + + if (isMinimalMode(installMode)) { + // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections. + // Without stripping them here, a full → minimal reinstall would leave the + // runtime advertising the old full agent surface even though the agent + // files are gone. Reuse the same helper that powers `--uninstall`. + if (isCodex) { + const codexConfigPath = path.join(targetDir, 'config.toml'); + if (fs.existsSync(codexConfigPath)) { + const existing = fs.readFileSync(codexConfigPath, 'utf8'); + const cleaned = stripGsdFromCodexConfig(existing); + if (cleaned === null) { + fs.unlinkSync(codexConfigPath); + } else if (cleaned !== existing) { + fs.writeFileSync(codexConfigPath, cleaned); } } } + console.log(` ${dim}↳${reset} Skipping agents (minimal install — run \`gsd update\` without \`--minimal\` to add full surface)`); + } else if (fs.existsSync(agentsSrc)) { + fs.mkdirSync(agentsDest, { recursive: true }); // Copy new agents const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true }); @@ -6283,7 +6324,7 @@ function install(isGlobal, runtime = 'claude') { } // Write file manifest for future modification detection - writeManifest(targetDir, runtime); + writeManifest(targetDir, runtime, { mode: installMode }); console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); // Report any backed-up local patches @@ -6338,8 +6379,9 @@ function install(isGlobal, runtime = 'claude') { } } - if (isCodex) { - // Generate Codex config.toml and per-agent .toml files + if (isCodex && !isMinimalMode(installMode)) { + // Generate Codex config.toml and per-agent .toml files. + // Skipped under --minimal — same rationale as filesystem agents above. const agentCount = installCodexConfig(targetDir, agentsSrc); console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`); console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`); diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 162abe1d4..192c9b63a 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-04-23", + "generated": "2026-04-27", "families": { "agents": [ "gsd-advisor-researcher", @@ -278,6 +278,7 @@ "graphify.cjs", "gsd2-import.cjs", "init.cjs", + "install-profiles.cjs", "intel.cjs", "learnings.cjs", "milestone.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index b45c9dc3b..b4859caab 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (30 shipped) +## CLI Modules (31 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -381,6 +381,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` | | `gsd2-import.cjs` | External-plan ingest for `/gsd-from-gsd2` | | `init.cjs` | Compound context loading for each workflow type | +| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `intel.cjs` | Codebase intel store backing `/gsd-intel` and `gsd-intel-updater` | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `milestone.cjs` | Milestone archival, requirements marking | diff --git a/get-shit-done/bin/lib/install-profiles.cjs b/get-shit-done/bin/lib/install-profiles.cjs new file mode 100644 index 000000000..68e2b63f0 --- /dev/null +++ b/get-shit-done/bin/lib/install-profiles.cjs @@ -0,0 +1,132 @@ +/** + * Install profiles — single source of truth for which skills/agents + * are written to the runtime config dirs. + * + * Background: every installed `gsd-*` skill costs eager system-prompt + * tokens because runtimes (Claude Code, opencode, etc.) enumerate + * skill descriptions in `` on every turn. With 86 + * skills + 33 agents the floor is ~12k tokens per turn, which is a + * meaningful tax for local LLMs with 32K–128K context. Frontier + * models (Sonnet 4.6 / Opus 4.7 with 200K–1M ctx) don't feel it. + * + * The `minimal` profile installs the main GSD loop only: + * new-project → discuss-phase → plan-phase → execute-phase + * plus `help` (discoverability) and `update` (upgrade path). + * + * Users opt into minimal via `--minimal` on the install CLI. + * Default install (`full`) is unchanged — back-compat preserved. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const MINIMAL_SKILL_ALLOWLIST = Object.freeze([ + 'new-project', + 'discuss-phase', + 'plan-phase', + 'execute-phase', + 'help', + 'update', +]); + +const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST); + +function isMinimalMode(mode) { + return mode === 'minimal'; +} + +function shouldInstallSkill(skillBaseName, mode) { + if (!isMinimalMode(mode)) return true; + return MINIMAL_ALLOWLIST_SET.has(skillBaseName); +} + +// Stage dirs created during this process — cleaned up on exit. +// 13 runtime dispatch sites in install.js can each call stageSkillsForMode, +// so accumulating them in a single set avoids leaks without forcing each +// site to track its own cleanup handle. +const STAGED_DIRS = new Set(); +let exitHandlerRegistered = false; + +function cleanupStagedSkills() { + for (const dir of STAGED_DIRS) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best-effort: missing dir or permission error shouldn't crash a + // successful install. The OS reaps tmpdir eventually. + } + } + STAGED_DIRS.clear(); +} + +// Signals we register a cleanup handler for in addition to the natural +// 'exit' event. `process.on('exit')` does NOT fire on these — an installer +// is exactly the kind of process users abort mid-run, so without explicit +// signal handling Ctrl+C would leave staged tmp dirs behind. +const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +function ensureExitCleanup() { + if (exitHandlerRegistered) return; + exitHandlerRegistered = true; + process.on('exit', cleanupStagedSkills); + for (const sig of CLEANUP_SIGNALS) { + // `once` so re-raising the signal below isn't intercepted by us a second + // time — the OS-default handler should take over and exit with the right + // status code (so CI sees the abort, scripts see 130 for SIGINT, etc.). + process.once(sig, () => { + cleanupStagedSkills(); + process.kill(process.pid, sig); + }); + } +} + +/** + * Stage a filtered copy of the source commands/gsd directory when in + * minimal mode. All runtime-specific copy fns recurse a source dir, + * so filtering at the source point lets every copy fn stay unchanged + * (DRY: one filter, not 12). + * + * In full mode this is a no-op — the original srcDir is returned. + * + * Cleanup: the staged dir is automatically removed on process exit. + * If the copy loop throws mid-flight, the partially-populated dir is + * removed and the error re-raised, so callers never see an orphan. + * + * @param {string} srcDir absolute path to commands/gsd + * @param {string} mode 'full' | 'minimal' + * @returns {string} path to use (original or staged tmp) + */ +function stageSkillsForMode(srcDir, mode) { + if (!isMinimalMode(mode)) return srcDir; + if (!fs.existsSync(srcDir)) return srcDir; + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-minimal-skills-')); + try { + const entries = fs.readdirSync(srcDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const baseName = entry.name.replace(/\.md$/, ''); + if (!shouldInstallSkill(baseName, mode)) continue; + fs.copyFileSync( + path.join(srcDir, entry.name), + path.join(stageDir, entry.name), + ); + } + } catch (err) { + try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} + throw err; + } + STAGED_DIRS.add(stageDir); + ensureExitCleanup(); + return stageDir; +} + +module.exports = { + MINIMAL_SKILL_ALLOWLIST, + isMinimalMode, + shouldInstallSkill, + stageSkillsForMode, + cleanupStagedSkills, +}; diff --git a/tests/install-minimal.test.cjs b/tests/install-minimal.test.cjs new file mode 100644 index 000000000..ceba4c8b5 --- /dev/null +++ b/tests/install-minimal.test.cjs @@ -0,0 +1,579 @@ +/** + * Tests for `--minimal` install profile (#2762). + * + * Verifies: + * 1. The install-profiles allowlist contains exactly the documented core + * main-loop skills. + * 2. stageSkillsForMode() filters source dir entries to the allowlist when + * mode === 'minimal' and is a no-op for mode === 'full'. + * 3. Filtering is by basename (mirrors how copyCommandsAs*Skills derives + * skill names). + * 4. shouldInstallSkill() agrees with stageSkillsForMode(). + * + * Note: end-to-end install tests (spawning bin/install.js with --minimal) are + * intentionally out of scope here — they require a fully-mocked runtime config + * dir which would duplicate antigravity-install.test.cjs scaffolding. The unit + * tests below pin the allowlist contract; the dispatch sites in install.js + * call stageSkillsForMode unconditionally so any breakage there shows up as + * a stage_dir/source_dir mismatch covered by these tests. + */ + +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 { + MINIMAL_SKILL_ALLOWLIST, + isMinimalMode, + shouldInstallSkill, + stageSkillsForMode, + cleanupStagedSkills, +} = require('../get-shit-done/bin/lib/install-profiles.cjs'); + +describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => { + test('contains exactly the main-loop core (no drift without test update)', () => { + assert.deepStrictEqual( + [...MINIMAL_SKILL_ALLOWLIST].sort(), + [ + 'discuss-phase', + 'execute-phase', + 'help', + 'new-project', + 'plan-phase', + 'update', + ], + ); + }); + + test('is frozen (mutations throw in strict mode)', () => { + assert.ok(Object.isFrozen(MINIMAL_SKILL_ALLOWLIST)); + }); + + test('every allowlisted skill exists in commands/gsd/', () => { + const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); + for (const name of MINIMAL_SKILL_ALLOWLIST) { + const file = path.join(commandsDir, `${name}.md`); + assert.ok( + fs.existsSync(file), + `core skill ${name} is allowlisted but ${file} does not exist`, + ); + } + }); +}); + +describe('install-profiles: isMinimalMode', () => { + test('returns true only for the literal string "minimal"', () => { + assert.strictEqual(isMinimalMode('minimal'), true); + assert.strictEqual(isMinimalMode('full'), false); + assert.strictEqual(isMinimalMode(''), false); + assert.strictEqual(isMinimalMode(undefined), false); + assert.strictEqual(isMinimalMode(null), false); + assert.strictEqual(isMinimalMode('MINIMAL'), false); + }); +}); + +describe('install-profiles: shouldInstallSkill', () => { + test('full mode admits every skill', () => { + assert.strictEqual(shouldInstallSkill('plan-phase', 'full'), true); + assert.strictEqual(shouldInstallSkill('autonomous', 'full'), true); + assert.strictEqual(shouldInstallSkill('arbitrary-future-name', 'full'), true); + }); + + test('minimal mode admits only allowlisted skills', () => { + for (const name of MINIMAL_SKILL_ALLOWLIST) { + assert.strictEqual(shouldInstallSkill(name, 'minimal'), true, name); + } + for (const denied of ['autonomous', 'do', 'progress', 'next', 'fast', 'quick']) { + assert.strictEqual(shouldInstallSkill(denied, 'minimal'), false, denied); + } + }); + + test('minimal mode rejects allowlist names with .md suffix (callers must strip)', () => { + assert.strictEqual(shouldInstallSkill('plan-phase.md', 'minimal'), false); + }); +}); + +describe('install-profiles: stageSkillsForMode', () => { + function createFixtureSkillsDir() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-')); + fs.writeFileSync(path.join(tmp, 'plan-phase.md'), '# plan-phase\n'); + fs.writeFileSync(path.join(tmp, 'execute-phase.md'), '# execute-phase\n'); + fs.writeFileSync(path.join(tmp, 'autonomous.md'), '# autonomous\n'); + fs.writeFileSync(path.join(tmp, 'do.md'), '# do\n'); + fs.writeFileSync(path.join(tmp, 'help.md'), '# help\n'); + fs.writeFileSync(path.join(tmp, 'new-project.md'), '# new-project\n'); + fs.writeFileSync(path.join(tmp, 'discuss-phase.md'), '# discuss-phase\n'); + fs.writeFileSync(path.join(tmp, 'update.md'), '# update\n'); + fs.writeFileSync(path.join(tmp, 'progress.md'), '# progress\n'); + return tmp; + } + + test('full mode returns the original src dir unchanged', () => { + const src = createFixtureSkillsDir(); + try { + const result = stageSkillsForMode(src, 'full'); + assert.strictEqual(result, src); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + } + }); + + test('minimal mode returns a new dir containing only allowlisted skills', () => { + const src = createFixtureSkillsDir(); + let staged; + try { + staged = stageSkillsForMode(src, 'minimal'); + assert.notStrictEqual(staged, src); + const stagedFiles = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(stagedFiles, [ + 'discuss-phase.md', + 'execute-phase.md', + 'help.md', + 'new-project.md', + 'plan-phase.md', + 'update.md', + ]); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (staged) fs.rmSync(staged, { recursive: true, force: true }); + } + }); + + test('minimal mode preserves file content byte-for-byte', () => { + const src = createFixtureSkillsDir(); + let staged; + try { + staged = stageSkillsForMode(src, 'minimal'); + const original = fs.readFileSync(path.join(src, 'plan-phase.md'), 'utf8'); + const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); + assert.strictEqual(copied, original); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (staged) fs.rmSync(staged, { recursive: true, force: true }); + } + }); + + test('minimal mode against non-existent source returns the source path (caller handles missing)', () => { + const ghost = path.join(os.tmpdir(), 'gsd-stage-does-not-exist-' + Date.now()); + const result = stageSkillsForMode(ghost, 'minimal'); + assert.strictEqual(result, ghost); + }); + + test('minimal mode skips non-md files and subdirectories', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-mixed-')); + let staged; + try { + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n'); + fs.writeFileSync(path.join(src, 'README.txt'), 'not a skill\n'); + fs.mkdirSync(path.join(src, 'nested-dir')); + fs.writeFileSync(path.join(src, 'nested-dir', 'plan-phase.md'), '# nested\n'); + staged = stageSkillsForMode(src, 'minimal'); + const stagedFiles = fs.readdirSync(staged); + assert.deepStrictEqual(stagedFiles, ['plan-phase.md']); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (staged) fs.rmSync(staged, { recursive: true, force: true }); + } + }); +}); + +describe('install-profiles: cleanupStagedSkills', () => { + test('removes every staged dir created during this process', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-cleanup-')); + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n'); + try { + const a = stageSkillsForMode(src, 'minimal'); + const b = stageSkillsForMode(src, 'minimal'); + assert.notStrictEqual(a, b, 'each call should mkdtemp a fresh dir'); + assert.ok(fs.existsSync(a)); + assert.ok(fs.existsSync(b)); + cleanupStagedSkills(); + assert.ok(!fs.existsSync(a), 'first staged dir should be removed'); + assert.ok(!fs.existsSync(b), 'second staged dir should be removed'); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + } + }); + + test('is idempotent — calling twice does not throw', () => { + cleanupStagedSkills(); + cleanupStagedSkills(); + }); + + test('full mode does not register a staged dir (no leak source for default install)', () => { + cleanupStagedSkills(); + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fullmode-')); + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n'); + try { + const before = listTmpStageDirs(); + const result = stageSkillsForMode(src, 'full'); + assert.strictEqual(result, src, 'full mode returns original src unchanged'); + cleanupStagedSkills(); + const after = listTmpStageDirs(); + // No new gsd-minimal-skills- dirs should have been created. + assert.deepStrictEqual(after, before); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + } + }); + + test('exit handler registers exactly once across many stageSkillsForMode calls', () => { + cleanupStagedSkills(); + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-exit-handler-')); + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n'); + try { + const before = process.listenerCount('exit'); + // Call 5x — install.js has 13 dispatch sites, so this matters. + for (let i = 0; i < 5; i++) stageSkillsForMode(src, 'minimal'); + const after = process.listenerCount('exit'); + // Either 0 (handler was already registered by an earlier test) or +1. + // Never +5. + assert.ok(after - before <= 1, `expected <=1 new exit listener, got ${after - before}`); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + cleanupStagedSkills(); + } + }); + + test('SIGINT triggers cleanup and re-raises the signal (Ctrl+C path)', () => { + // Run a child process that calls stageSkillsForMode then sleeps; send it + // SIGINT and assert (a) the child exits with the SIGINT-induced status + // (signal: 'SIGINT' OR exit code 130 depending on platform), and (b) the + // staged tmp dir is gone afterwards. Skipping on Windows where signal + // semantics differ — the unit test for natural `exit` covers Linux/macOS + // CI matrix, and signal handling is a Unix concern in practice. + if (process.platform === 'win32') return; + + const { spawnSync } = require('child_process'); + const probe = ` + const { stageSkillsForMode } = require(${JSON.stringify( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'), + )}); + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-sig-src-')); + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\\n'); + const staged = stageSkillsForMode(src, 'minimal'); + // Print the staged path so the parent knows what to look for, then + // signal readiness and block until SIGINT. + process.stdout.write(staged + '\\n'); + setInterval(() => {}, 1000); + `; + // Spawn detached so we control the signal cleanly. + const child = require('child_process').spawn(process.execPath, ['-e', probe], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let staged = ''; + child.stdout.on('data', (chunk) => { + staged += chunk.toString(); + if (!staged.includes('\n')) return; + // Once we have the staged path, send SIGINT and check on exit. + child.kill('SIGINT'); + }); + + return new Promise((resolve, reject) => { + child.on('exit', (code, signal) => { + try { + const stagedPath = staged.split('\n')[0]; + assert.ok( + stagedPath && stagedPath.startsWith(os.tmpdir()), + `child should have printed a staged path under tmpdir, got: ${JSON.stringify(stagedPath)}`, + ); + assert.ok( + !fs.existsSync(stagedPath), + `staged dir should have been cleaned up on SIGINT, but ${stagedPath} still exists`, + ); + // The child should have exited *because* of the signal, not 0. + assert.ok( + signal === 'SIGINT' || code === 130 || code === null, + `child should exit via SIGINT, got code=${code} signal=${signal}`, + ); + resolve(); + } catch (err) { + reject(err); + } + }); + child.on('error', reject); + }); + }); + + test('mid-copy failure removes the partial staged dir and re-throws', () => { + cleanupStagedSkills(); + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fail-')); + fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n'); + try { + // Force a failure mid-loop by making fs.copyFileSync throw on the + // second allowlisted file. Capture the staged dir from the first + // successful call (we can't see it directly, so we count tmp dirs). + const before = listTmpStageDirs(); + const realCopy = fs.copyFileSync; + let copyCount = 0; + fs.copyFileSync = (s, d) => { + copyCount++; + if (copyCount === 2) throw new Error('synthetic disk full'); + return realCopy(s, d); + }; + // Need at least 2 allowlisted files in src for the second copy to fire. + fs.writeFileSync(path.join(src, 'execute-phase.md'), '# x\n'); + try { + assert.throws(() => stageSkillsForMode(src, 'minimal'), /synthetic disk full/); + } finally { + fs.copyFileSync = realCopy; + } + const after = listTmpStageDirs(); + // Partial dir must have been cleaned up by stageSkillsForMode itself + // before re-throwing — so the count is unchanged. + assert.deepStrictEqual(after, before, 'partial staged dir should be removed on throw'); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + cleanupStagedSkills(); + } + }); +}); + +// Helper for the cleanup tests above. Listed as a sibling so the describe +// block stays focused on the contract assertions. +function listTmpStageDirs() { + try { + return fs + .readdirSync(os.tmpdir()) + .filter((n) => n.startsWith('gsd-minimal-skills-')) + .sort(); + } catch { + return []; + } +} + +// ─── End-to-end install regression: full → minimal Codex downgrade ───────── +// +// CodeRabbit (#2764) flagged that switching from full to minimal on Codex +// would leave stale `agents/gsd-*.toml` files plus `[agents.gsd-*]` +// sections in `config.toml`. This test simulates a previous full Codex +// install (a few stale agent files + an existing GSD-marked config.toml) +// and confirms that `--minimal` cleans them up. +describe('install: Codex full → minimal downgrade cleans stale agent state', () => { + const { spawnSync } = require('child_process'); + const installScript = path.join(__dirname, '..', 'bin', 'install.js'); + + function makeStaleCodexInstall(targetDir) { + const agentsDir = path.join(targetDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Pretend a previous full install left these behind: + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n'); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n'); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'name = "gsd-executor"\n'); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'name = "gsd-planner"\n'); + // Also drop an unrelated user agent to confirm we don't touch it: + fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n'); + + // A previously-written codex config.toml with both GSD and user content, + // matching the marker format produced by installCodexConfig. + const codexConfig = [ + '# user-owned setting', + 'model = "gpt-5"', + '', + '# GSD Agent Configuration — managed by get-shit-done installer', + '[agents.gsd-executor]', + 'cmd = "stale"', + '', + '[agents.gsd-planner]', + 'cmd = "stale"', + '', + ].join('\n'); + fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig); + } + + test('--minimal removes stale .toml agents and strips [agents.gsd-*] from config.toml', () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-downgrade-')); + try { + makeStaleCodexInstall(targetDir); + + const result = spawnSync( + process.execPath, + [installScript, '--codex', '--global', '--config-dir', targetDir, '--minimal'], + { encoding: 'utf8' }, + ); + // Install may print the SDK-not-found warning at the end (the worktree + // doesn't always have sdk/dist built). That's a non-fatal post-step; + // skill/agent staging happens before it. We assert state, not exit code. + assert.ok(result.stdout || result.stderr, 'install should produce some output'); + + const agentsDir = path.join(targetDir, 'agents'); + const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : []; + + // Stale gsd-* files (.md AND .toml) must be gone: + assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md should be removed'); + assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md should be removed'); + assert.ok(!remaining.includes('gsd-executor.toml'), 'stale gsd-executor.toml should be removed'); + assert.ok(!remaining.includes('gsd-planner.toml'), 'stale gsd-planner.toml should be removed'); + + // User-owned agent must survive: + assert.ok(remaining.includes('my-custom-agent.md'), 'user agent should be preserved'); + + // config.toml: GSD section gone, user content preserved + const configPath = path.join(targetDir, 'config.toml'); + if (fs.existsSync(configPath)) { + const config = fs.readFileSync(configPath, 'utf8'); + assert.ok(!config.includes('[agents.gsd-executor]'), 'gsd-executor section stripped'); + assert.ok(!config.includes('[agents.gsd-planner]'), 'gsd-planner section stripped'); + assert.ok(config.includes('model = "gpt-5"'), 'user setting preserved'); + } + // (If config.toml was GSD-only it'd be removed entirely, which is also acceptable — + // in this fixture there's user content so the file should still exist.) + assert.ok(fs.existsSync(configPath), 'config.toml with user content should remain'); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + }); +}); + +// ─── Claude full → minimal downgrade ──────────────────────────────────────── +// +// Mirrors the Codex test for the most common runtime. The Codex test pins +// the .toml + config.toml cleanup; this one pins the .md-only path that +// every non-Codex runtime shares. +describe('install: Claude full → minimal downgrade removes stale agents', () => { + const { spawnSync } = require('child_process'); + const installScript = path.join(__dirname, '..', 'bin', 'install.js'); + + test('--minimal removes stale gsd-*.md agents but preserves user-owned agents', () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-downgrade-')); + try { + const agentsDir = path.join(targetDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Fake a previous full install + a user-owned agent: + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n'); + fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n'); + fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n'); + + spawnSync( + process.execPath, + [installScript, '--claude', '--global', '--config-dir', targetDir, '--minimal'], + { encoding: 'utf8' }, + ); + + const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : []; + assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md removed'); + assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md removed'); + assert.ok(remaining.includes('my-custom-agent.md'), 'user agent preserved'); + + // No `gsd-*` files at all should remain: + const stragglers = remaining.filter((f) => f.startsWith('gsd-')); + assert.deepStrictEqual(stragglers, [], 'no gsd-* files should remain in agents/'); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + }); +}); + +// ─── Manifest mode field round-trip ───────────────────────────────────────── +// +// Locks in the contract that downstream tooling (uninstaller, drift detector, +// future profile-aware commands) can rely on the `mode` field being present +// and accurate after every install. Catches regressions in writeManifest's +// options threading. +describe('install: manifest records mode for both profiles', () => { + const { spawnSync } = require('child_process'); + const installScript = path.join(__dirname, '..', 'bin', 'install.js'); + + function manifestModeAfterInstall(extraArgs) { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-')); + try { + spawnSync( + process.execPath, + [installScript, '--claude', '--global', '--config-dir', targetDir, ...extraArgs], + { encoding: 'utf8' }, + ); + const manifestPath = path.join(targetDir, 'gsd-file-manifest.json'); + if (!fs.existsSync(manifestPath)) { + return { mode: '', skillCount: 0, agentCount: 0 }; + } + const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const skillCount = new Set( + Object.keys(m.files || {}) + .filter((k) => k.startsWith('skills/')) + .map((k) => k.split('/')[1]), + ).size; + const agentCount = Object.keys(m.files || {}).filter((k) => k.startsWith('agents/')).length; + return { mode: m.mode, skillCount, agentCount }; + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + } + + test('default install records mode: "full" with the full skill+agent count', () => { + const r = manifestModeAfterInstall([]); + assert.strictEqual(r.mode, 'full'); + assert.ok(r.skillCount > 6, `full install should have >6 skills, got ${r.skillCount}`); + assert.ok(r.agentCount > 0, `full install should have agents, got ${r.agentCount}`); + }); + + test('--minimal records mode: "minimal" with exactly 6 skills and 0 agents', () => { + const r = manifestModeAfterInstall(['--minimal']); + assert.strictEqual(r.mode, 'minimal'); + assert.strictEqual(r.skillCount, 6); + assert.strictEqual(r.agentCount, 0); + }); + + test('--core-only is an alias for --minimal', () => { + const r = manifestModeAfterInstall(['--core-only']); + assert.strictEqual(r.mode, 'minimal'); + assert.strictEqual(r.skillCount, 6); + assert.strictEqual(r.agentCount, 0); + }); +}); + +// ─── Allowlist scope guard ───────────────────────────────────────────────── +// +// Catches drift in the opposite direction: someone adds an off-loop command +// to the allowlist, or removes a main-loop command. The first test in this +// file asserts the exact set; these add semantic guard rails so the failure +// mode is clear ("autonomous shouldn't be in core") rather than just a diff. +describe('install-profiles: allowlist scope guards', () => { + test('every main-loop command is in the allowlist', () => { + for (const required of ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase']) { + assert.ok( + shouldInstallSkill(required, 'minimal'), + `main-loop command "${required}" must be in MINIMAL_SKILL_ALLOWLIST`, + ); + } + }); + + test('off-loop convenience commands are NOT in the allowlist', () => { + // These exist in commands/gsd/ and are valid skills, but they're not part + // of the core main loop. If any of these slip into the allowlist the + // floor erodes. + for (const offLoop of [ + 'autonomous', + 'ship', + 'do', + 'progress', + 'next', + 'fast', + 'quick', + 'debug', + 'code-review', + 'verify-work', + ]) { + assert.ok( + !shouldInstallSkill(offLoop, 'minimal'), + `off-loop command "${offLoop}" must NOT be in MINIMAL_SKILL_ALLOWLIST`, + ); + } + }); + + test('mode is required to be a known string — defensive against typos', () => { + // Any non-'minimal' mode should admit everything (full-mode behavior). + // This catches a future bug where someone adds a 'compact' or 'tier2' + // mode and forgets to wire up the predicate. + for (const unknownMode of ['compact', 'tier2', 'CORE', 'Minimal', 'mini']) { + assert.ok( + shouldInstallSkill('autonomous', unknownMode), + `unknown mode "${unknownMode}" should fall through to full behavior`, + ); + } + }); +});