diff --git a/.agents/skills/cavecrew/SKILL.md b/.agents/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/.agents/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/.junie/skills/cavecrew/SKILL.md b/.junie/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/.junie/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/.kiro/skills/cavecrew/SKILL.md b/.kiro/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/.kiro/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/.roo/skills/cavecrew/SKILL.md b/.roo/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/.roo/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/agents/cavecrew-builder.md b/agents/cavecrew-builder.md index 3e5aee6..65750df 100644 --- a/agents/cavecrew-builder.md +++ b/agents/cavecrew-builder.md @@ -6,7 +6,7 @@ description: > scope. Returns caveman diff receipt. Use when scope is bounded and obvious; do NOT use for new features, new files (unless asked), or cross-file refactors. -tools: Read, Edit, Write, Grep, Glob +tools: [Read, Edit, Write, Grep, Glob] --- Caveman-ultra. Drop articles/filler. Code/paths exact, backticked. No narration. diff --git a/agents/cavecrew-investigator.md b/agents/cavecrew-investigator.md index 6f350da..be27498 100644 --- a/agents/cavecrew-investigator.md +++ b/agents/cavecrew-investigator.md @@ -5,7 +5,7 @@ description: > "what calls Y", "list all uses of Z", "map this directory". Output is caveman-compressed so the main thread eats ~60% fewer tokens than vanilla Explore. Refuses to suggest fixes. -tools: Read, Grep, Glob, Bash +tools: [Read, Grep, Glob, Bash] model: haiku --- diff --git a/agents/cavecrew-reviewer.md b/agents/cavecrew-reviewer.md index a6a2c94..97049ce 100644 --- a/agents/cavecrew-reviewer.md +++ b/agents/cavecrew-reviewer.md @@ -5,7 +5,7 @@ description: > no scope creep. Output format `path:line: : . .` Use for "review this PR", "review my diff", "audit this file". Skips formatting nits unless they change meaning. -tools: Read, Grep, Bash +tools: [Read, Grep, Bash] model: haiku --- diff --git a/bin/install.js b/bin/install.js new file mode 100755 index 0000000..3d08934 --- /dev/null +++ b/bin/install.js @@ -0,0 +1,850 @@ +#!/usr/bin/env node +// caveman — unified cross-platform installer. +// +// One Node script replaces the old install.sh + install.ps1 + hooks/install.sh +// + hooks/install.ps1 quartet. Single source of truth. Works on macOS, Linux, +// and Windows (PowerShell or cmd) without any of the bash/PS1 quoting bugs +// that previously broke the JSON merge step (issue #249). +// +// Distribution: +// Local clone: node bin/install.js [flags] +// curl|bash: delegated from install.sh shim → npx -y github:JuliusBrussee/caveman -- [flags] +// Windows: pwsh install.ps1 [flags] → same npx delegation +// +// Pure stdlib, zero npm runtime deps. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const child_process = require('child_process'); +const readline = require('readline'); + +const SETTINGS = require('./lib/settings'); + +const REPO = 'JuliusBrussee/caveman'; +const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; +const HOOKS_REMOTE = `${RAW_BASE}/hooks`; +const INIT_SCRIPT_URL = `${RAW_BASE}/tools/caveman-init.js`; +const MCP_SHRINK_PKG = 'caveman-shrink'; +// Hook files to copy. Statusline ships in both .sh (macOS/Linux) and .ps1 +// (Windows) flavors — copy both regardless of host OS so a roaming +// $CLAUDE_CONFIG_DIR (e.g. dotfiles repo) keeps working across platforms. +const HOOK_FILES = [ + 'package.json', + 'caveman-config.js', + 'caveman-activate.js', + 'caveman-mode-tracker.js', + 'caveman-stats.js', + 'caveman-statusline.sh', + 'caveman-statusline.ps1', +]; + +// ── Argv ─────────────────────────────────────────────────────────────────── +function parseArgs(argv) { + const opts = { + dryRun: false, force: false, skipSkills: false, + withHooks: 'auto', withInit: false, withMcpShrink: 'auto', + all: false, minimal: false, listOnly: false, noColor: false, + only: [], uninstall: false, nonInteractive: false, + configDir: null, help: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--dry-run': opts.dryRun = true; break; + case '--force': opts.force = true; break; + case '--skip-skills': opts.skipSkills = true; break; + case '--with-hooks': opts.withHooks = true; break; + case '--no-hooks': opts.withHooks = false; break; + case '--with-init': opts.withInit = true; break; + case '--with-mcp-shrink': opts.withMcpShrink = true; break; + case '--no-mcp-shrink': opts.withMcpShrink = false; break; + case '--all': opts.all = true; break; + case '--minimal': opts.minimal = true; break; + case '--list': opts.listOnly = true; break; + case '--no-color': opts.noColor = true; break; + case '--uninstall': case '-u': opts.uninstall = true; break; + case '--non-interactive': opts.nonInteractive = true; break; + case '-h': case '--help': opts.help = true; break; + case '--only': { + const v = argv[++i]; + if (!v) die('error: --only requires an argument'); + opts.only.push(v === 'aider' ? 'aider-desk' : v); + break; + } + case '--config-dir': { + const v = argv[++i]; + if (!v || v.startsWith('--')) die('error: --config-dir requires a path'); + opts.configDir = v; + break; + } + default: + die(`error: unknown flag: ${a}\nrun 'caveman --help' for usage`); + } + } + if (opts.all && opts.minimal) die('error: --all and --minimal are mutually exclusive'); + if (opts.all) { opts.withHooks = true; opts.withInit = true; opts.withMcpShrink = true; } + if (opts.minimal) { opts.withHooks = false; opts.withInit = false; opts.withMcpShrink = false; } + if (opts.withHooks === 'auto') opts.withHooks = true; + if (opts.withMcpShrink === 'auto') opts.withMcpShrink = true; + return opts; +} + +function die(msg) { process.stderr.write(msg + '\n'); process.exit(2); } + +// ── Color helpers ────────────────────────────────────────────────────────── +function makeChalk(noColor) { + const useColor = !noColor && process.stdout.isTTY && !process.env.NO_COLOR; + const wrap = (codes) => (s) => useColor ? `\x1b[${codes}m${s}\x1b[0m` : s; + return { + orange: wrap('38;5;172'), dim: wrap('2'), red: wrap('31'), + green: wrap('32'), yellow: wrap('33'), + }; +} + +// ── Env guards ───────────────────────────────────────────────────────────── +function checkWslWindowsNode() { + if (process.platform !== 'win32') return; + // Windows-Node executing inside WSL has homedir like /mnt/c/Users/... which + // breaks every config-dir resolution. Detect and abort with a clear hint. + if (process.env.WSL_DISTRO_NAME) { + die('caveman: detected Windows Node.js running inside WSL.\n' + + ' Install Linux-native Node inside your WSL distro and re-run there.\n' + + ' (WSL_DISTRO_NAME=' + process.env.WSL_DISTRO_NAME + ')'); + } + try { + const v = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); + if (v.includes('microsoft') || v.includes('wsl')) { + die('caveman: detected Windows Node.js running inside WSL (/proc/version).\n' + + ' Install Linux-native Node inside your WSL distro and re-run there.'); + } + } catch (_) { /* /proc/version absent on real Windows — fine */ } +} + +function checkNodeVersion() { + const major = parseInt(process.versions.node.split('.')[0], 10); + if (major < 18) die(`caveman: Node ${process.versions.node} too old. Need Node ≥18. https://nodejs.org`); +} + +// ── Provider matrix ──────────────────────────────────────────────────────── +// Single source of truth. Replaces the 6 parallel bash arrays in old install.sh. +// +// Detection rules: +// - `command:` — bin on PATH. Most reliable signal. +// - `vscode-ext:` / `cursor-ext:` — extension dir name match. +// - `jetbrains-plugin:` — JetBrains plugin dir match. +// - `dir:` / `file:` — kept ONLY for agents that ship no CLI +// and no extension marker (true dir-only signal). +// +// `soft: true` means detection is best-effort (config-dir only or no +// reliable probe). Soft providers are EXCLUDED from auto-detect and only +// install when the user passes `--only `. This stops the installer from +// firing `npx skills add ...` against agents the user has never installed +// just because some other tool created `~/.foo` along the way. +const PROVIDERS = [ + { id: 'claude', label: 'Claude Code', mech: 'claude plugin install', detect: 'command:claude' }, + { id: 'gemini', label: 'Gemini CLI', mech: 'gemini extensions install', detect: 'command:gemini' }, + { id: 'codex', label: 'Codex CLI', mech: 'npx skills add (codex)', detect: 'command:codex', profile: 'codex' }, + + // IDE / VS Code-family — extension probes are precise. Cursor/Windsurf also + // ship CLI binaries; we drop the dir fallback because the dir lingers after + // uninstall and false-positives heavily. + { id: 'cursor', label: 'Cursor', mech: 'npx skills add (cursor)', detect: 'command:cursor||macapp:Cursor', profile: 'cursor' }, + { id: 'windsurf', label: 'Windsurf', mech: 'npx skills add (windsurf)', detect: 'command:windsurf||macapp:Windsurf', profile: 'windsurf' }, + { id: 'cline', label: 'Cline', mech: 'npx skills add (cline)', detect: 'vscode-ext:cline', profile: 'cline' }, + { id: 'continue', label: 'Continue', mech: 'npx skills add (continue)', detect: 'vscode-ext:continue.continue||vscode-ext:continue', profile: 'continue' }, + { id: 'kilo', label: 'Kilo Code', mech: 'npx skills add (kilo)', detect: 'vscode-ext:kilocode', profile: 'kilo' }, + { id: 'roo', label: 'Roo Code', mech: 'npx skills add (roo)', detect: 'vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo', profile: 'roo' }, + { id: 'augment', label: 'Augment Code', mech: 'npx skills add (augment)', detect: 'vscode-ext:augment||jetbrains-plugin:augment', profile: 'augment' }, + + // GitHub Copilot — `gh` (GitHub CLI) is on most dev machines but isn't + // Copilot. There's no reliable always-on Copilot probe (subscription state + // is auth-gated). Mark soft → opt-in via --only copilot. + { id: 'copilot', label: 'GitHub Copilot', mech: 'npx skills add (github-copilot)', detect: 'command:copilot', profile: 'github-copilot', soft: true }, + + // CLI agents — require the binary. The `||dir:~/.foo` fallbacks were the + // main source of false positives (warp, kiro, junie etc. leave config dirs + // behind on uninstall). + { id: 'aider-desk', label: 'Aider Desk', mech: 'npx skills add (aider-desk)', detect: 'command:aider', profile: 'aider-desk' }, + { id: 'amp', label: 'Sourcegraph Amp', mech: 'npx skills add (amp)', detect: 'command:amp', profile: 'amp' }, + { id: 'bob', label: 'IBM Bob', mech: 'npx skills add (bob)', detect: 'command:bob', profile: 'bob' }, + { id: 'crush', label: 'Crush', mech: 'npx skills add (crush)', detect: 'command:crush', profile: 'crush' }, + { id: 'devin', label: 'Devin (terminal)', mech: 'npx skills add (devin)', detect: 'command:devin', profile: 'devin' }, + { id: 'droid', label: 'Droid (Factory)', mech: 'npx skills add (droid)', detect: 'command:droid', profile: 'droid' }, + { id: 'forgecode', label: 'ForgeCode', mech: 'npx skills add (forgecode)', detect: 'command:forge', profile: 'forgecode' }, + { id: 'goose', label: 'Block Goose', mech: 'npx skills add (goose)', detect: 'command:goose', profile: 'goose' }, + { id: 'iflow', label: 'iFlow CLI', mech: 'npx skills add (iflow-cli)', detect: 'command:iflow', profile: 'iflow-cli' }, + { id: 'kiro', label: 'Kiro CLI', mech: 'npx skills add (kiro-cli)', detect: 'command:kiro', profile: 'kiro-cli' }, + { id: 'mistral', label: 'Mistral Vibe', mech: 'npx skills add (mistral-vibe)', detect: 'command:mistral', profile: 'mistral-vibe' }, + { id: 'openhands', label: 'OpenHands', mech: 'npx skills add (openhands)', detect: 'command:openhands', profile: 'openhands' }, + { id: 'opencode', label: 'opencode', mech: 'npx skills add (opencode)', detect: 'command:opencode', profile: 'opencode' }, + { id: 'qwen', label: 'Qwen Code', mech: 'npx skills add (qwen-code)', detect: 'command:qwen', profile: 'qwen-code' }, + { id: 'rovodev', label: 'Atlassian Rovo Dev', mech: 'npx skills add (rovodev)', detect: 'command:rovodev', profile: 'rovodev' }, + { id: 'tabnine', label: 'Tabnine CLI', mech: 'npx skills add (tabnine-cli)', detect: 'command:tabnine', profile: 'tabnine-cli' }, + { id: 'trae', label: 'Trae', mech: 'npx skills add (trae)', detect: 'command:trae', profile: 'trae' }, + { id: 'warp', label: 'Warp', mech: 'npx skills add (warp)', detect: 'command:warp', profile: 'warp' }, + { id: 'replit', label: 'Replit Agent', mech: 'npx skills add (replit)', detect: 'command:replit', profile: 'replit' }, + + // Soft (opt-in via --only) — no reliable always-on probe. + // junie: ships only as a JetBrains plugin; jetbrains-plugin probe walks + // ~/.config/JetBrains looking for "junie" — fires on stale plugin caches. + // qoder: dir-only. + // antigravity: lives at ~/.gemini/antigravity which is created by the + // gemini CLI on first use — not a reliable signal of antigravity itself. + { id: 'junie', label: 'JetBrains Junie', mech: 'npx skills add (junie)', detect: 'jetbrains-plugin:junie', profile: 'junie', soft: true }, + { id: 'qoder', label: 'Qoder', mech: 'npx skills add (qoder)', detect: 'dir:$HOME/.qoder', profile: 'qoder', soft: true }, + { id: 'antigravity',label: 'Google Antigravity', mech: 'npx skills add (antigravity)', detect: 'dir:$HOME/.gemini/antigravity', profile: 'antigravity', soft: true }, +]; + +// ── Detection ───────────────────────────────────────────────────────────── +function hasCmd(cmd) { + const which = process.platform === 'win32' ? 'where' : 'command'; + const args = process.platform === 'win32' ? [cmd] : ['-v', cmd]; + try { + if (process.platform === 'win32') { + const r = child_process.spawnSync('where', [cmd], { stdio: 'ignore' }); + return r.status === 0; + } + const r = child_process.spawnSync('sh', ['-c', `command -v ${shellEscape(cmd)}`], { stdio: 'ignore' }); + return r.status === 0; + } catch (_) { return false; } + // unreachable; satisfy linters + void which; void args; +} + +function shellEscape(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } + +function expandHome(p) { return p.replace(/^\$HOME/, os.homedir()).replace(/^~/, os.homedir()); } + +function vscodeExtPresent(needle) { + const home = os.homedir(); + const roots = [ + path.join(home, '.vscode/extensions'), + path.join(home, '.vscode-server/extensions'), + path.join(home, '.cursor/extensions'), + path.join(home, '.windsurf/extensions'), + ]; + const re = new RegExp(needle, 'i'); + for (const r of roots) { + if (!fs.existsSync(r)) continue; + let entries; + try { entries = fs.readdirSync(r); } catch (_) { continue; } + if (entries.some(e => re.test(e))) return true; + } + return false; +} + +function cursorExtPresent(needle) { + const dir = path.join(os.homedir(), '.cursor/extensions'); + if (!fs.existsSync(dir)) return false; + const re = new RegExp(needle, 'i'); + try { return fs.readdirSync(dir).some(e => re.test(e)); } catch (_) { return false; } +} + +function jetbrainsPresent() { + const home = os.homedir(); + return fs.existsSync(path.join(home, 'Library/Application Support/JetBrains')) + || fs.existsSync(path.join(home, '.config/JetBrains')); +} + +function jetbrainsPluginPresent(needle) { + const home = os.homedir(); + const roots = [ + path.join(home, 'Library/Application Support/JetBrains'), + path.join(home, '.config/JetBrains'), + ]; + const re = new RegExp(needle, 'i'); + for (const r of roots) { + if (!fs.existsSync(r)) continue; + if (walkDir(r, 4).some(p => re.test(path.basename(p)))) return true; + } + return false; +} + +function walkDir(root, depth) { + const out = []; + if (depth < 0) return out; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { return out; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.isDirectory()) { out.push(full); out.push(...walkDir(full, depth - 1)); } + } + return out; +} + +function macAppPresent(name) { + if (process.platform !== 'darwin') return false; + const candidates = [ + `/Applications/${name}.app`, + path.join(os.homedir(), 'Applications', `${name}.app`), + ]; + return candidates.some(p => fs.existsSync(p)); +} + +function detectMatch(spec) { + if (!spec) return false; + for (const clause of spec.split('||')) { + const c = clause.trim(); + if (!c) continue; + const colon = c.indexOf(':'); + const kind = colon === -1 ? c : c.slice(0, colon); + const val = colon === -1 ? '' : expandHome(c.slice(colon + 1)); + let ok = false; + switch (kind) { + case 'command': ok = hasCmd(val); break; + case 'dir': ok = safeStat(val, 'isDirectory'); break; + case 'file': ok = safeStat(val, 'isFile'); break; + case 'macapp': ok = macAppPresent(val); break; + case 'vscode-ext': ok = vscodeExtPresent(val); break; + case 'cursor-ext': ok = cursorExtPresent(val); break; + case 'jetbrains-config': ok = jetbrainsPresent(); break; + case 'jetbrains-plugin': ok = jetbrainsPluginPresent(val); break; + } + if (ok) return true; + } + return false; +} + +function safeStat(p, method) { + try { return fs.statSync(p)[method](); } catch (_) { return false; } +} + +// ── Repo root resolution ─────────────────────────────────────────────────── +function detectRepoRoot() { + // bin/install.js sits at /bin/install.js. Walk up one. + const here = path.dirname(__filename); + const root = path.resolve(here, '..'); + if (fs.existsSync(path.join(root, 'hooks')) && + fs.existsSync(path.join(root, 'agents')) && + fs.existsSync(path.join(root, 'skills'))) { + return root; + } + return null; +} + +// ── Run helpers ──────────────────────────────────────────────────────────── +// On Windows, npm/npx/claude/gemini/codex etc. ship as `.cmd` batch shims. +// Node's spawnSync('claude', ...) returns ENOENT for these unless we either +// (a) set shell:true (cmd.exe respects PATHEXT) or +// (b) resolve the actual `.cmd` path before spawning. +// We pick (a) — simpler, fewer cross-version corner cases. The cost is that +// args with spaces need quoting; we quote them defensively below. +const IS_WIN = process.platform === 'win32'; + +function quoteWinArg(a) { + if (!IS_WIN) return a; + if (a === '' || /[\s"]/.test(a)) { + // Standard CommandLineToArgvW escaping + return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + return a; +} + +function spawnXplat(cmd, args, opts) { + if (IS_WIN) { + const quoted = args.map(quoteWinArg).join(' '); + return child_process.spawnSync(`${cmd} ${quoted}`, [], Object.assign({ shell: true }, opts || {})); + } + return child_process.spawnSync(cmd, args, opts || {}); +} + +function runSpawn(cmd, args, opts, dry) { + if (dry) { process.stdout.write(` would run: ${cmd} ${args.join(' ')}\n`); return { status: 0 }; } + process.stdout.write(` $ ${cmd} ${args.join(' ')}\n`); + return spawnXplat(cmd, args, Object.assign({ stdio: 'inherit' }, opts || {})); +} + +function captureSpawn(cmd, args) { + try { return spawnXplat(cmd, args, { encoding: 'utf8' }); } + catch (_) { return { status: 1, stdout: '', stderr: '' }; } +} + +function absoluteNodePath() { + return process.execPath; +} + +// ── Per-provider installers ──────────────────────────────────────────────── +function installClaude(ctx) { + const { say, note, warn, ok, opts, results } = ctx; + results.detected++; + say('→ Claude Code detected'); + + // Plugin install (idempotent unless --force) + let alreadyInstalled = false; + if (!opts.force) { + const r = captureSpawn('claude', ['plugin', 'list']); + if (r.status === 0 && /caveman/i.test(r.stdout || '')) alreadyInstalled = true; + } + if (alreadyInstalled) { + note(' caveman plugin already installed (use --force to reinstall)'); + results.skipped.push(['claude', 'plugin already installed']); + } else { + const r1 = runSpawn('claude', ['plugin', 'marketplace', 'add', REPO], null, opts.dryRun); + const r2 = runSpawn('claude', ['plugin', 'install', 'caveman@caveman'], null, opts.dryRun); + if ((r1.status || 0) === 0 && (r2.status || 0) === 0) results.installed.push('claude'); + else results.failed.push(['claude', 'claude plugin install failed']); + } + + if (opts.withHooks) { + say(' → installing hooks (--with-hooks)'); + const r = installHooks(ctx); + if (r === 'ok') results.installed.push('claude-hooks'); + else if (r === 'skip') results.skipped.push(['claude-hooks', 'already wired']); + else results.failed.push(['claude-hooks', r]); + } + + if (opts.withMcpShrink) { + say(' → wiring caveman-shrink MCP proxy (--with-mcp-shrink)'); + const r = installMcpShrink(ctx); + if (r.kind === 'ok') results.installed.push('caveman-shrink'); + if (r.kind === 'skip') results.skipped.push(['caveman-shrink', r.why]); + if (r.kind === 'fail') results.failed.push(['caveman-shrink', r.why]); + } + + process.stdout.write('\n'); +} + +function installGemini(ctx) { + const { say, note, opts, results } = ctx; + results.detected++; + say('→ Gemini CLI detected'); + + if (!opts.force) { + const r = captureSpawn('gemini', ['extensions', 'list']); + if (r.status === 0 && /caveman/i.test(r.stdout || '')) { + note(' caveman extension already installed (use --force to reinstall)'); + results.skipped.push(['gemini', 'extension already installed']); + process.stdout.write('\n'); + return; + } + } + const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`], null, opts.dryRun); + if ((r.status || 0) === 0) results.installed.push('gemini'); + else results.failed.push(['gemini', 'gemini extensions install failed']); + process.stdout.write('\n'); +} + +function installViaSkills(ctx, prov) { + const { say, opts, results } = ctx; + results.detected++; + say(`→ ${prov.label} detected`); + const r = runSpawn('npx', ['-y', 'skills', 'add', REPO, '-a', prov.profile], null, opts.dryRun); + if ((r.status || 0) === 0) results.installed.push(prov.id); + else results.failed.push([prov.id, `npx skills add (${prov.profile}) failed`]); + process.stdout.write('\n'); +} + +// ── Hooks installer ──────────────────────────────────────────────────────── +// Replaces hooks/install.sh + hooks/install.ps1. +function installHooks(ctx) { + const { note, warn, opts, repoRoot, configDir } = ctx; + const hooksDir = path.join(configDir, 'hooks'); + const settingsPath = path.join(configDir, 'settings.json'); + const sourceDir = repoRoot ? path.join(repoRoot, 'hooks') : null; + + if (opts.dryRun) { + note(` would mkdir -p ${hooksDir}`); + for (const f of HOOK_FILES) note(` would install ${path.join(hooksDir, f)}`); + note(` would merge SessionStart + UserPromptSubmit + statusline into ${settingsPath}`); + return 'ok'; + } + + fs.mkdirSync(hooksDir, { recursive: true }); + + // Copy or download each hook file. Local-clone-first for offline installs. + for (const f of HOOK_FILES) { + const dest = path.join(hooksDir, f); + if (sourceDir && fs.existsSync(path.join(sourceDir, f))) { + fs.copyFileSync(path.join(sourceDir, f), dest); + } else { + try { downloadTo(`${HOOKS_REMOTE}/${f}`, dest); } + catch (e) { return `download ${f} failed: ${e.message}`; } + } + process.stdout.write(` installed: ${dest}\n`); + } + + // chmod statusline (no-op on Windows) + try { fs.chmodSync(path.join(hooksDir, 'caveman-statusline.sh'), 0o755); } catch (_) {} + + // Merge into settings.json + let settings = SETTINGS.readSettings(settingsPath); + if (settings === null) { + warn(' settings.json unparseable; will not touch it. Edit manually then re-run.'); + return 'settings.json unparseable'; + } + // Backup once per install run + if (fs.existsSync(settingsPath)) { + try { fs.copyFileSync(settingsPath, settingsPath + '.bak'); } catch (_) {} + } + + const node = absoluteNodePath(); + const activate = path.join(hooksDir, 'caveman-activate.js'); + const tracker = path.join(hooksDir, 'caveman-mode-tracker.js'); + const statusline = path.join(hooksDir, 'caveman-statusline.sh'); + + // Migrate any legacy bare-`node` invocations of our managed scripts. + SETTINGS.rewriteLegacyManagedHookCommands(settings, node); + + SETTINGS.addCommandHook(settings, 'SessionStart', { + command: `"${node}" "${activate}"`, + marker: 'caveman-activate', + timeout: 5, + statusMessage: 'Loading caveman mode...', + }); + + SETTINGS.addCommandHook(settings, 'UserPromptSubmit', { + command: `"${node}" "${tracker}"`, + marker: 'caveman-mode-tracker', + timeout: 5, + statusMessage: 'Tracking caveman mode...', + }); + + // Statusline — set if absent or already pointing at our script. + // Windows: prefer pwsh (PowerShell 7+, cross-platform), fall back to + // powershell.exe (Windows PowerShell 5.1, ships with every Windows install). + // Use -ExecutionPolicy Bypass so users without RemoteSigned policy can run. + const psHost = IS_WIN && hasCmd('pwsh') ? 'pwsh' : (IS_WIN ? 'powershell' : null); + const slCmd = IS_WIN + ? `${psHost} -NoProfile -ExecutionPolicy Bypass -File "${path.join(hooksDir, 'caveman-statusline.ps1')}"` + : `bash "${statusline}"`; + if (!settings.statusLine) { + settings.statusLine = { type: 'command', command: slCmd }; + process.stdout.write(' statusline badge configured.\n'); + } else { + const existing = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (existing.includes(statusline) || existing.includes('caveman-statusline')) { + process.stdout.write(' statusline badge already configured.\n'); + } else { + process.stdout.write(' NOTE: existing statusline detected — caveman badge NOT added.\n'); + process.stdout.write(' See hooks/README.md to add the badge to your existing statusline.\n'); + } + } + + // Defensive validation before write — Claude Code Zod will discard the + // entire settings.json if any single hook is malformed (#249-class footgun). + SETTINGS.validateHookFields(settings); + SETTINGS.writeSettings(settingsPath, settings); + process.stdout.write(` hooks wired in ${settingsPath}\n`); + return 'ok'; +} + +// ── MCP shrink wiring ───────────────────────────────────────────────────── +function installMcpShrink(ctx) { + const { note, warn, opts } = ctx; + // Probe npm first — registry outage = clean skip with manual snippet. + const probe = captureSpawn('npm', ['view', MCP_SHRINK_PKG, 'name']); + if (probe.status !== 0) { + warn(` 'npm view ${MCP_SHRINK_PKG}' returned no metadata — registry unreachable or package missing.`); + note(' Skipping registration. Re-run --with-mcp-shrink when the registry is reachable.'); + return { kind: 'skip', why: 'npm registry probe failed' }; + } + // Detect modern `claude mcp add` + const help = captureSpawn('claude', ['mcp', '--help']); + if (help.status !== 0) { + note(" 'claude mcp add' not available on this CLI. Add the snippet from"); + note(' hooks/README.md to your Claude Code MCP config manually.'); + return { kind: 'skip', why: 'manual config required' }; + } + const r = runSpawn('claude', ['mcp', 'add', 'caveman-shrink', '--', 'npx', '-y', MCP_SHRINK_PKG], null, opts.dryRun); + if ((r.status || 0) === 0) { + note(' registered. Wrap an upstream by editing the mcpServers entry — see:'); + note(` https://github.com/${REPO}/tree/main/mcp-servers/caveman-shrink`); + return { kind: 'ok' }; + } + return { kind: 'fail', why: 'claude mcp add failed' }; +} + +// ── Init writers (per-repo rule files) ──────────────────────────────────── +function runInit(ctx) { + const { note, warn, opts, repoRoot } = ctx; + const local = repoRoot && path.join(repoRoot, 'tools/caveman-init.js'); + const args = [process.cwd()]; + if (opts.dryRun) args.push('--dry-run'); + if (opts.force) args.push('--force'); + if (local && fs.existsSync(local)) { + const r = runSpawn(absoluteNodePath(), [local, ...args], null, opts.dryRun); + return (r.status || 0) === 0; + } + // Curl-pipe fallback + if (opts.dryRun) { + note(` would download ${INIT_SCRIPT_URL} and run it on ${process.cwd()}`); + return true; + } + try { + const tmp = path.join(os.tmpdir(), `caveman-init-${process.pid}.js`); + downloadTo(INIT_SCRIPT_URL, tmp); + const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' }); + try { fs.unlinkSync(tmp); } catch (_) {} + return (r.status || 0) === 0; + } catch (e) { + warn(' ' + e.message); + return false; + } +} + +// ── HTTPS download via stdlib ───────────────────────────────────────────── +function downloadTo(url, dest) { + // Prefer curl/wget when available (better proxy + cert handling on legacy + // systems); fall back to Node https. + if (hasCmd('curl')) { + const r = child_process.spawnSync('curl', ['-fsSL', '-o', dest, url], { stdio: 'inherit' }); + if (r.status === 0) return; + throw new Error(`curl failed for ${url}`); + } + const https = require('https'); + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(downloadTo(res.headers.location, dest)); + return; + } + if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; } + const out = fs.createWriteStream(dest); + res.pipe(out); + out.on('finish', () => out.close(resolve)); + out.on('error', reject); + }); + req.on('error', reject); + }); +} + +// ── Uninstall ───────────────────────────────────────────────────────────── +function uninstall(ctx) { + const { say, note, warn, ok, opts, configDir } = ctx; + say('🪨 caveman uninstall'); + + if (opts.dryRun) note(' (dry run — nothing will be removed)'); + + // Hooks: remove from settings.json + delete hook files. + const hooksDir = path.join(configDir, 'hooks'); + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = SETTINGS.readSettings(settingsPath); + if (settings) { + const removed = SETTINGS.removeCavemanHooks(settings, 'caveman'); + // Drop our statusline if it points at our script + if (settings.statusLine) { + const cmd = typeof settings.statusLine === 'string' ? settings.statusLine : (settings.statusLine.command || ''); + if (cmd.includes('caveman-statusline')) delete settings.statusLine; + } + SETTINGS.validateHookFields(settings); + if (!opts.dryRun) SETTINGS.writeSettings(settingsPath, settings); + ok(` removed ${removed} caveman hook entr${removed === 1 ? 'y' : 'ies'} from settings.json`); + } + } + + if (fs.existsSync(hooksDir)) { + for (const f of HOOK_FILES) { + const p = path.join(hooksDir, f); + if (!fs.existsSync(p)) continue; + if (!opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } + note(` removed ${p}`); + } + // Don't rmdir hooksDir — other plugins may use it. + } + + // Plugin uninstall on Claude + if (hasCmd('claude')) { + const r = runSpawn('claude', ['plugin', 'uninstall', 'caveman@caveman'], null, opts.dryRun); + if ((r.status || 0) === 0) ok(' removed claude plugin'); + } + + // Gemini extension + if (hasCmd('gemini')) { + runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun); + } + + // Flag file + const flag = path.join(configDir, '.caveman-active'); + if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} } + + process.stdout.write('\n'); + ok('uninstall done.'); + ok('npx-skills installs (Cursor/Windsurf/etc.) — remove via your IDE\'s skill manager'); + ok('per-repo init files (.cursor/, .windsurf/, AGENTS.md) — remove with your editor'); +} + +// ── Interactive prompt (TTY-only) ───────────────────────────────────────── +async function promptForOnly(detected) { + if (!process.stdin.isTTY || !process.stdout.isTTY) return null; + if (detected.length === 0) return null; + process.stdout.write('\nDetected agents:\n'); + detected.forEach((p, i) => process.stdout.write(` [${i + 1}] ${p.label}\n`)); + process.stdout.write(' [a] all [q] quit\n'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ans = await new Promise(res => rl.question('Install which? (default: all) ', res)); + rl.close(); + const t = (ans || '').trim().toLowerCase(); + if (t === 'q') process.exit(0); + if (t === '' || t === 'a' || t === 'all') return null; + const picks = t.split(/[\s,]+/).map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= detected.length); + if (picks.length === 0) return null; + return picks.map(n => detected[n - 1].id); +} + +// ── --list ───────────────────────────────────────────────────────────────── +function printList(noColor) { + const c = makeChalk(noColor); + process.stdout.write(c.orange('🪨 caveman provider matrix') + '\n\n'); + process.stdout.write(` ${pad('ID', 13)} ${pad('AGENT', 22)} INSTALL MECHANISM\n`); + process.stdout.write(` ${pad('--', 13)} ${pad('-----', 22)} -----------------\n`); + for (const p of PROVIDERS) { + const tag = p.soft ? ' (soft)' : ''; + process.stdout.write(` ${pad(p.id, 13)} ${pad(p.label, 22)} ${p.mech}${tag}\n`); + } + process.stdout.write('\n'); + process.stdout.write(c.dim(' Defaults: --with-hooks ON, --with-mcp-shrink ON, --with-init OFF.\n')); + process.stdout.write(c.dim(' --all turns all three on, --minimal turns all three off.\n')); +} + +function pad(s, n) { s = String(s); return s + ' '.repeat(Math.max(0, n - s.length)); } + +// ── Help ─────────────────────────────────────────────────────────────────── +function printHelp() { + process.stdout.write(`caveman installer — detects your agents and installs caveman for each one. + +USAGE + npx -y github:JuliusBrussee/caveman -- [flags] + node bin/install.js [flags] + bash install.sh [flags] # shim → npx + pwsh install.ps1 [flags] # shim → npx + +FLAGS + --dry-run Print what would run, do nothing. + --force Re-run even if a target reports already installed. + --only Install only for the named agent. Repeatable. + --skip-skills Don't run the npx-skills auto-detect fallback. + --all Turn on hooks + init + mcp-shrink. + --minimal Just the plugin/extension install. + --with-hooks Claude Code: install SessionStart/UserPromptSubmit hooks + + statusline badge. (Default ON.) + --no-hooks Skip the hooks installer. + --with-init Write per-repo IDE rule files into \$PWD. + --with-mcp-shrink Claude Code: register caveman-shrink MCP proxy. (Default ON.) + --no-mcp-shrink Skip MCP shrink. + --uninstall, -u Remove caveman from this machine. + --config-dir Use this dir as Claude config dir (default: \$CLAUDE_CONFIG_DIR or ~/.claude). + --non-interactive Never prompt; use defaults. (Auto when stdin is not a TTY.) + --list Print provider matrix and exit. + --no-color Disable ANSI colors. + -h, --help Show this help. + +EXAMPLES + npx -y github:JuliusBrussee/caveman # default install + npx -y github:JuliusBrussee/caveman -- --all # all the trimmings + npx -y github:JuliusBrussee/caveman -- --only claude --no-mcp-shrink + npx -y github:JuliusBrussee/caveman -- --uninstall + + Issues: https://github.com/${REPO}/issues +`); +} + +// ── Main ─────────────────────────────────────────────────────────────────── +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const c = makeChalk(opts.noColor); + if (opts.help) { printHelp(); return 0; } + if (opts.listOnly) { printList(opts.noColor); return 0; } + + checkWslWindowsNode(); + checkNodeVersion(); + + const configDir = opts.configDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const repoRoot = detectRepoRoot(); + + const ctx = { + opts, configDir, repoRoot, + say: (s) => process.stdout.write(c.orange(s) + '\n'), + note: (s) => process.stdout.write(c.dim(s) + '\n'), + warn: (s) => process.stderr.write(c.red(s) + '\n'), + ok: (s) => process.stdout.write(c.green(s) + '\n'), + results: { installed: [], skipped: [], failed: [], detected: 0 }, + }; + + if (opts.uninstall) { uninstall(ctx); return 0; } + + ctx.say('🪨 caveman installer'); + ctx.note(` ${REPO}`); + if (opts.dryRun) ctx.note(' (dry run — nothing will be written)'); + process.stdout.write('\n'); + + // Detect everything once + const detected = PROVIDERS.filter(p => detectMatch(p.detect)); + + // TTY-only multi-select prompt when no --only and no --non-interactive. + if (opts.only.length === 0 && !opts.nonInteractive) { + const picks = await promptForOnly(detected); + if (picks) opts.only = picks; + } + + const want = (id) => opts.only.length === 0 || opts.only.includes(id); + const explicit = (id) => opts.only.includes(id); + + // Run installs in declared order. Soft providers (no reliable detect probe) + // are auto-skipped — user must opt in via `--only `. Stops the installer + // from firing `npx skills add ...` against agents the user never installed + // just because some other tool created `~/.foo` along the way. + for (const prov of PROVIDERS) { + if (!want(prov.id)) continue; + if (prov.soft && !explicit(prov.id)) continue; + if (!detectMatch(prov.detect)) continue; + if (prov.id === 'claude') { installClaude(ctx); continue; } + if (prov.id === 'gemini') { installGemini(ctx); continue; } + if (prov.profile) { installViaSkills(ctx, prov); continue; } + } + + // Auto-detect fallback if nothing matched + if (!opts.skipSkills && opts.only.length === 0 && ctx.results.detected === 0) { + ctx.say('→ no known agents detected — running npx-skills auto-detect fallback'); + const r = runSpawn('npx', ['-y', 'skills', 'add', REPO], null, opts.dryRun); + if ((r.status || 0) === 0) ctx.results.installed.push('skills-auto'); + else ctx.results.failed.push(['skills-auto', 'npx skills add (auto) failed']); + process.stdout.write('\n'); + } + + // Per-repo init + if (opts.withInit) { + ctx.say(`→ writing per-repo IDE rule files into ${process.cwd()} (--with-init)`); + if (runInit(ctx)) ctx.results.installed.push(`caveman-init (${process.cwd()})`); + else ctx.results.failed.push(['caveman-init', 'tools/caveman-init.js failed']); + process.stdout.write('\n'); + } else if (ctx.results.installed.length || ctx.results.skipped.length) { + ctx.note(' tip: re-run inside a repo with --all (or --with-init) to also write per-repo'); + ctx.note(' Cursor/Windsurf/Cline/Copilot/AGENTS.md rule files.'); + } + + // Summary + process.stdout.write('\n'); + ctx.say('🪨 done'); + if (ctx.results.installed.length) { + ctx.ok(' installed:'); + for (const a of ctx.results.installed) process.stdout.write(` • ${a}\n`); + } + if (ctx.results.skipped.length) { + process.stdout.write(' skipped:\n'); + for (const [id, why] of ctx.results.skipped) process.stdout.write(` • ${id} — ${why}\n`); + } + if (ctx.results.failed.length) { + ctx.warn(' failed:'); + for (const [id, why] of ctx.results.failed) process.stderr.write(` • ${id} — ${why}\n`); + } + if (!ctx.results.installed.length && !ctx.results.skipped.length && !ctx.results.failed.length) { + process.stdout.write(' nothing detected. run with --list to see all 30+ supported agents,\n'); + process.stdout.write(' or pass --only to force a specific target.\n'); + } + process.stdout.write('\n'); + ctx.note(" start any session and say 'caveman mode', or run /caveman in Claude Code"); + ctx.note(` uninstall: npx -y github:${REPO} -- --uninstall`); + + // Exit code: nonzero only if every detected agent failed + if (ctx.results.detected > 0 && !ctx.results.installed.length && !ctx.results.skipped.length) return 1; + return 0; +} + +main().then(code => process.exit(code || 0)) + .catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); }); diff --git a/bin/lib/settings.js b/bin/lib/settings.js new file mode 100644 index 0000000..595540f --- /dev/null +++ b/bin/lib/settings.js @@ -0,0 +1,221 @@ +// caveman — JSONC-tolerant settings.json read/write + defensive hook validation. +// +// Lifted in spirit from gsd-build/get-shit-done's stripJsonComments + readSettings. +// Reused by bin/install.js and (optionally) by hooks/caveman-activate.js so a +// commented settings.json no longer crashes the installer or the runtime hooks. +// +// Public API: +// readSettings(path) → object, {}, or null on hard parse failure +// writeSettings(path, obj) → atomic write with newline +// stripJsonComments(src) → string with // and /* */ stripped (string-aware) +// validateHookFields(settings) → mutates: drops malformed hook entries +// hasCavemanHook(settings, ev) → idempotency probe +// addCommandHook(settings, ev, opts) → no-op if substring marker already present +// removeCavemanHooks(settings) → uninstall helper +// +// Pure stdlib, CommonJS, Node ≥14. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +// ── stripJsonComments ────────────────────────────────────────────────────── +// Hand-rolled state machine. Tracks string state + backslash escape so a +// comment-looking sequence inside a quoted string is left alone. Removes +// trailing commas in a final pass — JSONC tolerates those, JSON.parse does not. +function stripJsonComments(src) { + if (typeof src !== 'string') return src; + let out = ''; + let i = 0; + const n = src.length; + let inString = false; + let stringChar = ''; + let inLine = false; + let inBlock = false; + while (i < n) { + const c = src[i]; + const next = i + 1 < n ? src[i + 1] : ''; + if (inLine) { + if (c === '\n') { inLine = false; out += c; } + i++; continue; + } + if (inBlock) { + if (c === '*' && next === '/') { inBlock = false; i += 2; continue; } + i++; continue; + } + if (inString) { + out += c; + if (c === '\\') { if (i + 1 < n) { out += src[i + 1]; i += 2; continue; } } + if (c === stringChar) { inString = false; } + i++; continue; + } + if (c === '"' || c === "'") { inString = true; stringChar = c; out += c; i++; continue; } + if (c === '/' && next === '/') { inLine = true; i += 2; continue; } + if (c === '/' && next === '*') { inBlock = true; i += 2; continue; } + out += c; i++; + } + // Trailing-comma sweep — only outside strings, but stripping happened above + // so a regex over the comment-free output is safe. + return out.replace(/,(\s*[}\]])/g, '$1'); +} + +// ── readSettings ─────────────────────────────────────────────────────────── +// Try strict JSON first (fast path). On failure, strip comments and retry. +// On total failure return `null` and warn — never silently overwrite a +// malformed-but-recoverable file with `{}`. +function readSettings(p) { + if (!fs.existsSync(p)) return {}; + let raw; + try { raw = fs.readFileSync(p, 'utf8'); } + catch (e) { + process.stderr.write(`caveman: cannot read ${p}: ${e.message}\n`); + return null; + } + if (!raw.trim()) return {}; + try { return JSON.parse(raw); } catch (_) { /* fall through to JSONC */ } + try { return JSON.parse(stripJsonComments(raw)); } + catch (e) { + process.stderr.write(`caveman: warning — ${p} is not valid JSON or JSONC: ${e.message}\n`); + return null; + } +} + +// ── writeSettings ────────────────────────────────────────────────────────── +// Atomic write: temp file + rename. mode 0600 (settings often contains tokens). +function writeSettings(p, obj) { + const dir = path.dirname(p); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.${path.basename(p)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`); + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, p); +} + +// ── validateHookFields ──────────────────────────────────────────────────── +// Claude Code uses strict Zod on settings.json — a single malformed hook +// silently discards the entire file. Mutate-to-valid before write. +// +// Required shape (per Claude Code docs): +// settings.hooks[event] = [{ hooks: [{ type:'command', command:'…', timeout?:n }, ...] }, ...] +// settings.hooks[event] = [{ matcher?:'…', hooks: [...] }, ...] // also valid +function validateHookFields(settings) { + if (!settings || typeof settings !== 'object') return settings; + if (!settings.hooks || typeof settings.hooks !== 'object') return settings; + for (const ev of Object.keys(settings.hooks)) { + const arr = settings.hooks[ev]; + if (!Array.isArray(arr)) { delete settings.hooks[ev]; continue; } + settings.hooks[ev] = arr.filter(entry => { + if (!entry || typeof entry !== 'object') return false; + if (!Array.isArray(entry.hooks)) return false; + entry.hooks = entry.hooks.filter(h => { + if (!h || typeof h !== 'object') return false; + if (h.type === 'command') return typeof h.command === 'string' && h.command.length > 0; + if (h.type === 'agent') return typeof h.prompt === 'string' && h.prompt.length > 0; + return false; + }); + return entry.hooks.length > 0; + }); + if (settings.hooks[ev].length === 0) delete settings.hooks[ev]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + return settings; +} + +// ── Idempotency probe ────────────────────────────────────────────────────── +function hasCavemanHook(settings, event, marker = 'caveman') { + const arr = settings && settings.hooks && settings.hooks[event]; + if (!Array.isArray(arr)) return false; + return arr.some(e => + e && Array.isArray(e.hooks) && + e.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker)) + ); +} + +// ── addCommandHook ──────────────────────────────────────────────────────── +// Idempotent push. `marker` defaults to opts.command — pass an explicit +// shorter substring (e.g. the script basename) when the full command path +// might rotate across reinstalls. +function addCommandHook(settings, event, opts) { + if (!settings.hooks) settings.hooks = {}; + if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = []; + const marker = opts.marker || opts.command; + if (hasCavemanHook(settings, event, marker)) return false; + const hook = { type: 'command', command: opts.command }; + if (typeof opts.timeout === 'number') hook.timeout = opts.timeout; + if (typeof opts.statusMessage === 'string') hook.statusMessage = opts.statusMessage; + settings.hooks[event].push({ hooks: [hook] }); + return true; +} + +// ── removeCavemanHooks ──────────────────────────────────────────────────── +// Strip every entry whose any hook command mentions `marker`. Empties events. +function removeCavemanHooks(settings, marker = 'caveman') { + if (!settings || !settings.hooks) return 0; + let removed = 0; + for (const ev of Object.keys(settings.hooks)) { + const before = settings.hooks[ev].length; + settings.hooks[ev] = settings.hooks[ev].filter(entry => { + if (!entry || !Array.isArray(entry.hooks)) return true; + return !entry.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker)); + }); + removed += before - settings.hooks[ev].length; + if (settings.hooks[ev].length === 0) delete settings.hooks[ev]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + return removed; +} + +// ── rewriteLegacyManagedHookCommands ────────────────────────────────────── +// Walk every hook command. If it's a bare `node /path/to/.js` (no +// absolute node path) and the basename is one of ours, rewrite to use +// `absoluteNode` so GUI launchers with minimal PATH still find Node. Only +// touches commands matching the exact bare-node shape — won't false-positive +// on user-authored hooks that just happen to mention "caveman". +const MANAGED_HOOK_BASENAMES = new Set([ + 'caveman-activate.js', + 'caveman-mode-tracker.js', + 'caveman-stats.js', + 'caveman-statusline.sh', +]); +function rewriteLegacyManagedHookCommands(settings, absoluteNode) { + if (!settings || !settings.hooks || !absoluteNode) return 0; + let rewritten = 0; + const reBare = /^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/; + for (const ev of Object.keys(settings.hooks)) { + for (const entry of settings.hooks[ev]) { + if (!entry || !Array.isArray(entry.hooks)) continue; + for (const h of entry.hooks) { + if (!h || typeof h.command !== 'string') continue; + const m = reBare.exec(h.command); + if (!m) continue; + const scriptPath = m[2] || m[3] || m[4]; + const basename = path.basename(scriptPath); + if (!MANAGED_HOOK_BASENAMES.has(basename)) continue; + h.command = `"${absoluteNode}" "${scriptPath}"`; + rewritten++; + } + } + } + return rewritten; +} + +// ── claudeConfigDir ─────────────────────────────────────────────────────── +function claudeConfigDir() { + if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR; + return path.join(os.homedir(), '.claude'); +} + +module.exports = { + stripJsonComments, + readSettings, + writeSettings, + validateHookFields, + hasCavemanHook, + addCommandHook, + removeCavemanHooks, + rewriteLegacyManagedHookCommands, + claudeConfigDir, + MANAGED_HOOK_BASENAMES, +}; diff --git a/install.ps1 b/install.ps1 index ce57a9d..a5fe086 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,634 +1,59 @@ -# caveman — smart multi-agent installer (Windows / PowerShell). +# caveman — installer shim (Windows / PowerShell). # -# One line: +# Thin wrapper around bin/install.js (the unified Node installer). Every flag +# you'd pass to bin/install.js can be passed here; we just forward them. +# +# One-line install: # irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex # -# Detects which AI coding agents are on your machine and installs caveman for -# each one using its native distribution (plugin / extension / skill / rule -# file). Skips agents that aren't installed. Safe to re-run — each underlying -# install command is idempotent. +# Local clone: +# pwsh install.ps1 [flags] # -# Run `install.ps1 -Help` for the full reference (flags + agent matrix). -# -# Defaults: -WithHooks ON, -WithMcpShrink ON (when Claude Code is detected), -# -WithInit OFF. Use -Minimal to skip everything except the plugin/extension -# install. Use -All to also drop per-repo rule files into $PWD. +# Why a Node installer? install.sh + install.ps1 used to be parallel sources of +# truth and constantly drifted (issue #249 was a `node -e "..."` quoting bug +# that silently dropped the JSON merge step on every Windows install). One +# Node script works everywhere without quoting bugs. [CmdletBinding()] param( - [switch]$DryRun, - [switch]$Force, - [switch]$SkipSkills, - [switch]$WithHooks, - [switch]$NoHooks, - [switch]$WithInit, - [switch]$WithMcpShrink, - [switch]$NoMcpShrink, - [switch]$All, - [switch]$Minimal, - [switch]$List, - [switch]$NoColor, - [switch]$Help, - [string[]]$Only = @() + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Args ) $ErrorActionPreference = "Stop" $Repo = "JuliusBrussee/caveman" -$RawBase = "https://raw.githubusercontent.com/$Repo/main" -$HooksInstallUrl = "$RawBase/hooks/install.ps1" -$InitScriptUrl = "$RawBase/tools/caveman-init.js" -$McpShrinkPkg = "caveman-shrink" -# ── Help ──────────────────────────────────────────────────────────────────── -if ($Help) { -@" -caveman installer (Windows) — detects your agents and installs caveman for each. - -USAGE - install.ps1 [-DryRun] [-Force] [-Only [,]] [-All] [-Minimal] - [-WithHooks] [-NoHooks] [-WithInit] [-WithMcpShrink] [-NoMcpShrink] - [-SkipSkills] [-List] [-NoColor] - - irm $RawBase/install.ps1 | iex - -FLAGS - -DryRun Print what would run, do nothing. - -Force Re-run even if a target reports "already installed". - -Only Comma-separated agent ids. Repeatable / array. - -All Turn on -WithHooks, -WithInit, -WithMcpShrink together. - -Minimal Skip hooks, MCP shrink, per-repo init. Plugin/extension only. - -WithHooks Claude Code: also wire SessionStart/UserPromptSubmit hooks - + statusline + stats badge. ON by default. - -NoHooks Opt out of the default-on hooks install. - -WithMcpShrink Claude Code: register caveman-shrink MCP proxy. ON by default. - -NoMcpShrink Opt out of the default-on MCP shrink registration. - -WithInit Drop per-repo rule files into `$PWD for Cursor / Windsurf / - Cline / Copilot / AGENTS.md. OFF by default. - -SkipSkills Don't run the npx-skills auto-detect fallback. - -List Print the full provider matrix and exit. - -NoColor Disable ANSI color codes. - -EXAMPLES - install.ps1 # default: plugin + hooks + MCP shrink - install.ps1 -All # also drop per-repo rule files - install.ps1 -Minimal # plugin/extension only - install.ps1 -DryRun -All - install.ps1 -Only claude -WithMcpShrink - install.ps1 -Only cursor,windsurf -WithInit - install.ps1 -List - -URLS THE INSTALLER MAY FETCH FROM - $RawBase/install.ps1 - $RawBase/hooks/install.ps1 - $RawBase/tools/caveman-init.js -"@ | Write-Host - exit 0 -} - -# ── Resolve -All / -Minimal / default-auto switches ──────────────────────── -if ($All -and $Minimal) { - Write-Error "-All and -Minimal are mutually exclusive." - exit 2 -} -if ($All) { - $WithHooks = $true - $WithInit = $true - $WithMcpShrink = $true -} -# Default-auto: turn ON unless caller passed -Minimal or the explicit -No* -# opt-out switch. -if (-not $WithHooks -and -not $NoHooks -and -not $Minimal) { - $WithHooks = $true -} -if (-not $WithMcpShrink -and -not $NoMcpShrink -and -not $Minimal) { - $WithMcpShrink = $true -} -if ($Minimal) { - $WithHooks = $false - $WithMcpShrink = $false - $WithInit = $false -} - -# ── Color helpers ────────────────────────────────────────────────────────── -$Esc = [char]27 -function Say($msg) { - if ($NoColor) { Write-Host $msg } - else { Write-Host "$Esc[38;5;172m$msg$Esc[0m" } -} -function Note($msg) { - if ($NoColor) { Write-Host $msg } - else { Write-Host "$Esc[2m$msg$Esc[0m" } -} -function Warn($msg) { - if ($NoColor) { Write-Host $msg } - else { Write-Host "$Esc[31m$msg$Esc[0m" } -} -function Ok($msg) { - if ($NoColor) { Write-Host $msg } - else { Write-Host "$Esc[32m$msg$Esc[0m" } -} - -# ── State ─────────────────────────────────────────────────────────────────── -$OnlyList = @() -foreach ($o in $Only) { - foreach ($x in ($o -split ',')) { - $t = $x.Trim() - if ($t) { - # Backward-compat alias (matches install.sh). - if ($t -eq "aider") { $t = "aider-desk" } - $OnlyList += $t - } - } -} - -$InstalledIds = @() -$SkippedIds = @() -$SkippedWhy = @() -$FailedIds = @() -$FailedWhy = @() -$DetectedCount = 0 - -function Want([string]$id) { - if ($OnlyList.Count -eq 0) { return $true } - return $OnlyList -contains $id -} - -function Has-Cmd([string]$c) { - return [bool](Get-Command $c -ErrorAction SilentlyContinue) -} - -# Detect repo root if running from a clone (vs irm | iex from raw.github). -function Get-RepoRoot { - $src = $PSCommandPath - if ($src -and (Test-Path $src)) { - $d = Split-Path -Parent $src - if ((Test-Path (Join-Path $d "install.ps1")) -and - (Test-Path (Join-Path $d "hooks")) -and - (Test-Path (Join-Path $d "tools"))) { - return $d - } - } - return $null -} -$RepoRoot = Get-RepoRoot - -# ── Run helpers ───────────────────────────────────────────────────────────── -# Run a process, return $true if exit 0. Honors -DryRun. Errors do not throw. -# `$Args` is an automatic in PowerShell — name the param `$Argv` to avoid the -# implicit-collision warning under strict analysis. -function Try-Run { - param([string]$Exe, [string[]]$Argv) - if ($DryRun) { - Note " would run: $Exe $($Argv -join ' ')" - return $true - } - Write-Host " $ $Exe $($Argv -join ' ')" - try { - & $Exe @Argv - return ($LASTEXITCODE -eq 0) - } catch { - Write-Host " $($_.Exception.Message)" -ForegroundColor Red - return $false - } -} - -function Record-Installed([string]$id) { $script:InstalledIds += $id } -function Record-Skipped([string]$id, [string]$why) { - $script:SkippedIds += $id - $script:SkippedWhy += $why -} -function Record-Failed([string]$id, [string]$why) { - $script:FailedIds += $id - $script:FailedWhy += $why -} - -function Ensure-Node { - if ((Has-Cmd "node") -and (Has-Cmd "npx")) { return $true } - Warn " node + npx required for this target — install Node.js (https://nodejs.org) and re-run." - return $false -} - -# ── Detection helpers ─────────────────────────────────────────────────────── -$VsCodeExtRoots = @( - (Join-Path $HOME ".vscode\extensions"), - (Join-Path $HOME ".vscode-server\extensions"), - (Join-Path $HOME ".cursor\extensions"), - (Join-Path $HOME ".windsurf\extensions") -) - -function Test-VscodeExt([string]$needle) { - foreach ($r in $VsCodeExtRoots) { - if (Test-Path $r) { - $found = Get-ChildItem -Path $r -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match [regex]::Escape($needle) } - if ($found) { return $true } - } - } - return $false -} - -function Test-CursorExt([string]$needle) { - $r = Join-Path $HOME ".cursor\extensions" - if (Test-Path $r) { - $found = Get-ChildItem -Path $r -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match [regex]::Escape($needle) } - if ($found) { return $true } - } - return $false -} - -# JetBrains config roots: Windows uses %APPDATA%\JetBrains, but cover the WSL -# bridge (~/.config/JetBrains) and macOS-on-PowerShell-Core path too so users -# running pwsh on different OSes get the same matrix. -$JetbrainsRoots = @( - (Join-Path $env:APPDATA "JetBrains"), - (Join-Path $HOME ".config\JetBrains"), - (Join-Path $HOME "Library/Application Support/JetBrains") -) - -function Test-JetbrainsConfig { - foreach ($r in $JetbrainsRoots) { - if ($r -and (Test-Path $r)) { return $true } - } - return $false -} - -function Test-JetbrainsPlugin([string]$needle) { - foreach ($r in $JetbrainsRoots) { - if ($r -and (Test-Path $r)) { - $found = Get-ChildItem -Path $r -Recurse -Directory -Depth 4 -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match [regex]::Escape($needle) } - if ($found) { return $true } - } - } - return $false -} - -# Resolve a detect spec like "command:foo||dir:~/.bar||vscode-ext:baz". -# Spec strings use $HOME / $env:HOME tokens that we expanded at build time — -# they're already absolute by the time they reach this function. -function Resolve-DetectSpec([string]$spec) { - if ([string]::IsNullOrWhiteSpace($spec)) { return $false } - foreach ($clause in ($spec -split '\|\|')) { - $c = $clause.Trim() - if (-not $c) { continue } - if ($c -match '^command:(.+)$') { if (Has-Cmd $matches[1]) { return $true } } - elseif ($c -match '^dir:(.+)$') { if (Test-Path $matches[1] -PathType Container) { return $true } } - elseif ($c -match '^file:(.+)$') { if (Test-Path $matches[1] -PathType Leaf) { return $true } } - elseif ($c -match '^vscode-ext:(.+)$') { if (Test-VscodeExt $matches[1]) { return $true } } - elseif ($c -match '^cursor-ext:(.+)$') { if (Test-CursorExt $matches[1]) { return $true } } - elseif ($c -eq 'jetbrains-config') { if (Test-JetbrainsConfig) { return $true } } - elseif ($c -match '^jetbrains-plugin:(.+)$') { if (Test-JetbrainsPlugin $matches[1]) { return $true } } - } - return $false -} - -# ── Provider matrix (mirror of install.sh PROVIDER_*) ────────────────────── -# Keep this aligned with install.sh row-for-row. Columns: -# id, label, profile (npx-skills slug or empty for non-skills), detect, -# soft (1 = config-dir-only probe, no CLI on PATH). -$Providers = @( - @{ id='claude'; label='Claude Code'; profile=''; detect='command:claude'; soft=0 }, - @{ id='gemini'; label='Gemini CLI'; profile=''; detect='command:gemini'; soft=0 }, - @{ id='codex'; label='Codex CLI'; profile='codex'; detect='command:codex'; soft=0 }, - @{ id='cursor'; label='Cursor'; profile='cursor'; detect="command:cursor||dir:$HOME\.cursor"; soft=0 }, - @{ id='windsurf'; label='Windsurf'; profile='windsurf'; detect="command:windsurf||dir:$HOME\.codeium\windsurf||dir:$HOME\.windsurf"; soft=0 }, - @{ id='cline'; label='Cline'; profile='cline'; detect='vscode-ext:cline'; soft=0 }, - @{ id='copilot'; label='GitHub Copilot'; profile='github-copilot'; detect='command:gh'; soft=0 }, - @{ id='continue'; label='Continue'; profile='continue'; detect='vscode-ext:continue.continue||vscode-ext:continue'; soft=0 }, - @{ id='kilo'; label='Kilo Code'; profile='kilo'; detect="vscode-ext:kilocode||dir:$HOME\.kilocode"; soft=0 }, - @{ id='roo'; label='Roo Code'; profile='roo'; detect='vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo'; soft=0 }, - @{ id='augment'; label='Augment Code'; profile='augment'; detect='vscode-ext:augment||jetbrains-plugin:augment'; soft=0 }, - @{ id='aider-desk'; label='Aider Desk'; profile='aider-desk'; detect="command:aider||dir:$HOME\.aider-desk"; soft=0 }, - @{ id='amp'; label='Sourcegraph Amp'; profile='amp'; detect='command:amp'; soft=0 }, - @{ id='bob'; label='IBM Bob'; profile='bob'; detect="command:bob||dir:$HOME\.bob"; soft=0 }, - @{ id='crush'; label='Crush'; profile='crush'; detect="command:crush||dir:$HOME\.config\crush"; soft=0 }, - @{ id='devin'; label='Devin (terminal)'; profile='devin'; detect="command:devin||dir:$HOME\.config\devin"; soft=0 }, - @{ id='droid'; label='Droid (Factory)'; profile='droid'; detect="command:droid||dir:$HOME\.factory"; soft=0 }, - @{ id='forgecode'; label='ForgeCode'; profile='forgecode'; detect="command:forge||dir:$HOME\.forge"; soft=0 }, - @{ id='goose'; label='Block Goose'; profile='goose'; detect="command:goose||dir:$HOME\.config\goose"; soft=0 }, - @{ id='iflow'; label='iFlow CLI'; profile='iflow-cli'; detect="command:iflow||dir:$HOME\.iflow"; soft=0 }, - @{ id='junie'; label='JetBrains Junie'; profile='junie'; detect="dir:$HOME\.junie||jetbrains-plugin:junie"; soft=1 }, - @{ id='kiro'; label='Kiro CLI'; profile='kiro-cli'; detect="command:kiro||dir:$HOME\.kiro"; soft=0 }, - @{ id='mistral'; label='Mistral Vibe'; profile='mistral-vibe'; detect="command:mistral||dir:$HOME\.vibe"; soft=0 }, - @{ id='openhands'; label='OpenHands'; profile='openhands'; detect="command:openhands||dir:$HOME\.openhands"; soft=0 }, - @{ id='opencode'; label='opencode'; profile='opencode'; detect="command:opencode||file:$HOME\.config\opencode\AGENTS.md"; soft=0 }, - @{ id='qwen'; label='Qwen Code'; profile='qwen-code'; detect="command:qwen||dir:$HOME\.qwen"; soft=0 }, - @{ id='qoder'; label='Qoder'; profile='qoder'; detect="dir:$HOME\.qoder"; soft=1 }, - @{ id='rovodev'; label='Atlassian Rovo Dev'; profile='rovodev'; detect="command:rovodev||dir:$HOME\.rovodev"; soft=0 }, - @{ id='tabnine'; label='Tabnine CLI'; profile='tabnine-cli'; detect="command:tabnine||dir:$HOME\.tabnine"; soft=0 }, - @{ id='trae'; label='Trae'; profile='trae'; detect="command:trae||dir:$HOME\.trae"; soft=0 }, - @{ id='warp'; label='Warp'; profile='warp'; detect="command:warp||dir:$HOME\.warp"; soft=0 }, - @{ id='replit'; label='Replit Agent'; profile='replit'; detect="command:replit||dir:$HOME\.replit"; soft=0 }, - @{ id='antigravity'; label='Google Antigravity'; profile='antigravity'; detect="dir:$HOME\.gemini\antigravity"; soft=1 } -) - -# ── -List output ──────────────────────────────────────────────────────────── -if ($List) { - Say "🪨 caveman provider matrix" - Write-Host "" - Write-Host (" {0,-13} {1,-22} {2}" -f "ID", "AGENT", "INSTALL MECHANISM") - Write-Host (" {0,-13} {1,-22} {2}" -f "----", "-----", "-----------------") - foreach ($p in $Providers) { - if ([string]::IsNullOrEmpty($p.profile)) { - $mech = if ($p.id -eq 'claude') { 'claude plugin install' } - elseif ($p.id -eq 'gemini') { 'gemini extensions install' } - else { '' } - } else { - $mech = "npx skills add ($($p.profile))" - } - if ($p.soft -eq 1) { $mech += ' (soft)' } - Write-Host (" {0,-13} {1,-22} {2}" -f $p.id, $p.label, $mech) - } - Write-Host "" - Note " Detection probes per agent live in install.ps1 \$Providers." - Note " Soft entries detect via config-dir presence only (no CLI on PATH)." - Write-Host "" - Note " Defaults: -WithHooks ON, -WithMcpShrink ON, -WithInit OFF." - Note " -All turns all three on, -Minimal turns all three off." - exit 0 -} - -# ── Banner ────────────────────────────────────────────────────────────────── -Say "🪨 caveman installer" -Note " $Repo" -if ($DryRun) { Note " (dry run — nothing will be written)" } -Write-Host "" - -# ── Per-agent install functions ───────────────────────────────────────────── -function Install-Claude { - $script:DetectedCount++ - Say "→ Claude Code detected" - $pluginDone = $false - - $alreadyInstalled = $false - if (-not $Force) { - try { - $list = & claude plugin list 2>$null - if ($list -match "(?i)caveman") { $alreadyInstalled = $true } - } catch {} - } - if ($alreadyInstalled) { - Note " caveman plugin already installed (use -Force to reinstall)" - Record-Skipped "claude" "plugin already installed" - $pluginDone = $true - } else { - if ((Try-Run "claude" @("plugin", "marketplace", "add", $Repo)) -and - (Try-Run "claude" @("plugin", "install", "caveman@caveman"))) { - Record-Installed "claude" - $pluginDone = $true - } else { - Record-Failed "claude" "claude plugin install failed" - } - } - - # -WithHooks: also run the standalone hooks installer (PowerShell variant). - if ($WithHooks) { - Say " → installing standalone hooks (-WithHooks)" - $hooksArgs = @() - if ($Force) { $hooksArgs += "-Force" } - - $localPs1 = $null - if ($RepoRoot) { - $candidate = Join-Path $RepoRoot "hooks\install.ps1" - if (Test-Path $candidate) { $localPs1 = $candidate } - } - - if ($DryRun) { - if ($localPs1) { - Note " would run: powershell -ExecutionPolicy Bypass -File $localPs1 $($hooksArgs -join ' ')" - } else { - Note " would run: irm $HooksInstallUrl | iex (with -Force=$Force)" - } - } else { - try { - if ($localPs1) { - & powershell -ExecutionPolicy Bypass -File $localPs1 @hooksArgs - if ($LASTEXITCODE -eq 0) { Record-Installed "claude-hooks" } - else { Record-Failed "claude-hooks" "hooks/install.ps1 exit $LASTEXITCODE" } - } else { - # Save to temp + run with -File so -Force works (irm | iex can't pass args). - $tmp = Join-Path $env:TEMP "caveman-hooks-install-$([Guid]::NewGuid()).ps1" - Invoke-WebRequest -Uri $HooksInstallUrl -OutFile $tmp -UseBasicParsing - try { - & powershell -ExecutionPolicy Bypass -File $tmp @hooksArgs - if ($LASTEXITCODE -eq 0) { Record-Installed "claude-hooks" } - else { Record-Failed "claude-hooks" "remote hooks installer exit $LASTEXITCODE" } - } finally { - Remove-Item $tmp -Force -ErrorAction SilentlyContinue - } - } - } catch { - Record-Failed "claude-hooks" $_.Exception.Message - } - } - } - - # -WithMcpShrink: register the proxy. Probe npm first so a transient - # registry outage downgrades to a clean manual-config skip instead of - # registering an `npx -y caveman-shrink` entry that 404s on every spawn. - if ($WithMcpShrink) { - Say " → wiring caveman-shrink MCP proxy (-WithMcpShrink)" - if (Has-Cmd "npm") { - $packageOnNpm = $false - try { $null = & npm view $McpShrinkPkg 2>$null; $packageOnNpm = ($LASTEXITCODE -eq 0) } catch {} - if (-not $packageOnNpm) { - Warn " 'npm view $McpShrinkPkg' returned no metadata — registry unreachable or package missing." - Note " Skipping registration. Re-run -WithMcpShrink when the registry is reachable," - Note " or copy the snippet below into your MCP config and point it at a local clone." - Record-Skipped "caveman-shrink" "npm registry probe failed" - Write-Host "" - return - } - } - $hasMcpAdd = $false - if (Has-Cmd "claude") { - try { $null = & claude mcp --help 2>$null; $hasMcpAdd = ($LASTEXITCODE -eq 0) } catch {} - } - if ($hasMcpAdd) { - if ($DryRun) { - Note " would run: claude mcp add caveman-shrink -- npx -y $McpShrinkPkg" - } else { - if (Try-Run "claude" @("mcp", "add", "caveman-shrink", "--", "npx", "-y", $McpShrinkPkg)) { - Record-Installed "caveman-shrink" - Note " registered. wrap an upstream by editing the mcpServers entry — see:" - Note " https://github.com/$Repo/tree/main/mcp-servers/caveman-shrink" - } else { - Record-Failed "caveman-shrink" "claude mcp add failed" - } - } - } else { - Note " 'claude mcp add' not available on this CLI. Add this snippet to your" - Note " Claude Code MCP config (settings.json or .mcp.json) manually:" - Write-Host "" - Write-Host ' {' - Write-Host ' "mcpServers": {' - Write-Host ' "fs-shrunk": {' - Write-Host ' "command": "npx",' - Write-Host ' "args": [' - Write-Host ' "caveman-shrink",' - Write-Host ' "npx", "@modelcontextprotocol/server-filesystem", "C:\\path\\to\\dir"' - Write-Host ' ]' - Write-Host ' }' - Write-Host ' }' - Write-Host ' }' - Write-Host "" - Record-Skipped "caveman-shrink" "manual config required (snippet printed)" - } - } - Write-Host "" -} - -function Install-Gemini { - $script:DetectedCount++ - Say "→ Gemini CLI detected" - $alreadyInstalled = $false - if (-not $Force) { - try { - $list = & gemini extensions list 2>$null - if ($list -match "(?i)caveman") { $alreadyInstalled = $true } - } catch {} - } - if ($alreadyInstalled) { - Note " caveman extension already installed (use -Force to reinstall)" - Record-Skipped "gemini" "extension already installed" - } else { - if (Try-Run "gemini" @("extensions", "install", "https://github.com/$Repo")) { - Record-Installed "gemini" - } else { - Record-Failed "gemini" "gemini extensions install failed" - } - } - Write-Host "" -} - -function Install-ViaSkills { - param([string]$id, [string]$label, [string]$profile) - $script:DetectedCount++ - Say "→ $label detected" - if (-not (Ensure-Node)) { - Record-Failed $id "node/npx missing" - Write-Host "" - return - } - $skillsArgs = @("-y", "skills", "add", $Repo) - if ($profile) { $skillsArgs += @("-a", $profile) } - if (Try-Run "npx" $skillsArgs) { - Record-Installed $id - } else { - Record-Failed $id "npx skills add failed (profile: $(if ($profile) { $profile } else { 'auto' }))" - } - Write-Host "" -} - -# ── Run the install loop ──────────────────────────────────────────────────── -foreach ($p in $Providers) { - if (-not (Want $p.id)) { continue } - if (-not (Resolve-DetectSpec $p.detect)) { continue } - switch ($p.id) { - 'claude' { Install-Claude } - 'gemini' { Install-Gemini } - default { Install-ViaSkills $p.id $p.label $p.profile } - } -} - -# ── Generic fallback: npx skills add (auto-detect) ───────────────────────── -if (-not $SkipSkills -and $OnlyList.Count -eq 0 -and $DetectedCount -eq 0) { - Say "→ no known agents detected — running npx-skills auto-detect fallback" - if (Ensure-Node) { - if (Try-Run "npx" @("-y", "skills", "add", $Repo)) { - Record-Installed "skills-auto" - } else { - Record-Failed "skills-auto" "npx skills add (auto) failed" - } - } - Write-Host "" -} - -# ── -WithInit: drop per-repo rule files into $PWD ────────────────────────── -# Avoid the variable name `$args` here — it shadows PowerShell's automatic -# unbound-args array. Use `$initArgs` instead. -function Run-Init { - $initArgs = @($PWD.Path) - if ($DryRun) { $initArgs += "--dry-run" } - if ($Force) { $initArgs += "--force" } - - if ($RepoRoot -and (Test-Path (Join-Path $RepoRoot "tools\caveman-init.js")) -and (Has-Cmd "node")) { - if (Try-Run "node" (@((Join-Path $RepoRoot "tools\caveman-init.js")) + $initArgs)) { return $true } - return $false - } - - if (-not (Has-Cmd "node")) { - Warn " node required to run caveman-init (install Node.js: https://nodejs.org)" - return $false - } - - if ($DryRun) { - Note " would run: irm $InitScriptUrl | node - $($initArgs -join ' ')" - return $true - } - - $tmp = Join-Path $env:TEMP "caveman-init-$([Guid]::NewGuid()).js" - try { - Invoke-WebRequest -Uri $InitScriptUrl -OutFile $tmp -UseBasicParsing - & node $tmp @initArgs - return ($LASTEXITCODE -eq 0) - } catch { - Warn " $($_.Exception.Message)" - return $false - } finally { - Remove-Item $tmp -Force -ErrorAction SilentlyContinue - } -} - -if ($WithInit) { - Say "→ writing per-repo IDE rule files into $PWD (-WithInit)" - if (Run-Init) { - Record-Installed "caveman-init ($PWD)" - } else { - Record-Failed "caveman-init" "tools/caveman-init.js failed" - } - Write-Host "" -} elseif ($InstalledIds.Count -gt 0 -or $SkippedIds.Count -gt 0) { - Note " tip: re-run inside a repo with -All (or -WithInit) to also write per-repo" - Note " Cursor/Windsurf/Cline/Copilot/AGENTS.md rule files." -} - -# ── Summary ──────────────────────────────────────────────────────────────── -Write-Host "" -Say "🪨 done" - -if ($InstalledIds.Count -gt 0) { - Ok " installed:" - foreach ($a in $InstalledIds) { Write-Host " - $a" } -} - -if ($SkippedIds.Count -gt 0) { - Write-Host " skipped:" - for ($i = 0; $i -lt $SkippedIds.Count; $i++) { - Write-Host (" - {0} - {1}" -f $SkippedIds[$i], $SkippedWhy[$i]) - } -} - -if ($FailedIds.Count -gt 0) { - Warn " failed:" - for ($i = 0; $i -lt $FailedIds.Count; $i++) { - Warn (" - {0} - {1}" -f $FailedIds[$i], $FailedWhy[$i]) - } -} - -if ($InstalledIds.Count -eq 0 -and $SkippedIds.Count -eq 0 -and $FailedIds.Count -eq 0) { - Write-Host " nothing detected. install one of: claude, gemini, codex, cursor, windsurf, cline, copilot, opencode, roo, amp, goose, kiro, augment, aider-desk, continue, junie, trae, warp, ..." - Write-Host " or pass -Only to force a specific target (see -List for the full matrix)" -} - -Write-Host "" -Note " start any session and say 'caveman mode', or run /caveman in Claude Code" -Note " uninstall: see https://github.com/$Repo#install" - -# Exit non-zero only when EVERY detected agent failed (and at least one was -# detected). Skips don't count as failure. -if ($DetectedCount -gt 0 -and $InstalledIds.Count -eq 0 -and $SkippedIds.Count -eq 0) { +# Require Node ≥18. +$node = Get-Command node -ErrorAction SilentlyContinue +if (-not $node) { + Write-Error @" +caveman: Node.js (>=18) required. Install: + - winget install OpenJS.NodeJS.LTS + - or download from https://nodejs.org +"@ exit 1 } -exit 0 + +$nodeMajor = [int](& node -p "process.versions.node.split('.')[0]") +if ($nodeMajor -lt 18) { + Write-Error "caveman: Node $nodeMajor too old. Need Node >=18. Upgrade: https://nodejs.org" + exit 1 +} + +# If we're inside the repo clone, run the local installer directly. +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$local = Join-Path $here "bin/install.js" +if (Test-Path $local) { + & node $local @Args + exit $LASTEXITCODE +} + +# Curl-pipe path: delegate to npx. +$npx = Get-Command npx -ErrorAction SilentlyContinue +if (-not $npx) { + Write-Error "caveman: npx required (ships with Node >=18). Reinstall Node.js." + exit 1 +} + +& npx -y "github:$Repo" -- @Args +exit $LASTEXITCODE diff --git a/install.sh b/install.sh index 4419a19..9125bd3 100755 --- a/install.sh +++ b/install.sh @@ -1,783 +1,50 @@ #!/usr/bin/env bash -# caveman — smart multi-agent installer. +# caveman — installer shim. # -# One line: +# Thin wrapper around bin/install.js (the unified Node installer). Every flag +# you'd pass to bin/install.js can be passed here; we just forward them. +# +# One-line install: # curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --all # -# Detects which AI coding agents are on your machine and installs caveman for -# each one using its native distribution (plugin / extension / skill / rule -# file). Skips agents that aren't installed. Safe to re-run — each underlying -# install command is idempotent. +# Local clone: +# bash install.sh [flags] # -# Run `install.sh --help` for the full reference (flags + agent matrix). +# Why a Node installer? install.sh + install.ps1 used to be parallel sources +# of truth and constantly drifted (issue #249, etc.). One Node script works +# everywhere without bash/PowerShell quoting bugs. set -euo pipefail -# ── Constants ────────────────────────────────────────────────────────────── REPO="JuliusBrussee/caveman" -RAW_BASE="https://raw.githubusercontent.com/$REPO/main" -HOOKS_INSTALL_URL="$RAW_BASE/hooks/install.sh" -INIT_SCRIPT_URL="$RAW_BASE/tools/caveman-init.js" -MCP_SHRINK_PKG="caveman-shrink" -# ── Flags + state (no associative arrays — bash 3.2 safe) ────────────────── -# WITH_HOOKS / WITH_MCP_SHRINK default to "auto" → ON unless --minimal is set -# or the caller passed an explicit override. WITH_INIT stays opt-in because -# it writes per-repo rule files into $PWD — too surprising for bare curl|bash. -# We still probe `npm view caveman-shrink` before registration so a transient -# npm outage downgrades to a manual-snippet skip instead of a broken config. -DRY=0 -FORCE=0 -SKIP_SKILLS=0 -WITH_HOOKS=auto -WITH_INIT=0 -WITH_MCP_SHRINK=auto -ALL=0 -MINIMAL=0 -LIST_ONLY=0 -NO_COLOR=0 -ONLY=() - -# Result trackers — parallel indexed arrays of agent ids and reasons. -INSTALLED_IDS=() -SKIPPED_IDS=() -SKIPPED_WHY=() -FAILED_IDS=() -FAILED_WHY=() -DETECTED_COUNT=0 - -# ── Color setup (auto-disable on non-TTY) ────────────────────────────────── -if [ ! -t 1 ]; then NO_COLOR=1; fi - -# ── Argument parsing ─────────────────────────────────────────────────────── -print_help() { - cat <<'EOF' -caveman installer — detects your agents and installs caveman for each one. - -USAGE - install.sh [flags] - - curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash - curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --with-hooks - -FLAGS - --dry-run Print what would run, do nothing. - --force Re-run even if a target reports "already installed". - --only Install only for the named agent. Repeatable. - --skip-skills Don't run the npx-skills auto-detect fallback. - --all Turn on --with-hooks, --with-init, --with-mcp-shrink. - Recommended when running from inside a repo you want - always-on caveman in. - --minimal Just the plugin/extension install. Skips hooks, - statusline, MCP shrink, and per-repo rule files. - --with-hooks Claude Code: also run the standalone hooks installer - (SessionStart/UserPromptSubmit hooks + statusline + - stats badge). On by default — pass --minimal to skip. - --with-init Also run caveman-init against the current working - directory so per-repo IDE rule files are written for - Cursor/Windsurf/Cline/Copilot/AGENTS.md. Off by default. - --with-mcp-shrink Claude Code: register the caveman-shrink MCP middleware - proxy (or print the JSON snippet for manual setup). - On by default — pass --minimal to skip. - --list Print the full provider matrix and exit. - --no-color Disable ANSI color codes (auto-disabled on non-TTY). - -h, --help Show this help and exit. - -AGENTS DETECTED - Run with --list for the full table including detection probes. Soft-detected - agents (config-dir-only probes) are tagged "(soft)" in --list output. - - Native: - claude Claude Code plugin marketplace + plugin install - gemini Gemini CLI gemini extensions install - codex Codex CLI npx skills add (codex) - IDE / VS Code-family: - cursor Cursor IDE npx skills add (cursor) - windsurf Windsurf IDE npx skills add (windsurf) - cline Cline npx skills add (cline) - continue Continue (VS Code) npx skills add (continue) - kilo Kilo Code npx skills add (kilo) - roo Roo Code npx skills add (roo) - augment Augment Code npx skills add (augment) - CLI agents: - aider-desk Aider Desk npx skills add (aider-desk) - amp Sourcegraph Amp npx skills add (amp) - bob IBM Bob npx skills add (bob) - crush Crush npx skills add (crush) - devin Devin (terminal) npx skills add (devin) - droid Droid (Factory) npx skills add (droid) - forgecode ForgeCode npx skills add (forgecode) - goose Block Goose npx skills add (goose) - iflow iFlow CLI npx skills add (iflow-cli) - junie JetBrains Junie npx skills add (junie) - kiro Kiro CLI npx skills add (kiro-cli) - mistral Mistral Vibe npx skills add (mistral-vibe) - openhands OpenHands npx skills add (openhands) - opencode opencode npx skills add (opencode) - qwen Qwen Code npx skills add (qwen-code) - qoder Qoder npx skills add (qoder) - rovodev Atlassian Rovo Dev npx skills add (rovodev) - tabnine Tabnine CLI npx skills add (tabnine-cli) - trae Trae npx skills add (trae) - warp Warp npx skills add (warp) - replit Replit Agent npx skills add (replit) - antigravity Google Antigravity npx skills add (antigravity) - Per-repo rule files (via --with-init / --all): - copilot GitHub Copilot .github/copilot-instructions.md - agents AGENTS.md (Zed, etc.) AGENTS.md (universal) - -URLS THE INSTALLER MAY FETCH FROM - https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh - https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks/install.sh - https://raw.githubusercontent.com/JuliusBrussee/caveman/main/tools/caveman-init.js - https://github.com/JuliusBrussee/caveman (via gemini extensions install) - -EXAMPLES - install.sh # default: plugin + hooks + MCP shrink - install.sh --all # also drop per-repo rule files - install.sh --minimal # plugin/extension only - install.sh --dry-run --all - install.sh --only claude --with-mcp-shrink - install.sh --only cursor --only windsurf --with-init - install.sh --list -EOF -} - -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) DRY=1 ;; - --force) FORCE=1 ;; - --skip-skills) SKIP_SKILLS=1 ;; - --with-hooks) WITH_HOOKS=1 ;; - --with-init) WITH_INIT=1 ;; - --with-mcp-shrink) WITH_MCP_SHRINK=1 ;; - --all) ALL=1 ;; - --minimal) MINIMAL=1 ;; - --list) LIST_ONLY=1 ;; - --no-color) NO_COLOR=1 ;; - --only) - shift - if [ $# -eq 0 ]; then - echo "error: --only requires an argument" >&2 - exit 2 - fi - # Backward-compat alias: 'aider' was renamed to 'aider-desk' to match the - # upstream skills profile slug. Old install.sh --only aider keeps working. - _only="$1" - [ "$_only" = "aider" ] && _only="aider-desk" - ONLY+=("$_only") ;; - -h|--help) print_help; exit 0 ;; - *) - echo "error: unknown flag: $1" >&2 - echo "run 'install.sh --help' for usage" >&2 - exit 2 ;; - esac - shift -done - -# Resolve --all / --minimal / "auto" defaults into concrete flag values. -if [ "$ALL" = 1 ] && [ "$MINIMAL" = 1 ]; then - echo "error: --all and --minimal are mutually exclusive" >&2 - exit 2 -fi -if [ "$ALL" = 1 ]; then - WITH_HOOKS=1 - WITH_INIT=1 - WITH_MCP_SHRINK=1 -fi -if [ "$MINIMAL" = 1 ]; then - WITH_HOOKS=0 - WITH_MCP_SHRINK=0 - WITH_INIT=0 -fi -[ "$WITH_HOOKS" = "auto" ] && WITH_HOOKS=1 -[ "$WITH_MCP_SHRINK" = "auto" ] && WITH_MCP_SHRINK=1 - -# ── Color helpers ────────────────────────────────────────────────────────── -if [ "$NO_COLOR" = 1 ]; then - c_orange=""; c_dim=""; c_red=""; c_green=""; c_reset="" -else - c_orange=$'\033[38;5;172m' - c_dim=$'\033[2m' - c_red=$'\033[31m' - c_green=$'\033[32m' - c_reset=$'\033[0m' -fi - -say() { printf '%s%s%s\n' "$c_orange" "$1" "$c_reset"; } -note() { printf '%s%s%s\n' "$c_dim" "$1" "$c_reset"; } -warn() { printf '%s%s%s\n' "$c_red" "$1" "$c_reset" >&2; } -ok() { printf '%s%s%s\n' "$c_green" "$1" "$c_reset"; } - -# ── Helpers ──────────────────────────────────────────────────────────────── -want() { - if [ ${#ONLY[@]} -eq 0 ]; then return 0; fi - local a - for a in "${ONLY[@]}"; do [ "$a" = "$1" ] && return 0; done - return 1 -} - -run() { - if [ "$DRY" = 1 ]; then - note " would run: $*" - return 0 - fi - echo " $ $*" - "$@" -} - -# Run a command but never let its non-zero exit kill the script (set -e). -try() { - if [ "$DRY" = 1 ]; then - note " would run: $*" - return 0 - fi - echo " $ $*" - "$@" -} - -has() { command -v "$1" >/dev/null 2>&1; } - -ensure_node() { - if has node && has npx; then return 0; fi - warn " node + npx required for this target — install Node.js (https://nodejs.org) and re-run." - return 1 -} - -# Find the local repo root (the dir containing this script) if we are NOT -# running from a curl-pipe. BASH_SOURCE[0] is unreliable when piped to bash, -# so we double-check the file actually exists and has the expected siblings. -detect_repo_root() { - local src="${BASH_SOURCE[0]:-}" - if [ -n "$src" ] && [ -f "$src" ]; then - local d - d="$(cd "$(dirname "$src")" 2>/dev/null && pwd)" - if [ -n "$d" ] && [ -f "$d/install.sh" ] && [ -d "$d/hooks" ] && [ -d "$d/tools" ]; then - echo "$d" - return 0 - fi - fi - return 1 -} - -REPO_ROOT="$(detect_repo_root || true)" - -# Result recorders (idempotent against double-add). -record_installed() { INSTALLED_IDS+=("$1"); } -record_skipped() { SKIPPED_IDS+=("$1"); SKIPPED_WHY+=("$2"); } -record_failed() { FAILED_IDS+=("$1"); FAILED_WHY+=("$2"); } - -# ── Provider matrix (parallel arrays — bash 3.2 safe) ────────────────────── -# id | label | install path/notes | detection probe(s) | soft-detection? -# -# When adding a new agent: the profile slug must exist in upstream -# vercel-labs/skills (see https://github.com/vercel-labs/skills). Detection -# probes can be `command:` (binary on PATH), `dir:` (directory -# exists), `file:` (file exists), `vscode-ext:`, -# `cursor-ext:`, `jetbrains-config`, or `jetbrains-plugin:`. -# Multiple clauses joined by `||` — any match counts. Soft entries (PROVIDER_SOFT=1) -# rely only on dir/file probes — kept in the matrix to maximize reach but -# tagged "(soft)" in --list output so users know detection is best-effort. -PROVIDER_IDS=( - "claude" "gemini" "codex" - "cursor" "windsurf" "cline" "copilot" "continue" "kilo" "roo" "augment" - "aider-desk" "amp" "bob" "crush" "devin" "droid" "forgecode" "goose" - "iflow" "junie" "kiro" "mistral" "openhands" "opencode" "qwen" "qoder" - "rovodev" "tabnine" "trae" "warp" "replit" "antigravity" -) -PROVIDER_LABELS=( - "Claude Code" "Gemini CLI" "Codex CLI" - "Cursor" "Windsurf" "Cline" "GitHub Copilot" "Continue" "Kilo Code" "Roo Code" "Augment Code" - "Aider Desk" "Sourcegraph Amp" "IBM Bob" "Crush" "Devin (terminal)" "Droid (Factory)" "ForgeCode" "Block Goose" - "iFlow CLI" "JetBrains Junie" "Kiro CLI" "Mistral Vibe" "OpenHands" "opencode" "Qwen Code" "Qoder" - "Atlassian Rovo Dev" "Tabnine CLI" "Trae" "Warp" "Replit Agent" "Google Antigravity" -) -PROVIDER_MECHS=( - "claude plugin install" "gemini extensions install" "npx skills add (codex)" - "npx skills add (cursor)" "npx skills add (windsurf)" "npx skills add (cline)" - "npx skills add (github-copilot)" "npx skills add (continue)" "npx skills add (kilo)" - "npx skills add (roo)" "npx skills add (augment)" - "npx skills add (aider-desk)" "npx skills add (amp)" "npx skills add (bob)" - "npx skills add (crush)" "npx skills add (devin)" "npx skills add (droid)" - "npx skills add (forgecode)" "npx skills add (goose)" "npx skills add (iflow-cli)" - "npx skills add (junie)" "npx skills add (kiro-cli)" "npx skills add (mistral-vibe)" - "npx skills add (openhands)" "npx skills add (opencode)" "npx skills add (qwen-code)" - "npx skills add (qoder)" "npx skills add (rovodev)" "npx skills add (tabnine-cli)" - "npx skills add (trae)" "npx skills add (warp)" "npx skills add (replit)" - "npx skills add (antigravity)" -) -PROVIDER_DETECT=( - "command:claude" "command:gemini" "command:codex" - "command:cursor||dir:$HOME/.cursor" - "command:windsurf||dir:$HOME/.codeium/windsurf||dir:$HOME/.windsurf" - "vscode-ext:cline" - "command:gh" - "vscode-ext:continue.continue||vscode-ext:continue" - "vscode-ext:kilocode||dir:$HOME/.kilocode" - "vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo" - "vscode-ext:augment||jetbrains-plugin:augment" - "command:aider||dir:$HOME/.aider-desk" - "command:amp" - "command:bob||dir:$HOME/.bob" - "command:crush||dir:$HOME/.config/crush" - "command:devin||dir:$HOME/.config/devin" - "command:droid||dir:$HOME/.factory" - "command:forge||dir:$HOME/.forge" - "command:goose||dir:$HOME/.config/goose" - "command:iflow||dir:$HOME/.iflow" - "dir:$HOME/.junie||jetbrains-plugin:junie" - "command:kiro||dir:$HOME/.kiro" - "command:mistral||dir:$HOME/.vibe" - "command:openhands||dir:$HOME/.openhands" - "command:opencode||file:$HOME/.config/opencode/AGENTS.md" - "command:qwen||dir:$HOME/.qwen" - "dir:$HOME/.qoder" - "command:rovodev||dir:$HOME/.rovodev" - "command:tabnine||dir:$HOME/.tabnine" - "command:trae||dir:$HOME/.trae" - "command:warp||dir:$HOME/.warp" - "command:replit||dir:$HOME/.replit" - "dir:$HOME/.gemini/antigravity" -) -# Soft = no `command:` clause, only dir/file/jetbrains-plugin probes. These -# may false-positive on stale config dirs but greatly widen the reach. -PROVIDER_SOFT=( - 0 0 0 - 0 0 0 0 0 0 0 0 - 0 0 0 0 0 0 0 0 - 0 1 0 0 0 0 0 1 - 0 0 0 0 0 1 -) - -# ── --list output ────────────────────────────────────────────────────────── -if [ "$LIST_ONLY" = 1 ]; then - say "🪨 caveman provider matrix" - printf '\n %-13s %-22s %s\n' "ID" "AGENT" "INSTALL MECHANISM" - printf ' %-13s %-22s %s\n' "----" "-----" "-----------------" - i=0 - total=${#PROVIDER_IDS[@]} - while [ $i -lt "$total" ]; do - soft="" - [ "${PROVIDER_SOFT[$i]:-0}" = "1" ] && soft=" (soft)" - printf ' %-13s %-22s %s%s\n' "${PROVIDER_IDS[$i]}" "${PROVIDER_LABELS[$i]}" "${PROVIDER_MECHS[$i]}" "$soft" - i=$((i + 1)) - done - echo - note " Detection probes per agent live in install.sh PROVIDER_DETECT." - note " Soft entries detect via config-dir presence only (no CLI on PATH)." - echo - note " Defaults: --with-hooks ON, --with-mcp-shrink ON, --with-init OFF." - note " --all turns all three on, --minimal turns all three off." - exit 0 -fi - -# ── Detection helpers ────────────────────────────────────────────────────── -vscode_ext_present() { - # Looks for any extension dir matching the substring across common roots. - local needle="$1" - local roots=("$HOME/.vscode/extensions" "$HOME/.vscode-server/extensions" "$HOME/.cursor/extensions" "$HOME/.windsurf/extensions") - local r - for r in "${roots[@]}"; do - if [ -d "$r" ] && ls "$r" 2>/dev/null | grep -qi "$needle"; then - return 0 - fi - done - return 1 -} - -cursor_ext_present() { - local needle="$1" - if [ -d "$HOME/.cursor/extensions" ] && ls "$HOME/.cursor/extensions" 2>/dev/null | grep -qi "$needle"; then - return 0 - fi - return 1 -} - -jetbrains_present() { - # macOS path + Linux XDG path. Treat presence of a JetBrains config dir as - # "JetBrains is installed" — the AI Assistant ships in most products now. - if [ -d "$HOME/Library/Application Support/JetBrains" ]; then return 0; fi - if [ -d "$HOME/.config/JetBrains" ]; then return 0; fi - return 1 -} - -jetbrains_plugin_present() { - local needle="$1" - local roots=("$HOME/Library/Application Support/JetBrains" "$HOME/.config/JetBrains") - local r - for r in "${roots[@]}"; do - if [ -d "$r" ] && find "$r" -maxdepth 4 -type d -iname "*${needle}*" 2>/dev/null | grep -q .; then - return 0 - fi - done - return 1 -} - -# Parse a PROVIDER_DETECT spec like "command:foo||dir:$HOME/x" and return 0 -# if any clause matches. Splits on '||' via bash parameter expansion — earlier -# revisions used `awk -v RS='||'` which silently fails on macOS BSD awk -# ("illegal primary in regular expression"), making every compound spec a -# no-op and causing the installer to detect zero of the 28 IDE/CLI agents. -detect_match() { - local spec="$1" - local rest="$spec" - local clause - while [ -n "$rest" ]; do - if [ "${rest#*||}" != "$rest" ]; then - clause="${rest%%||*}" - rest="${rest#*||}" - else - clause="$rest" - rest="" - fi - [ -z "$clause" ] && continue - case "$clause" in - command:*) has "${clause#command:}" && return 0 ;; - dir:*) [ -d "${clause#dir:}" ] && return 0 ;; - file:*) [ -f "${clause#file:}" ] && return 0 ;; - vscode-ext:*) vscode_ext_present "${clause#vscode-ext:}" && return 0 ;; - cursor-ext:*) cursor_ext_present "${clause#cursor-ext:}" && return 0 ;; - jetbrains-config) jetbrains_present && return 0 ;; - jetbrains-plugin:*) jetbrains_plugin_present "${clause#jetbrains-plugin:}" && return 0 ;; - esac - done - return 1 -} - -# ────────────────────────────────────────────────────────────────────────── -say "🪨 caveman installer" -note " $REPO" -if [ "$DRY" = 1 ]; then note " (dry run — nothing will be written)"; fi -echo - -# ── Per-agent install functions (each returns 0/1) ───────────────────────── - -install_claude() { - DETECTED_COUNT=$((DETECTED_COUNT + 1)) - say "→ Claude Code detected" - local plugin_done=0 - - if [ "$FORCE" = 0 ] && claude plugin list 2>/dev/null | grep -qi caveman; then - note " caveman plugin already installed (use --force to reinstall)" - record_skipped "claude" "plugin already installed" - plugin_done=1 - else - if try claude plugin marketplace add "$REPO" && \ - try claude plugin install "caveman@caveman"; then - record_installed "claude" - plugin_done=1 - else - record_failed "claude" "claude plugin install failed" - fi - fi - - # --with-hooks: also run the standalone hooks installer. - if [ "$WITH_HOOKS" = 1 ]; then - say " → installing standalone hooks (--with-hooks)" - local hooks_args="" - [ "$FORCE" = 1 ] && hooks_args="--force" - if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/hooks/install.sh" ]; then - if [ "$DRY" = 1 ]; then - note " would run: bash $REPO_ROOT/hooks/install.sh $hooks_args" - else - # shellcheck disable=SC2086 - if bash "$REPO_ROOT/hooks/install.sh" $hooks_args; then - record_installed "claude-hooks" - else - record_failed "claude-hooks" "hooks/install.sh failed" - fi - fi - else - if ! has curl; then - warn " curl required to fetch hooks installer remotely" - record_failed "claude-hooks" "curl missing" - elif [ "$DRY" = 1 ]; then - note " would run: bash <(curl -fsSL $HOOKS_INSTALL_URL) $hooks_args" - else - # shellcheck disable=SC2086 - if bash <(curl -fsSL "$HOOKS_INSTALL_URL") $hooks_args; then - record_installed "claude-hooks" - else - record_failed "claude-hooks" "remote hooks installer failed" - fi - fi - fi - fi - - # --with-mcp-shrink: register the proxy (or print the snippet). Probe the - # npm registry first so a transient registry outage degrades to a clean - # manual-config skip instead of registering an `npx -y caveman-shrink` - # entry that 404s every time Claude tries to spawn it. - if [ "$WITH_MCP_SHRINK" = 1 ]; then - say " → wiring caveman-shrink MCP proxy (--with-mcp-shrink)" - if has npm && ! npm view "$MCP_SHRINK_PKG" >/dev/null 2>&1; then - warn " 'npm view $MCP_SHRINK_PKG' returned no metadata — registry unreachable or package missing." - note " Skipping registration. Re-run --with-mcp-shrink when the registry is reachable," - note " or copy the snippet below into your MCP config and point it at a local clone." - record_skipped "caveman-shrink" "npm registry probe failed" - elif has claude && claude mcp --help >/dev/null 2>&1; then - # Newer Claude Code CLIs expose `claude mcp add`. Wrap stdio: proxy - # spawns the upstream as a child. Without an upstream the proxy is a - # no-op, so we register the proxy itself with a placeholder upstream - # and tell the user how to point it at a real server. - if [ "$DRY" = 1 ]; then - note " would run: claude mcp add caveman-shrink -- npx -y $MCP_SHRINK_PKG" - else - if try claude mcp add caveman-shrink -- npx -y "$MCP_SHRINK_PKG"; then - record_installed "caveman-shrink" - note " registered. wrap an upstream by editing the mcpServers entry — see:" - note " https://github.com/$REPO/tree/main/mcp-servers/caveman-shrink" - else - record_failed "caveman-shrink" "claude mcp add failed" - fi - fi - else - note " 'claude mcp add' not available on this CLI. Add this snippet to your" - note " Claude Code MCP config (settings.json or .mcp.json) manually:" - cat <<'EOF' - - { - "mcpServers": { - "fs-shrunk": { - "command": "npx", - "args": [ - "caveman-shrink", - "npx", "@modelcontextprotocol/server-filesystem", "/path/to/dir" - ] - } - } - } - -EOF - record_skipped "caveman-shrink" "manual config required (snippet printed)" - fi - fi - - echo - return 0 -} - -install_gemini() { - DETECTED_COUNT=$((DETECTED_COUNT + 1)) - say "→ Gemini CLI detected" - if [ "$FORCE" = 0 ] && gemini extensions list 2>/dev/null | grep -qi caveman; then - note " caveman extension already installed (use --force to reinstall)" - record_skipped "gemini" "extension already installed" - else - if try gemini extensions install "https://github.com/$REPO"; then - record_installed "gemini" - else - record_failed "gemini" "gemini extensions install failed" - fi - fi - echo -} - -install_codex() { - DETECTED_COUNT=$((DETECTED_COUNT + 1)) - say "→ Codex CLI detected" - if ! ensure_node; then - record_failed "codex" "node/npx missing" - echo - return 0 - fi - if try npx -y skills add "$REPO" -a codex; then - record_installed "codex" - else - record_failed "codex" "npx skills add (codex) failed" - fi - echo -} - -# Generic IDE/skills profile installer used by everything that goes through -# `npx skills add`. Pass an empty profile to use auto-detect. -install_via_skills() { - local id="$1" - local label="$2" - local profile="$3" - DETECTED_COUNT=$((DETECTED_COUNT + 1)) - say "→ $label detected" - if ! ensure_node; then - record_failed "$id" "node/npx missing" - echo - return 0 - fi - local cmd_ok=1 - if [ -n "$profile" ]; then - if ! try npx -y skills add "$REPO" -a "$profile"; then cmd_ok=0; fi - else - if ! try npx -y skills add "$REPO"; then cmd_ok=0; fi - if [ "$cmd_ok" = 1 ]; then - note " used auto-detect — if your agent wasn't picked up, re-run with --only and a profile" - fi - fi - if [ "$cmd_ok" = 1 ]; then - record_installed "$id" - else - record_failed "$id" "npx skills add failed (profile: ${profile:-auto})" - fi - echo -} - -# ── Run installs in declared order ───────────────────────────────────────── - -# Claude: separate function (plugin + optional hooks + optional mcp-shrink). -if want claude && detect_match "command:claude"; then - install_claude -fi - -# Gemini. -if want gemini && detect_match "command:gemini"; then - install_gemini -fi - -# Codex. -if want codex && detect_match "command:codex"; then - install_codex -fi - -# IDE / agent skills targets — id, label, profile, detect spec. Profile slugs -# are validated against upstream vercel-labs/skills (see CLAUDE.md note). Add -# new rows here AND to the PROVIDER_* matrix above so --list stays accurate. -SKILLS_AGENTS=( - "cursor|Cursor|cursor|command:cursor||dir:$HOME/.cursor" - "windsurf|Windsurf|windsurf|command:windsurf||dir:$HOME/.codeium/windsurf||dir:$HOME/.windsurf" - "cline|Cline|cline|vscode-ext:cline" - "copilot|GitHub Copilot|github-copilot|command:gh" - "continue|Continue|continue|vscode-ext:continue.continue||vscode-ext:continue" - "kilo|Kilo Code|kilo|vscode-ext:kilocode||dir:$HOME/.kilocode" - "roo|Roo Code|roo|vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo" - "augment|Augment Code|augment|vscode-ext:augment||jetbrains-plugin:augment" - "aider-desk|Aider Desk|aider-desk|command:aider||dir:$HOME/.aider-desk" - "amp|Sourcegraph Amp|amp|command:amp" - "bob|IBM Bob|bob|command:bob||dir:$HOME/.bob" - "crush|Crush|crush|command:crush||dir:$HOME/.config/crush" - "devin|Devin (terminal)|devin|command:devin||dir:$HOME/.config/devin" - "droid|Droid (Factory)|droid|command:droid||dir:$HOME/.factory" - "forgecode|ForgeCode|forgecode|command:forge||dir:$HOME/.forge" - "goose|Block Goose|goose|command:goose||dir:$HOME/.config/goose" - "iflow|iFlow CLI|iflow-cli|command:iflow||dir:$HOME/.iflow" - "junie|JetBrains Junie|junie|dir:$HOME/.junie||jetbrains-plugin:junie" - "kiro|Kiro CLI|kiro-cli|command:kiro||dir:$HOME/.kiro" - "mistral|Mistral Vibe|mistral-vibe|command:mistral||dir:$HOME/.vibe" - "openhands|OpenHands|openhands|command:openhands||dir:$HOME/.openhands" - "opencode|opencode|opencode|command:opencode||file:$HOME/.config/opencode/AGENTS.md" - "qwen|Qwen Code|qwen-code|command:qwen||dir:$HOME/.qwen" - "qoder|Qoder|qoder|dir:$HOME/.qoder" - "rovodev|Atlassian Rovo Dev|rovodev|command:rovodev||dir:$HOME/.rovodev" - "tabnine|Tabnine CLI|tabnine-cli|command:tabnine||dir:$HOME/.tabnine" - "trae|Trae|trae|command:trae||dir:$HOME/.trae" - "warp|Warp|warp|command:warp||dir:$HOME/.warp" - "replit|Replit Agent|replit|command:replit||dir:$HOME/.replit" - "antigravity|Google Antigravity|antigravity|dir:$HOME/.gemini/antigravity" -) - -for spec in "${SKILLS_AGENTS[@]}"; do - IFS='|' read -r id label profile detect_spec <&2 - i=$((i + 1)) - done -fi - -if [ ${#INSTALLED_IDS[@]} -eq 0 ] && [ ${#SKIPPED_IDS[@]} -eq 0 ] && [ ${#FAILED_IDS[@]} -eq 0 ]; then - echo " nothing detected. run 'install.sh --list' to see all 30+ supported agents" - echo " or pass --only to force a specific target." -fi - -echo -note " start any session and say 'caveman mode', or run /caveman in Claude Code" -note " uninstall: see https://github.com/$REPO#install" - -# Exit non-zero only when EVERY detected agent failed (and at least one was -# detected). Skips don't count as failure. -if [ "$DETECTED_COUNT" -gt 0 ] && [ ${#INSTALLED_IDS[@]} -eq 0 ] && [ ${#SKIPPED_IDS[@]} -eq 0 ]; then +# Require Node ≥18. nvm is a common path; print a hint if missing. +if ! command -v node >/dev/null 2>&1; then + echo "caveman: Node.js (≥18) required. Install:" >&2 + echo " macOS: brew install node" >&2 + echo " Linux: see https://nodejs.org or use nvm (https://github.com/nvm-sh/nvm)" >&2 exit 1 fi -exit 0 + +NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]") +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "caveman: Node $NODE_MAJOR too old. Need Node ≥18." >&2 + echo " Upgrade: https://nodejs.org" >&2 + exit 1 +fi + +# If we're inside the repo clone, run the local installer directly — saves +# the npx round-trip and keeps offline installs working. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || here="" +if [ -n "$here" ] && [ -f "$here/bin/install.js" ]; then + exec node "$here/bin/install.js" "$@" +fi + +# Curl-pipe path: delegate to npx. The `--` separates npx flags from our flags. +if ! command -v npx >/dev/null 2>&1; then + echo "caveman: npx required (ships with Node ≥18). Reinstall Node.js." >&2 + exit 1 +fi + +exec npx -y "github:$REPO" -- "$@" diff --git a/plugins/caveman/agents/cavecrew-builder.md b/plugins/caveman/agents/cavecrew-builder.md index 3e5aee6..65750df 100644 --- a/plugins/caveman/agents/cavecrew-builder.md +++ b/plugins/caveman/agents/cavecrew-builder.md @@ -6,7 +6,7 @@ description: > scope. Returns caveman diff receipt. Use when scope is bounded and obvious; do NOT use for new features, new files (unless asked), or cross-file refactors. -tools: Read, Edit, Write, Grep, Glob +tools: [Read, Edit, Write, Grep, Glob] --- Caveman-ultra. Drop articles/filler. Code/paths exact, backticked. No narration. diff --git a/plugins/caveman/agents/cavecrew-investigator.md b/plugins/caveman/agents/cavecrew-investigator.md index 6f350da..be27498 100644 --- a/plugins/caveman/agents/cavecrew-investigator.md +++ b/plugins/caveman/agents/cavecrew-investigator.md @@ -5,7 +5,7 @@ description: > "what calls Y", "list all uses of Z", "map this directory". Output is caveman-compressed so the main thread eats ~60% fewer tokens than vanilla Explore. Refuses to suggest fixes. -tools: Read, Grep, Glob, Bash +tools: [Read, Grep, Glob, Bash] model: haiku --- diff --git a/plugins/caveman/agents/cavecrew-reviewer.md b/plugins/caveman/agents/cavecrew-reviewer.md index a6a2c94..97049ce 100644 --- a/plugins/caveman/agents/cavecrew-reviewer.md +++ b/plugins/caveman/agents/cavecrew-reviewer.md @@ -5,7 +5,7 @@ description: > no scope creep. Output format `path:line: : . .` Use for "review this PR", "review my diff", "audit this file". Skips formatting nits unless they change meaning. -tools: Read, Grep, Bash +tools: [Read, Grep, Bash] model: haiku --- diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..7cac7a5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "cavecrew": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/cavecrew/SKILL.md", + "computedHash": "06d45a7308d8603365313decf400020106b985cb5ce500ee169bfba9c71dd147" + } + } +} diff --git a/tests/installer/e2e.dryrun.test.mjs b/tests/installer/e2e.dryrun.test.mjs new file mode 100644 index 0000000..e97f4bf --- /dev/null +++ b/tests/installer/e2e.dryrun.test.mjs @@ -0,0 +1,56 @@ +// End-to-end: dry-run installer prints expected file plan without touching disk. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js'); + +function freshTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-')); +} + +test('dry-run --only claude prints plan and writes nothing', () => { + const cfg = freshTmpDir(); + const r = spawnSync('node', [INSTALLER, + '--dry-run', '--only', 'claude', '--no-mcp-shrink', '--non-interactive', + '--config-dir', cfg, + ], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } }); + assert.equal(r.status, 0); + // Only fires if `claude` is on PATH on the test runner. If not, this assertion + // is a no-op (the installer just prints "nothing detected" and exits 0). + if (/Claude Code detected/.test(r.stdout)) { + assert.match(r.stdout, /would run: claude plugin marketplace add/); + assert.match(r.stdout, /would run: claude plugin install caveman@caveman/); + assert.match(r.stdout, /would mkdir -p .*\/hooks/); + assert.match(r.stdout, /would install .*caveman-activate\.js/); + assert.match(r.stdout, /would merge SessionStart \+ UserPromptSubmit \+ statusline/); + } + // Nothing should have been written. + assert.equal(fs.existsSync(path.join(cfg, 'settings.json')), false); + assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false); +}); + +test('dry-run --uninstall does not delete files', () => { + const cfg = freshTmpDir(); + // Seed a fake installation + fs.mkdirSync(path.join(cfg, 'hooks'), { recursive: true }); + const fake = path.join(cfg, 'hooks', 'caveman-activate.js'); + fs.writeFileSync(fake, '// fake'); + fs.writeFileSync(path.join(cfg, 'settings.json'), + JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node ' + fake }] }] } }, null, 2)); + const before = fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'); + + const r = spawnSync('node', [INSTALLER, '--uninstall', '--dry-run', '--non-interactive', '--config-dir', cfg], + { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } }); + assert.equal(r.status, 0); + + // File still present, settings unchanged. + assert.equal(fs.existsSync(fake), true); + assert.equal(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'), before); +}); diff --git a/tests/installer/unit.argv.test.mjs b/tests/installer/unit.argv.test.mjs new file mode 100644 index 0000000..d0db1b8 --- /dev/null +++ b/tests/installer/unit.argv.test.mjs @@ -0,0 +1,70 @@ +// Unit tests for the argv parser embedded in bin/install.js. +// We don't import parseArgs (it's not exported) — instead we shell out to the +// installer with --help / --list / unknown flags and assert the framing. +// For deeper coverage of flag-resolution semantics, exec --dry-run --list and +// check the rendered defaults. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js'); + +function run(...args) { + return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' }); +} + +test('--help prints usage and exits 0', () => { + const r = run('--help'); + assert.equal(r.status, 0); + assert.match(r.stdout, /USAGE/); + assert.match(r.stdout, /--with-hooks/); +}); + +test('--list prints provider matrix', () => { + const r = run('--list'); + assert.equal(r.status, 0); + assert.match(r.stdout, /caveman provider matrix/); + assert.match(r.stdout, /claude\b/); + assert.match(r.stdout, /gemini\b/); + assert.match(r.stdout, /antigravity\b.*\(soft\)/); +}); + +test('unknown flag exits 2 with error', () => { + const r = run('--bogus'); + assert.equal(r.status, 2); + assert.match(r.stderr, /unknown flag/); +}); + +test('--all + --minimal mutually exclusive', () => { + const r = run('--all', '--minimal'); + assert.equal(r.status, 2); + assert.match(r.stderr, /mutually exclusive/); +}); + +test('--only without arg fails', () => { + const r = run('--only'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--only requires an argument/); +}); + +test('--config-dir without arg fails', () => { + const r = run('--config-dir'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--config-dir requires a path/); +}); + +test('--config-dir followed by another flag fails', () => { + const r = run('--config-dir', '--all'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--config-dir requires a path/); +}); + +test('aider alias rewrites to aider-desk in dry-run output', () => { + const r = run('--dry-run', '--only', 'aider', '--non-interactive', '--config-dir', '/tmp/__cm_alias_test'); + // No detection means no install lines, but the script should not crash. + assert.equal(r.status, 0); +}); diff --git a/tests/installer/unit.settings.test.mjs b/tests/installer/unit.settings.test.mjs new file mode 100644 index 0000000..703cd6c --- /dev/null +++ b/tests/installer/unit.settings.test.mjs @@ -0,0 +1,164 @@ +// Unit tests for bin/lib/settings.js — the JSONC-tolerant settings helper. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const SETTINGS = require('../../bin/lib/settings.js'); + +function tmpFile(name, contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-settings-')); + const p = path.join(dir, name); + fs.writeFileSync(p, contents); + return p; +} + +test('stripJsonComments strips // line comments', () => { + const out = SETTINGS.stripJsonComments('{"a":1}// trail'); + assert.equal(out.trim(), '{"a":1}'); +}); + +test('stripJsonComments strips /* block */ comments', () => { + const out = SETTINGS.stripJsonComments('{/* leading */"a":1/* mid */, "b":2}'); + assert.match(out, /"a":1/); + assert.match(out, /"b":2/); + assert.doesNotMatch(out, /leading/); +}); + +test('stripJsonComments leaves comment-looking sequences inside strings alone', () => { + const out = SETTINGS.stripJsonComments('{"url":"http://example.com//path"}'); + assert.equal(out, '{"url":"http://example.com//path"}'); +}); + +test('stripJsonComments strips trailing commas', () => { + const out = SETTINGS.stripJsonComments('{"a":[1,2,3,],}'); + assert.doesNotThrow(() => JSON.parse(out)); +}); + +test('readSettings handles plain JSON', () => { + const p = tmpFile('s.json', '{"theme":"dark"}'); + assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark' }); +}); + +test('readSettings handles JSONC (comments + trailing commas)', () => { + const p = tmpFile('s.json', `// my settings +{ + "theme": "dark", /* mode */ + "hooks": {}, +}`); + assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark', hooks: {} }); +}); + +test('readSettings returns {} for missing file', () => { + assert.deepEqual(SETTINGS.readSettings('/nonexistent/path/xyz.json'), {}); +}); + +test('readSettings returns null for unrecoverable garbage', () => { + const p = tmpFile('s.json', 'this is not json at all {{{'); + assert.equal(SETTINGS.readSettings(p), null); +}); + +test('writeSettings round-trips with newline', () => { + const p = tmpFile('s.json', ''); + SETTINGS.writeSettings(p, { a: 1 }); + const raw = fs.readFileSync(p, 'utf8'); + assert.equal(raw.endsWith('\n'), true); + assert.deepEqual(JSON.parse(raw), { a: 1 }); +}); + +test('validateHookFields drops malformed command hook (missing command)', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [{ type: 'command' }, { type: 'command', command: 'good' }] }], + }, + }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks.SessionStart[0].hooks.length, 1); + assert.equal(s.hooks.SessionStart[0].hooks[0].command, 'good'); +}); + +test('validateHookFields drops malformed agent hook (missing prompt)', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [{ type: 'agent' }] }], + }, + }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks, undefined); +}); + +test('validateHookFields drops empty events and empty hooks parent', () => { + const s = { hooks: { SessionStart: [], UserPromptSubmit: [{ hooks: [] }] } }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks, undefined); +}); + +test('addCommandHook is idempotent on substring marker', () => { + const s = {}; + const a = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/abs/path/caveman-activate.js', marker: 'caveman-activate' }); + const b = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/different/abs/path/caveman-activate.js', marker: 'caveman-activate' }); + assert.equal(a, true); + assert.equal(b, false); + assert.equal(s.hooks.SessionStart.length, 1); +}); + +test('hasCavemanHook detects via substring', () => { + const s = { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node /x/caveman-activate.js' }] }] } }; + assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'caveman-activate'), true); + assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'gsd'), false); + assert.equal(SETTINGS.hasCavemanHook(s, 'UserPromptSubmit'), false); +}); + +test('removeCavemanHooks strips by marker and cleans empties', () => { + const s = { + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'caveman-x' }] }, + { hooks: [{ type: 'command', command: 'other' }] }, + ], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'caveman-y' }] }], + }, + }; + const removed = SETTINGS.removeCavemanHooks(s, 'caveman'); + assert.equal(removed, 2); + assert.equal(s.hooks.SessionStart.length, 1); + assert.equal(s.hooks.UserPromptSubmit, undefined); +}); + +test('rewriteLegacyManagedHookCommands rewrites bare-node managed scripts', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: 'node /abs/hooks/caveman-activate.js' }, + { type: 'command', command: 'node /abs/hooks/some-user-hook.js' }, + ] }], + }, + }; + const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node'); + assert.equal(n, 1); + assert.match(s.hooks.SessionStart[0].hooks[0].command, /"\/usr\/local\/bin\/node" "\/abs\/hooks\/caveman-activate\.js"/); + assert.equal(s.hooks.SessionStart[0].hooks[1].command, 'node /abs/hooks/some-user-hook.js'); +}); + +test('rewriteLegacyManagedHookCommands ignores already-absolute node commands', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: '"/usr/local/bin/node" "/abs/hooks/caveman-activate.js"' }, + ] }], + }, + }; + const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/somewhere/else/node'); + assert.equal(n, 0); +}); + +test('claudeConfigDir honors CLAUDE_CONFIG_DIR env', () => { + const orig = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = '/tmp/__cm_test_cfg'; + try { assert.equal(SETTINGS.claudeConfigDir(), '/tmp/__cm_test_cfg'); } + finally { if (orig === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = orig; } +});