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`, + ); + } + }); +});