mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
@@ -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
@@ -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
@@ -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" -- "$@"
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"cavecrew": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/cavecrew/SKILL.md",
|
||||
"computedHash": "06d45a7308d8603365313decf400020106b985cb5ce500ee169bfba9c71dd147"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
Reference in New Issue
Block a user