feat: land bin/install.js + JSONC settings helper + installer tests

Brings the long-stashed Node installer onto main. install.sh and install.ps1
shrink to thin shims (~50 lines each) that delegate to bin/install.js, fixing
the cross-platform drift that caused #249-class quoting bugs.

- bin/install.js (850 lines) — unified PROVIDERS-driven installer
- bin/lib/settings.js (221 lines) — JSONC parser + hook validator
  (validateHookFields prevents single bad hook from poisoning settings.json)
- tests/installer/{unit.argv,unit.settings,e2e.dryrun}.test.mjs — npm test
  now actually runs four real tests (was silently passing 0)
- .agents/skills/cavecrew, .junie/, .kiro/, .roo/ — per-agent skill mirrors
- skills-lock.json — vercel-labs/skills slug pinning
- install.{sh,ps1}.legacy escape hatch dropped (git history is the fallback)
- Minor cavecrew agent description refinements

Closes the gap between docs (already merged) describing bin/install.js and
the actual implementation.
This commit is contained in:
Julius Brussee
2026-05-10 14:11:17 +02:00
parent 5786dd56cc
commit 4f2314ae43
18 changed files with 1789 additions and 1397 deletions
+82
View File
@@ -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`**
```
<Header>:
- path:line — `symbol` — short note
totals: <counts>.
```
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
**`cavecrew-builder`**
```
<path:line-range> — <change ≤10 words>.
verified: <re-read OK | mismatch @ path:line>.
```
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
**`cavecrew-reviewer`**
```
path:line: <emoji> <severity>: <problem>. <fix>.
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.
+82
View File
@@ -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`**
```
<Header>:
- path:line — `symbol` — short note
totals: <counts>.
```
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
**`cavecrew-builder`**
```
<path:line-range> — <change ≤10 words>.
verified: <re-read OK | mismatch @ path:line>.
```
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
**`cavecrew-reviewer`**
```
path:line: <emoji> <severity>: <problem>. <fix>.
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.
+82
View File
@@ -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`**
```
<Header>:
- path:line — `symbol` — short note
totals: <counts>.
```
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
**`cavecrew-builder`**
```
<path:line-range> — <change ≤10 words>.
verified: <re-read OK | mismatch @ path:line>.
```
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
**`cavecrew-reviewer`**
```
path:line: <emoji> <severity>: <problem>. <fix>.
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.
+82
View File
@@ -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`**
```
<Header>:
- path:line — `symbol` — short note
totals: <counts>.
```
Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`.
**`cavecrew-builder`**
```
<path:line-range> — <change ≤10 words>.
verified: <re-read OK | mismatch @ path:line>.
```
Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token).
**`cavecrew-reviewer`**
```
path:line: <emoji> <severity>: <problem>. <fix>.
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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
---
+1 -1
View File
@@ -5,7 +5,7 @@ description: >
no scope creep. Output format `path:line: <emoji> <severity>: <problem>. <fix>.`
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
---
Executable
+850
View File
@@ -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>` — bin on PATH. Most reliable signal.
// - `vscode-ext:<needle>` / `cursor-ext:<needle>` — extension dir name match.
// - `jetbrains-plugin:<needle>` — JetBrains plugin dir match.
// - `dir:<path>` / `file:<path>` — 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 <id>`. 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 <repo>/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 <agent> 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 <path> 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 <id>`. 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 <agent> 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); });
+221
View File
@@ -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/<managed>.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,
};
+45 -620
View File
@@ -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 <agent>[,<agent>]] [-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 <list> 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 <agent> 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
+38 -771
View File
@@ -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 <agent> 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:<bin>` (binary on PATH), `dir:<path>` (directory
# exists), `file:<path>` (file exists), `vscode-ext:<needle>`,
# `cursor-ext:<needle>`, `jetbrains-config`, or `jetbrains-plugin:<needle>`.
# 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 <<EOF
$spec
EOF
if want "$id" && detect_match "$detect_spec"; then
install_via_skills "$id" "$label" "$profile"
fi
done
# ── Generic fallback: npx skills add (auto-detect) ─────────────────────────
# Only fire if (a) no --only filter, (b) skills not disabled, (c) we neither
# installed, skipped, nor failed anything detected.
if [ "$SKIP_SKILLS" = 0 ] && [ ${#ONLY[@]} -eq 0 ] && [ "$DETECTED_COUNT" -eq 0 ]; then
say "→ no known agents detected — running npx-skills auto-detect fallback"
if ensure_node; then
if try npx -y skills add "$REPO"; then
record_installed "skills-auto"
else
record_failed "skills-auto" "npx skills add (auto) failed"
fi
fi
echo
fi
# ── --with-init: drop per-repo rule files into $PWD ────────────────────────
run_init() {
local args=("$PWD")
[ "$DRY" = 1 ] && args+=("--dry-run")
[ "$FORCE" = 1 ] && args+=("--force")
if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/tools/caveman-init.js" ]; then
if has node; then
try node "$REPO_ROOT/tools/caveman-init.js" "${args[@]}"
return $?
fi
fi
# Curl-pipe fallback: stream the init script into `node -`.
if ! has node; then
warn " node required to run caveman-init (install Node.js: https://nodejs.org)"
return 1
fi
if ! has curl; then
warn " curl required to fetch caveman-init remotely"
return 1
fi
if [ "$DRY" = 1 ]; then
note " would run: curl -fsSL $INIT_SCRIPT_URL | node - ${args[*]}"
return 0
fi
curl -fsSL "$INIT_SCRIPT_URL" | node - "${args[@]}"
}
if [ "$WITH_INIT" = 1 ]; then
say "→ writing per-repo IDE rule files into $PWD (--with-init)"
if run_init; then
record_installed "caveman-init ($PWD)"
else
record_failed "caveman-init" "tools/caveman-init.js failed"
fi
echo
elif [ ${#INSTALLED_IDS[@]} -gt 0 ] || [ ${#SKIPPED_IDS[@]} -gt 0 ]; then
# Friendly nudge for the per-repo flow (only when we actually did something).
note " tip: re-run inside a repo with --all (or --with-init) to also write per-repo"
note " Cursor/Windsurf/Cline/Copilot/AGENTS.md rule files."
fi
# ── Summary ────────────────────────────────────────────────────────────────
echo
say "🪨 done"
if [ ${#INSTALLED_IDS[@]} -gt 0 ]; then
ok " installed:"
for a in "${INSTALLED_IDS[@]}"; do printf ' • %s\n' "$a"; done
fi
if [ ${#SKIPPED_IDS[@]} -gt 0 ]; then
echo " skipped:"
i=0
while [ $i -lt ${#SKIPPED_IDS[@]} ]; do
printf ' • %s — %s\n' "${SKIPPED_IDS[$i]}" "${SKIPPED_WHY[$i]}"
i=$((i + 1))
done
fi
if [ ${#FAILED_IDS[@]} -gt 0 ]; then
warn " failed:"
i=0
while [ $i -lt ${#FAILED_IDS[@]} ]; do
printf ' • %s — %s\n' "${FAILED_IDS[$i]}" "${FAILED_WHY[$i]}" >&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 <agent> 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" -- "$@"
+1 -1
View File
@@ -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.
@@ -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
---
+1 -1
View File
@@ -5,7 +5,7 @@ description: >
no scope creep. Output format `path:line: <emoji> <severity>: <problem>. <fix>.`
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
---
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"cavecrew": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/cavecrew/SKILL.md",
"computedHash": "06d45a7308d8603365313decf400020106b985cb5ce500ee169bfba9c71dd147"
}
}
}
+56
View File
@@ -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);
});
+70
View File
@@ -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);
});
+164
View File
@@ -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; }
});