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