#!/usr/bin/env node // caveman — unified cross-platform installer. // // One Node script replaces the old install.sh + install.ps1 + src/hooks/install.sh // + src/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 crypto = require('crypto'); const SETTINGS = require('./lib/settings'); const OPENCLAW = require('./lib/openclaw'); const { stripOpencodeAgentTools } = require('./lib/opencode-agent'); const REPO = 'JuliusBrussee/caveman'; // Pin remote fetches to an immutable release tag, not the moving `main` // branch (issue #261). A push to main must never silently change what a // curl|bash / detached-script install downloads and executes. Bump this to // the new tag on every release (CI release step) AFTER regenerating // src/hooks/checksums.sha256 so the integrity manifest matches the ref. // Overridable via CAVEMAN_REF for testing against a branch. const PINNED_REF = process.env.CAVEMAN_REF || 'v1.9.1'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${PINNED_REF}`; const HOOKS_REMOTE = `${RAW_BASE}/src/hooks`; const INIT_SCRIPT_URL = `${RAW_BASE}/src/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', 'cavecrew-model-overrides.js', ]; // ── Argv ─────────────────────────────────────────────────────────────────── function parseArgs(argv) { const opts = { dryRun: false, force: false, skipSkills: false, withHooks: 'auto', withInit: false, withMcpShrink: false, 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]; // --with-mcp-shrink= (handled before the switch so the // GNU-style =value form is recognized). Bare --with-mcp-shrink falls // through to the switch and is rejected — caveman-shrink is a proxy // and a stub registration just lands the user in a broken-MCP loop (#474). if (a.startsWith('--with-mcp-shrink=')) { const raw = a.slice('--with-mcp-shrink='.length); const tokens = raw.trim().split(/\s+/).filter(Boolean); if (tokens.length === 0) { die('error: --with-mcp-shrink requires an upstream command\n' + ' example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"'); } opts.withMcpShrink = tokens; continue; } 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': { const v = argv[i + 1]; if (v && !v.startsWith('--')) { i++; const tokens = v.trim().split(/\s+/).filter(Boolean); if (tokens.length === 0) { die('error: --with-mcp-shrink requires an upstream command\n' + ' example: --with-mcp-shrink "npx @modelcontextprotocol/server-filesystem /path"'); } opts.withMcpShrink = tokens; } else { die('error: --with-mcp-shrink requires an upstream command — caveman-shrink\n' + ' is a proxy and exits immediately without one. Pass the upstream:\n' + ' --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"'); } 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; // POSIX end-of-options marker. Older curl|bash flows pipe `-- --only foo` // through npx; some npx versions forward the literal `--`. Accept and // ignore so we never regress on the headline install command. case '--': 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 = expandHome(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'); // --all turns on per-repo init only. It deliberately does NOT force: // • withHooks — left at 'auto' so installClaude() can skip standalone // settings.json wiring when the plugin manifest already wires the hooks // (duplicate registration fires both per event — issue #392). // • withMcpShrink — caveman-shrink is a proxy that needs an upstream // command, so there's no sensible "everything on" default (issue #474). // Opt in explicitly with --with-mcp-shrink="". if (opts.all) { opts.withInit = true; } if (opts.minimal) { opts.withHooks = false; opts.withInit = false; opts.withMcpShrink = false; } // Validate --only ids against the provider matrix. PROVIDERS is defined later // in the file but is in scope by the time this function runs. if (opts.only.length) { const knownIds = new Set(PROVIDERS.map(p => p.id)); for (const id of opts.only) { if (!knownIds.has(id)) { die(`error: unknown agent: ${id}\n see 'caveman --list' for valid ids`); } } } return opts; } function die(msg) { process.stderr.write(msg + '\n'); process.exit(2); } // ── Color helpers ────────────────────────────────────────────────────────── function makeChalk(noColor) { const useColor = !noColor && process.stdout.isTTY && !process.env.NO_COLOR; const wrap = (codes) => (s) => useColor ? `\x1b[${codes}m${s}\x1b[0m` : s; return { orange: wrap('38;5;172'), dim: wrap('2'), red: wrap('31'), green: wrap('32'), yellow: wrap('33'), }; } // ── Env guards ───────────────────────────────────────────────────────────── function checkWslWindowsNode() { if (process.platform !== 'win32') return; // Windows-Node executing inside WSL has homedir like /mnt/c/Users/... which // breaks every config-dir resolution. Detect and abort with a clear hint. if (process.env.WSL_DISTRO_NAME) { die('caveman: detected Windows Node.js running inside WSL.\n' + ' Install Linux-native Node inside your WSL distro and re-run there.\n' + ' (WSL_DISTRO_NAME=' + process.env.WSL_DISTRO_NAME + ')'); } try { const v = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); if (v.includes('microsoft') || v.includes('wsl')) { die('caveman: detected Windows Node.js running inside WSL (/proc/version).\n' + ' Install Linux-native Node inside your WSL distro and re-run there.'); } } catch (_) { /* /proc/version absent on real Windows — fine */ } } function checkNodeVersion() { const major = parseInt(process.versions.node.split('.')[0], 10); if (major < 18) die(`caveman: Node ${process.versions.node} too old. Need Node ≥18. https://nodejs.org`); } // ── Provider matrix ──────────────────────────────────────────────────────── // Single source of truth. Replaces the 6 parallel bash arrays in old install.sh. // // Detection rules: // - `command:` — bin on PATH. Most reliable signal. // - `vscode-ext:` / `cursor-ext:` — extension dir name match. // - `jetbrains-plugin:` — JetBrains plugin dir match. // - `dir:` / `file:` — kept ONLY for agents that ship no CLI // and no extension marker (true dir-only signal). // // `soft: true` means detection is best-effort (config-dir only or no // reliable probe). Soft providers are EXCLUDED from auto-detect and only // install when the user passes `--only `. This stops the installer from // firing `npx skills add ...` against agents the user has never installed // just because some other tool created `~/.foo` along the way. const PROVIDERS = [ { id: 'claude', label: 'Claude Code', mech: 'claude plugin install', detect: 'command:claude' }, { id: 'gemini', label: 'Gemini CLI', mech: 'gemini extensions install', detect: 'command:gemini' }, { id: 'opencode', label: 'opencode', mech: 'native opencode plugin', detect: 'command:opencode' }, { id: 'openclaw', label: 'OpenClaw', mech: 'workspace skill + SOUL.md', detect: 'command:openclaw||dir:$HOME/.openclaw/workspace' }, { 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: detected via VS Code / Cursor extension dirs (no `gh` CLI // needed). The old `command:copilot` soft probe never fired for most users // because Copilot ships as an editor extension, not a CLI (issue #336). { id: 'copilot', label: 'GitHub Copilot', mech: 'npx skills add (github-copilot)', detect: 'vscode-ext:github.copilot||vscode-ext:github.copilot-chat||cursor-ext:github.copilot', profile: 'github-copilot' }, // 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: 'hermes', label: 'Hermes Agent', mech: 'native hermes skills copy', detect: 'command:hermes' }, { 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: '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) { 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; } } function shellEscape(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } function expandHome(p) { return p.replace(/^\$HOME/, os.homedir()).replace(/^~/, os.homedir()); } function vscodeExtPresent(needle) { const home = os.homedir(); const roots = [ path.join(home, '.vscode/extensions'), path.join(home, '.vscode-server/extensions'), path.join(home, '.cursor/extensions'), path.join(home, '.windsurf/extensions'), ]; const re = new RegExp(needle, 'i'); for (const r of roots) { if (!fs.existsSync(r)) continue; let entries; try { entries = fs.readdirSync(r); } catch (_) { continue; } if (entries.some(e => re.test(e))) return true; } return false; } function cursorExtPresent(needle) { const dir = path.join(os.homedir(), '.cursor/extensions'); if (!fs.existsSync(dir)) return false; const re = new RegExp(needle, 'i'); try { return fs.readdirSync(dir).some(e => re.test(e)); } catch (_) { return false; } } function jetbrainsPresent() { const home = os.homedir(); return fs.existsSync(path.join(home, 'Library/Application Support/JetBrains')) || fs.existsSync(path.join(home, '.config/JetBrains')); } function jetbrainsPluginPresent(needle) { const home = os.homedir(); const roots = [ path.join(home, 'Library/Application Support/JetBrains'), path.join(home, '.config/JetBrains'), ]; const re = new RegExp(needle, 'i'); for (const r of roots) { if (!fs.existsSync(r)) continue; if (walkDir(r, 4).some(p => re.test(path.basename(p)))) return true; } return false; } function walkDir(root, depth) { const out = []; if (depth < 0) return out; let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { return out; } for (const e of entries) { const full = path.join(root, e.name); if (e.isDirectory()) { out.push(full); out.push(...walkDir(full, depth - 1)); } } return out; } function macAppPresent(name) { if (process.platform !== 'darwin') return false; const candidates = [ `/Applications/${name}.app`, path.join(os.homedir(), 'Applications', `${name}.app`), ]; return candidates.some(p => fs.existsSync(p)); } function detectMatch(spec) { if (!spec) return false; for (const clause of spec.split('||')) { const c = clause.trim(); if (!c) continue; const colon = c.indexOf(':'); const kind = colon === -1 ? c : c.slice(0, colon); const val = colon === -1 ? '' : expandHome(c.slice(colon + 1)); let ok = false; switch (kind) { case 'command': ok = hasCmd(val); break; case 'dir': ok = safeStat(val, 'isDirectory'); break; case 'file': ok = safeStat(val, 'isFile'); break; case 'macapp': ok = macAppPresent(val); break; case 'vscode-ext': ok = vscodeExtPresent(val); break; case 'cursor-ext': ok = cursorExtPresent(val); break; case 'jetbrains-config': ok = jetbrainsPresent(); break; case 'jetbrains-plugin': ok = jetbrainsPluginPresent(val); break; } if (ok) return true; } return false; } function safeStat(p, method) { try { return fs.statSync(p)[method](); } catch (_) { return false; } } // ── Repo root resolution ─────────────────────────────────────────────────── function detectRepoRoot() { // bin/install.js sits at /bin/install.js. Walk up one. const here = path.dirname(__filename); const root = path.resolve(here, '..'); if (fs.existsSync(path.join(root, 'src', '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 || {})); } // Create env with TMPDIR pointing to a temp dir inside configDir. // Workaround for Claude Code plugin install EXDEV bug: it tries to rename // from ~/.claude/plugins/cache/ to /tmp/ which fails when /tmp is on a // different filesystem (common on Linux). Setting TMPDIR to a directory // on the same filesystem as ~/.claude/ avoids the cross-device link error. function sameFilesystemTmpEnv(configDir) { const tmpDir = path.join(configDir, 'tmp'); try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (_) {} return Object.assign({}, process.env, { TMPDIR: tmpDir, // Unix TEMP: tmpDir, // Windows TMP: tmpDir, // Windows alternate }); } function captureSpawn(cmd, args) { try { return spawnXplat(cmd, args, { encoding: 'utf8' }); } catch (_) { return { status: 1, stdout: '', stderr: '' }; } } // spawnSync reports a missing binary as { status: null, error }, so the old // `(r.status || 0) === 0` checks read ENOENT as success — a machine without // the `claude` CLI got "installed: claude" with nothing installed and the // standalone-hook fallback skipped (issue #592). Every spawn result must pass // through here before being treated as "it worked". function spawnOk(r) { return !!r && !r.error && r.status === 0; } function absoluteNodePath() { return process.execPath; } // ── Per-provider installers ──────────────────────────────────────────────── async function installClaude(ctx) { const { say, note, warn, ok, opts, results, configDir } = 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; } let pluginInstallSucceeded = false; if (alreadyInstalled) { note(' caveman plugin already installed (use --force to reinstall)'); results.skipped.push(['claude', 'plugin already installed']); pluginInstallSucceeded = true; } else { // Use a temp dir on the same filesystem as configDir to avoid EXDEV errors // when Claude Code's plugin installer tries to rename across filesystems (#585). const pluginEnv = sameFilesystemTmpEnv(configDir); const r1 = runSpawn('claude', ['plugin', 'marketplace', 'add', REPO], { env: pluginEnv }, opts.dryRun); const r2 = runSpawn('claude', ['plugin', 'install', 'caveman@caveman'], { env: pluginEnv }, opts.dryRun); if (spawnOk(r1) && spawnOk(r2)) { results.installed.push('claude'); pluginInstallSucceeded = true; } else { if (r1.error || r2.error) { warn(' claude CLI not found on PATH (or could not be spawned)'); } results.failed.push(['claude', 'claude plugin install failed']); } } // Self-heal: drop managed settings.json hook/statusLine entries whose target // script no longer exists (issue #471). Migrating an old manual install to // the plugin leaves settings.json pointing at removed ~/.claude/hooks/ // caveman-*.js scripts, so Claude Code crashes every SessionStart / // UserPromptSubmit with `loader:1478 — Cannot find module …`. Runs // unconditionally so it repairs an already-dirty config even when we then // skip standalone wiring because the plugin manifest handles hooks. { const settingsPath = path.join(configDir, 'settings.json'); const settings = SETTINGS.readSettings(settingsPath); if (settings) { const pruned = SETTINGS.pruneOrphanedManagedHooks(settings, configDir); if (pruned > 0) { note(` removed ${pruned} orphaned caveman hook entr${pruned === 1 ? 'y' : 'ies'} from settings.json (target script missing)`); if (!opts.dryRun) { SETTINGS.validateHookFields(settings); SETTINGS.writeSettings(settingsPath, settings); } } } } // Hook wiring decision matrix (issue #392 — avoid double-firing): // --no-hooks → skip // --with-hooks → wire (warn if the plugin manifest also wires them) // default / --all → wire only if the plugin install did NOT succeed. // The plugin manifest already wires SessionStart + UserPromptSubmit when the // plugin install succeeds; wiring them again in settings.json fires both per // event (two CAVEMAN MODE blocks, two reinforcement lines). let shouldWireHooks; if (opts.withHooks === false) { shouldWireHooks = false; } else if (opts.withHooks === true) { shouldWireHooks = true; if (pluginInstallSucceeded) { warn(' --with-hooks wires hooks in settings.json alongside the plugin manifest.'); warn(' Both will fire on every event. Pass --no-hooks to keep only the plugin path.'); } } else { // 'auto' shouldWireHooks = !pluginInstallSucceeded; if (!shouldWireHooks) { note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit'); note(' (pass --with-hooks to also wire standalone hooks in settings.json)'); results.skipped.push(['claude-hooks', 'plugin manifest handles hooks']); } else { note(' hooks: plugin install did not succeed; falling back to standalone wiring'); } } if (shouldWireHooks) { say(' → installing hooks'); const r = await 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 (spawnOk(r)) 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`); // --skill '*' --yes: skip the upstream skill-selection TUI and confirmation // prompts. Without --skill, `curl|bash` (no TTY on stdin) renders an empty // checkbox list the user can't interact with, then exits 0 with zero skills // installed — and our installer happily reports success. See issue #370. // // We pass `--skill '*'` rather than `--all` because the upstream `skills` CLI // interprets `--all` as "all skills from the source to *all* agents", which // ignores the `-a prov.profile` selection and writes every skill through // every agent adapter (see issue #389). `--skill '*' -a ` is the // documented form for "install every skill into a specific agent". const args = ['-y', 'skills', 'add', REPO, '--skill', '*', '-a', prov.profile, '--yes']; const r = runSpawn('npx', args, null, opts.dryRun); if (spawnOk(r)) results.installed.push(prov.id); else results.failed.push([prov.id, `npx skills add (${prov.profile}) failed`]); process.stdout.write('\n'); } // ── hermes native install ────────────────────────────────────────────────── // Drops the caveman skills into ~/.hermes/skills/productivity/ (or HERMES_HOME if set). const HERMES_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']; function hermesConfigDir() { // Hermes uses ~/.hermes by default, or HERMES_HOME env var. if (process.env.HERMES_HOME) return path.join(process.env.HERMES_HOME, 'skills'); return path.join(os.homedir(), '.hermes', 'skills'); } function installHermes(ctx) { const { say, note, warn, opts, repoRoot, results } = ctx; results.detected++; say('→ Hermes Agent detected'); if (!repoRoot) { warn(' Hermes native install requires a local clone of the caveman repo.'); note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only hermes'); results.failed.push(['hermes', 'native install requires local repo clone']); process.stdout.write('\n'); return; } const skillsRoot = path.join(hermesConfigDir(), 'productivity'); if (opts.dryRun) { note(` would mkdir ${skillsRoot}/`); note(` would copy ${HERMES_SKILL_DIRS.length} skill dirs into ${skillsRoot}/`); results.installed.push('hermes'); process.stdout.write('\n'); return; } try { fs.mkdirSync(skillsRoot, { recursive: true }); for (const skillDir of HERMES_SKILL_DIRS) { const srcDir = path.join(repoRoot, 'skills', skillDir); const destDir = path.join(skillsRoot, skillDir); if (fs.existsSync(srcDir)) { // Remove existing to ensure clean copy if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true }); copyDirRecursive(srcDir, destDir); note(` copied ${skillDir} → ${destDir}`); } else { warn(` skill dir not found: ${srcDir}`); } } results.installed.push('hermes'); } catch (err) { results.failed.push(['hermes', 'copy failed: ' + err.message]); } process.stdout.write('\n'); } // ── opencode native install ─────────────────────────────────────────────── // Drops the in-repo plugin (src/plugins/opencode/) plus skills, agents, // commands, and an AGENTS.md ruleset into ~/.config/opencode/. Patches // opencode.json with a "plugin" array entry. Mirrors the Claude Code hook // architecture as closely as opencode allows — only the statusline is missing // (opencode's TUI exposes no plugin-writable badge). const OPENCODE_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']; const OPENCODE_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']; const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md']; const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js'; const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman'; // Marker fence for the opencode AGENTS.md ruleset block. Same convention as // bin/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when // the user has authored content above AND below it. const OPENCODE_AGENTS_MD_BEGIN = ''; const OPENCODE_AGENTS_MD_END = ''; function opencodeConfigDir() { // opencode uses ~/.config/opencode on every platform (on Windows that's // %USERPROFILE%\.config\opencode via os.homedir()), NOT %APPDATA% (#376). if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'opencode'); return path.join(os.homedir(), '.config', 'opencode'); } function copyDirRecursive(src, dest) { fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, entry.name); const d = path.join(dest, entry.name); if (entry.isDirectory()) copyDirRecursive(s, d); else if (entry.isFile()) fs.copyFileSync(s, d); } } function installOpencode(ctx) { const { say, note, warn, opts, repoRoot, results } = ctx; results.detected++; say('→ opencode detected'); if (!repoRoot) { warn(' opencode native install requires a local clone of the caveman repo.'); note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only opencode'); results.failed.push(['opencode', 'native install requires local repo clone']); process.stdout.write('\n'); return; } const dir = opencodeConfigDir(); const pluginDir = path.join(dir, 'plugins', 'caveman'); const commandsDir = path.join(dir, 'commands'); const agentsDir = path.join(dir, 'agents'); const skillsDir = path.join(dir, 'skills'); const opencodeJson = path.join(dir, 'opencode.json'); const agentsMd = path.join(dir, 'AGENTS.md'); if (opts.dryRun) { note(` would mkdir ${pluginDir}/, ${commandsDir}/, ${agentsDir}/, ${skillsDir}/`); note(` would copy plugin.js + package.json + caveman-config.cjs into ${pluginDir}/`); note(` would copy ${OPENCODE_COMMAND_FILES.length} command files into ${commandsDir}/`); note(` would copy ${OPENCODE_AGENT_FILES.length} cavecrew agents into ${agentsDir}/`); note(` would copy ${OPENCODE_SKILL_DIRS.length} skill dirs into ${skillsDir}/`); note(` would patch ${opencodeJson} with "plugin" entry${opts.withMcpShrink ? ' + caveman-shrink MCP' : ''}`); note(` would write Tier-3 ruleset to ${agentsMd}`); results.installed.push('opencode'); process.stdout.write('\n'); return; } try { // 1. Plugin dir — copy plugin.js, package.json, caveman-config.js (sibling). // Same `--force` semantic as commands/agents/skills below: re-runs leave // user edits to plugin.js alone unless --force is passed. fs.mkdirSync(pluginDir, { recursive: true }); const pluginSrc = path.join(repoRoot, 'src', 'plugins', 'opencode'); const pluginPayload = [ [path.join(pluginSrc, 'plugin.js'), path.join(pluginDir, 'plugin.js')], [path.join(pluginSrc, 'package.json'), path.join(pluginDir, 'package.json')], // Renamed to .cjs because the plugin dir is "type": "module" — a bare .js // sibling would be loaded as ESM and break the plugin's require() bridge. [path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'), path.join(pluginDir, 'caveman-config.cjs')], ]; for (const [src, dest] of pluginPayload) { if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } fs.copyFileSync(src, dest); } process.stdout.write(` installed: ${pluginDir}\n`); // 2. Commands. fs.mkdirSync(commandsDir, { recursive: true }); const cmdSrcDir = path.join(pluginSrc, 'commands'); for (const f of OPENCODE_COMMAND_FILES) { const src = path.join(cmdSrcDir, f); const dest = path.join(commandsDir, f); if (!fs.existsSync(src)) continue; // defense-in-depth: skip a missing command file rather than crash (#434) if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } fs.copyFileSync(src, dest); process.stdout.write(` installed: ${dest}\n`); } // 3. Subagents. Source files target Claude Code's schema (`tools: [...]` // YAML array); opencode rejects that form and refuses to boot until the // file is removed. Strip the `tools:` line on copy — opencode falls back // to its default tool set, and subagent prompts already self-restrict in // the body. Issue #386. fs.mkdirSync(agentsDir, { recursive: true }); const agentSrcDir = path.join(repoRoot, 'agents'); for (const f of OPENCODE_AGENT_FILES) { const src = path.join(agentSrcDir, f); const dest = path.join(agentsDir, f); if (!fs.existsSync(src)) continue; if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8'))); process.stdout.write(` installed: ${dest}\n`); } // 4. Skills — opencode auto-discovers SKILL.md from ~/.config/opencode/skills/. fs.mkdirSync(skillsDir, { recursive: true }); const skillSrcDir = path.join(repoRoot, 'skills'); for (const name of OPENCODE_SKILL_DIRS) { const src = path.join(skillSrcDir, name); const dest = path.join(skillsDir, name); if (!fs.existsSync(src)) continue; if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest}/ (exists; --force to overwrite)`); continue; } copyDirRecursive(src, dest); process.stdout.write(` installed: ${dest}/\n`); } // 5. AGENTS.md — Tier-3 always-on ruleset. Wrapped in begin/end markers so // a later --uninstall can strip our block cleanly even if the user has // authored content above AND below it. Idempotency check uses the begin // marker (the legacy sentinel still matches old installs). const ruleBody = fs.readFileSync(path.join(repoRoot, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n'; const fencedBlock = `${OPENCODE_AGENTS_MD_BEGIN}\n${ruleBody}${OPENCODE_AGENTS_MD_END}\n`; if (fs.existsSync(agentsMd)) { const existing = fs.readFileSync(agentsMd, 'utf8'); const alreadyFenced = existing.includes(OPENCODE_AGENTS_MD_BEGIN) && existing.includes(OPENCODE_AGENTS_MD_END); const alreadyByLegacySentinel = !alreadyFenced && existing.includes(OPENCODE_AGENTS_MD_SENTINEL); if (alreadyFenced) { note(` ${agentsMd} already contains caveman ruleset`); } else if (alreadyByLegacySentinel) { if (!opts.force) { note(` ${agentsMd} contains a legacy (un-fenced) caveman block — leaving as-is`); note(' re-run with --force to migrate it to a fenced block'); } if (opts.force) { // Migrate, don't wipe (issue #594): the old code replaced the whole // file, destroying any user-authored content around the legacy // block. Back up once, then remove only the legacy block: exact // match of the current rule body when possible, otherwise cut from // the sentinel's paragraph start to EOF (the legacy path APPENDED // the block, so user content precedes it; anything after lives on // in the backup). const agentsBak = agentsMd + '.bak'; if (!fs.existsSync(agentsBak)) { try { fs.copyFileSync(agentsMd, agentsBak); } catch (_) {} } const bodyTrim = ruleBody.trimEnd(); let userPart; const exact = existing.indexOf(bodyTrim); if (exact !== -1) { userPart = (existing.slice(0, exact) + existing.slice(exact + bodyTrim.length)).trim(); } else { const sentinelAt = existing.indexOf(OPENCODE_AGENTS_MD_SENTINEL); const cutAt = existing.lastIndexOf('\n\n', sentinelAt); userPart = cutAt === -1 ? '' : existing.slice(0, cutAt).trim(); note(` legacy block did not match the current ruleset — everything from the sentinel down was replaced; original kept at ${agentsBak}`); } const next = (userPart ? userPart + '\n\n' : '') + fencedBlock; fs.writeFileSync(agentsMd, next, { mode: 0o644 }); process.stdout.write(` migrated ${agentsMd} legacy block to fenced (backup: ${agentsBak})\n`); } } else { const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); fs.writeFileSync(agentsMd, existing + sep + fencedBlock, { mode: 0o644 }); process.stdout.write(` appended caveman ruleset to ${agentsMd}\n`); } } else { fs.writeFileSync(agentsMd, fencedBlock, { mode: 0o644 }); process.stdout.write(` installed: ${agentsMd}\n`); } // 6. opencode.json — add plugin entry; optional caveman-shrink MCP. let cfg = SETTINGS.readSettings(opencodeJson); if (cfg === null) { warn(` ${opencodeJson} unparseable; will not touch it. Edit manually then re-run.`); results.failed.push(['opencode', 'opencode.json unparseable']); process.stdout.write('\n'); return; } // Preserve the original on first install only — repeat installs would // otherwise overwrite the only known-good copy with an already-merged file. const opencodeBak = opencodeJson + '.bak'; if (fs.existsSync(opencodeJson) && !fs.existsSync(opencodeBak)) { try { fs.copyFileSync(opencodeJson, opencodeBak); } catch (_) {} } if (!Array.isArray(cfg.plugin)) cfg.plugin = []; if (!cfg.plugin.includes(OPENCODE_PLUGIN_REL)) { cfg.plugin.push(OPENCODE_PLUGIN_REL); } if (opts.withMcpShrink) { // opts.withMcpShrink is the array of upstream-cmd tokens parseArgs // produced. caveman-shrink is a proxy — it crashes without an upstream, // so we always wire one through. if (!cfg.mcp || typeof cfg.mcp !== 'object') cfg.mcp = {}; if (!cfg.mcp['caveman-shrink']) { cfg.mcp['caveman-shrink'] = { type: 'local', command: ['npx', '-y', MCP_SHRINK_PKG, ...opts.withMcpShrink], enabled: true, }; process.stdout.write(` registered caveman-shrink MCP server (wraps: ${opts.withMcpShrink.join(' ')})\n`); } } SETTINGS.writeSettings(opencodeJson, cfg); process.stdout.write(` patched: ${opencodeJson}\n`); results.installed.push('opencode'); } catch (e) { warn(' opencode install failed: ' + (e && e.message || e)); results.failed.push(['opencode', (e && e.message) || 'unknown error']); } process.stdout.write('\n'); } // ── OpenClaw native install ─────────────────────────────────────────────── // Drops skills/caveman/ into the OpenClaw workspace and appends a small // auto-injected bootstrap block to the workspace SOUL.md. Always-on behavior // comes from SOUL.md (auto-injected each turn); the skill folder makes // caveman discoverable via `openclaw skills list`. See bin/lib/openclaw.js // for the actual file writes. function installOpenclaw(ctx) { const { say, note, warn, opts, repoRoot, results } = ctx; results.detected++; say('→ OpenClaw detected'); const log = { write: (s) => process.stdout.write(s), note: (s) => note(s), warn: (s) => warn(s), }; const r = OPENCLAW.installOpenclaw({ workspace: process.env.OPENCLAW_WORKSPACE || undefined, repoRoot, dryRun: opts.dryRun, force: opts.force, log, }); if (r.ok) results.installed.push('openclaw'); else results.failed.push(['openclaw', r.reason || 'install failed']); process.stdout.write('\n'); } // ── Hooks installer ──────────────────────────────────────────────────────── // Replaces src/hooks/install.sh + src/hooks/install.ps1. async 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, 'src', '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. // Downloaded files (the rare detached-script / curl fallback) are verified // against the SHA-256 manifest published at the pinned release ref (#262); // a mismatch aborts before the file is wired into settings.json. Local // copies are trusted — they come from the same package as this script. let checksums; // undefined = not yet loaded; null = unavailable for this ref let warnedNoChecksums = false; 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 { await downloadTo(`${HOOKS_REMOTE}/${f}`, dest); } catch (e) { return `download ${f} failed: ${e.message}`; } if (checksums === undefined) checksums = await loadRemoteHookChecksums(); if (checksums) { const want = checksums.get(f); const got = sha256File(dest); if (!want || want !== got) { try { fs.unlinkSync(dest); } catch (_) {} return `integrity check failed for ${f} (expected ${want || ''}, got ${got}) — ` + `refusing to install a hook that doesn't match pinned release ${PINNED_REF}`; } } else if (!warnedNoChecksums) { warnedNoChecksums = true; warn(` note: no integrity manifest at ${PINNED_REF} — downloaded hooks installed unverified.`); } } 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, preserved across reinstalls. Without the !fs.existsSync(bak) // guard, the second install would overwrite the only known-good copy with // the already-merged file, destroying recovery. const bak = settingsPath + '.bak'; if (fs.existsSync(settingsPath) && !fs.existsSync(bak)) { try { fs.copyFileSync(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 src/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(' src/hooks/README.md to your Claude Code MCP config manually.'); return { kind: 'skip', why: 'manual config required' }; } // opts.withMcpShrink is always an array of upstream-cmd tokens by the // time we get here; parseArgs rejects bare --with-mcp-shrink. The proxy // gets `npx -y caveman-shrink ` so it has something // to wrap. const upstream = opts.withMcpShrink; const r = runSpawn( 'claude', ['mcp', 'add', 'caveman-shrink', '--', 'npx', '-y', MCP_SHRINK_PKG, ...upstream], null, opts.dryRun ); if (spawnOk(r)) { note(` registered, wrapping: ${upstream.join(' ')}`); note(` Edit ~/.claude.json mcpServers["caveman-shrink"] to change the upstream,`); note(' or `claude mcp remove caveman-shrink` to drop it.'); note(` Docs: https://github.com/${REPO}/tree/main/src/mcp-servers/caveman-shrink`); return { kind: 'ok' }; } return { kind: 'fail', why: 'claude mcp add failed' }; } // ── Init writers (per-repo rule files) ──────────────────────────────────── async function runInit(ctx) { const { note, warn, opts, repoRoot } = ctx; const local = repoRoot && path.join(repoRoot, 'src/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 spawnOk(r); } // 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`); await downloadTo(INIT_SCRIPT_URL, tmp); const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' }); try { fs.unlinkSync(tmp); } catch (_) {} return spawnOk(r); } 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); }); } // ── Integrity verification for downloaded hooks (#262) ───────────────────── function sha256File(p) { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); } // Download + parse the hook integrity manifest from the pinned release ref. // Returns Map, or null when the manifest is unavailable // (release tags older than this feature predate it) — the caller treats null // as "cannot verify" and warns rather than aborting, for back-compat. Parses // the standard `sha256sum` text format: "<64-hex> " (two spaces, or // " *" binary marker). async function loadRemoteHookChecksums() { const tmp = path.join(os.tmpdir(), `caveman-checksums-${process.pid}-${Date.now()}.sha256`); try { await downloadTo(`${HOOKS_REMOTE}/checksums.sha256`, tmp); const txt = fs.readFileSync(tmp, 'utf8'); const map = new Map(); for (const line of txt.split('\n')) { const m = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); if (m) map.set(path.basename(m[2].trim()), m[1].toLowerCase()); } return map.size ? map : null; } catch (_) { return null; } finally { try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ } } } // ── 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); // 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. Probe `plugin list` first so a re-run on a // machine where caveman was never installed (or was already removed) doesn't // print "Plugin not installed" stderr noise. if (hasCmd('claude')) { const probe = captureSpawn('claude', ['plugin', 'list']); if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) { const r = runSpawn('claude', ['plugin', 'uninstall', 'caveman@caveman'], null, opts.dryRun); if (spawnOk(r)) ok(' removed claude plugin'); } else { note(' claude plugin not installed — skipping'); } // caveman-shrink MCP — only run if `claude mcp` subcommand exists. Tolerate // non-zero exit (server may have never been registered). const mcpHelp = captureSpawn('claude', ['mcp', '--help']); if (mcpHelp.status === 0) { runSpawn('claude', ['mcp', 'remove', 'caveman-shrink'], null, opts.dryRun); } } // Gemini extension. Same idempotency probe as claude. if (hasCmd('gemini')) { const probe = captureSpawn('gemini', ['extensions', 'list']); if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) { runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun); } else { note(' gemini extension not installed — skipping'); } } // opencode native install — strip plugin entry, MCP entry, and our files. // Probed by the existence of the plugin dir we own; if absent, skip silently. const ocDir = opencodeConfigDir(); const ocPluginDir = path.join(ocDir, 'plugins', 'caveman'); if (fs.existsSync(ocPluginDir)) { const ocJson = path.join(ocDir, 'opencode.json'); if (fs.existsSync(ocJson)) { const cfg = SETTINGS.readSettings(ocJson); if (cfg) { if (Array.isArray(cfg.plugin)) { cfg.plugin = cfg.plugin.filter(p => p !== OPENCODE_PLUGIN_REL); if (cfg.plugin.length === 0) delete cfg.plugin; } if (cfg.mcp && typeof cfg.mcp === 'object' && cfg.mcp['caveman-shrink']) { delete cfg.mcp['caveman-shrink']; if (Object.keys(cfg.mcp).length === 0) delete cfg.mcp; } if (!opts.dryRun) SETTINGS.writeSettings(ocJson, cfg); ok(` pruned caveman entries from ${ocJson}`); } } if (!opts.dryRun) { try { fs.rmSync(ocPluginDir, { recursive: true, force: true }); } catch (_) {} } note(` removed ${ocPluginDir}`); // Commands, agents, skills — only files matching our manifest (don't // sweep the parent dirs; user may have other entries there). for (const f of OPENCODE_COMMAND_FILES) { const p = path.join(ocDir, 'commands', f); if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } } for (const f of OPENCODE_AGENT_FILES) { const p = path.join(ocDir, 'agents', f); if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } } for (const name of OPENCODE_SKILL_DIRS) { const p = path.join(ocDir, 'skills', name); if (fs.existsSync(p) && !opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} } } // AGENTS.md — strip the fenced caveman block (preserves user content // above and below). If the file is empty after the strip, remove it. // Falls back to legacy unfenced-sentinel handling for installs that // pre-date the marker fence. const ocAgentsMd = path.join(ocDir, 'AGENTS.md'); if (fs.existsSync(ocAgentsMd)) { const body = fs.readFileSync(ocAgentsMd, 'utf8'); const begin = body.indexOf(OPENCODE_AGENTS_MD_BEGIN); const end = body.indexOf(OPENCODE_AGENTS_MD_END); if (begin !== -1 && end !== -1 && end > begin) { const before = body.slice(0, begin).replace(/\n+$/, '\n'); const after = body.slice(end + OPENCODE_AGENTS_MD_END.length).replace(/^\n+/, '\n'); let next = (before + after).trimEnd(); next = next ? next + '\n' : ''; if (!opts.dryRun) { if (next === '') { try { fs.unlinkSync(ocAgentsMd); } catch (_) {} } else { fs.writeFileSync(ocAgentsMd, next, { mode: 0o644 }); } } note(next === '' ? ` removed ${ocAgentsMd}` : ` stripped caveman block from ${ocAgentsMd}`); } else if (body.includes(OPENCODE_AGENTS_MD_SENTINEL)) { // Legacy install (no marker fence). Remove only if the file is ours. if (body.trim() === '' || body.trim().startsWith(OPENCODE_AGENTS_MD_SENTINEL)) { if (!opts.dryRun) { try { fs.unlinkSync(ocAgentsMd); } catch (_) {} } note(` removed ${ocAgentsMd}`); } else { note(` left ${ocAgentsMd} in place (legacy mixed content — strip caveman block manually)`); } } } // opencode flag file const ocFlag = path.join(ocDir, '.caveman-active'); if (fs.existsSync(ocFlag) && !opts.dryRun) { try { fs.unlinkSync(ocFlag); } catch (_) {} } } // OpenClaw native install — strip skill folder + SOUL.md marker block. // Probed by the skill folder we own; if absent, skip silently. const ocwWs = process.env.OPENCLAW_WORKSPACE || path.join(os.homedir(), '.openclaw', 'workspace'); if (fs.existsSync(path.join(ocwWs, 'skills', 'caveman')) || fs.existsSync(path.join(ocwWs, 'SOUL.md'))) { const log = { write: (s) => process.stdout.write(s), note: (s) => note(s), warn: (s) => warn(s), }; const r = OPENCLAW.uninstallOpenclaw({ workspace: ocwWs, dryRun: opts.dryRun, log }); if (r.touched) ok(' pruned caveman entries from OpenClaw workspace'); } // Hermes native install — remove the skill folders installHermes copied. // Honors HERMES_HOME via hermesConfigDir(); probed by the dirs we own. const hermesRoot = path.join(hermesConfigDir(), 'productivity'); if (fs.existsSync(hermesRoot)) { let prunedHermes = false; for (const name of HERMES_SKILL_DIRS) { const p = path.join(hermesRoot, name); if (fs.existsSync(p)) { if (!opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} } note(` removed ${p}`); prunedHermes = true; } } if (prunedHermes) ok(' pruned caveman skills from Hermes'); } // 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-init OFF, --with-mcp-shrink OFF.\n')); process.stdout.write(c.dim(' --all = hooks + init (mcp-shrink needs an upstream — opt in explicitly).\n')); process.stdout.write(c.dim(' --minimal turns hooks + init + mcp-shrink off.\n')); } function pad(s, n) { s = String(s); return s + ' '.repeat(Math.max(0, n - s.length)); } // ── Help ─────────────────────────────────────────────────────────────────── function printHelp() { process.stdout.write(`caveman installer — detects your agents and installs caveman for each one. USAGE npx -y github:JuliusBrussee/caveman -- [flags] node bin/install.js [flags] bash install.sh [flags] # shim → npx pwsh install.ps1 [flags] # shim → npx FLAGS --dry-run Print what would run, do nothing. --force Re-run even if a target reports already installed. --only Install only for the named agent. Repeatable. See --list for valid ids. --skip-skills Don't run the npx-skills auto-detect fallback. --all Turn on hooks + init. (mcp-shrink needs an upstream; pass --with-mcp-shrink="" to add it.) --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 (and opencode): register caveman-shrink MCP proxy wrapping the given upstream. Default OFF. caveman-shrink crashes without an upstream, so a value is required. The value is whitespace-tokenized. Example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp" --no-mcp-shrink Skip MCP shrink. (Default.) --uninstall, -u Remove caveman from this machine. --config-dir Claude Code config dir for hook files + settings.json. Default: \$CLAUDE_CONFIG_DIR or ~/.claude. Does NOT scope \`claude plugin install\`, \`gemini extensions install\`, opencode (XDG_CONFIG_HOME), or openclaw (OPENCLAW_WORKSPACE) — those use their own paths. --non-interactive Never prompt; use defaults. (Auto when stdin is not a TTY.) --list Print provider matrix and exit. --no-color Disable ANSI colors. -h, --help Show this help. EXAMPLES npx -y github:JuliusBrussee/caveman # default install npx -y github:JuliusBrussee/caveman -- --all # all the trimmings npx -y github:JuliusBrussee/caveman -- --only claude --no-mcp-shrink npx -y github:JuliusBrussee/caveman -- --uninstall Issues: https://github.com/${REPO}/issues `); } // ── Main ─────────────────────────────────────────────────────────────────── async function main() { const opts = parseArgs(process.argv.slice(2)); const c = makeChalk(opts.noColor); if (opts.help) { printHelp(); return 0; } if (opts.listOnly) { printList(opts.noColor); return 0; } checkWslWindowsNode(); checkNodeVersion(); const configDir = opts.configDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const repoRoot = detectRepoRoot(); const ctx = { opts, configDir, repoRoot, say: (s) => process.stdout.write(c.orange(s) + '\n'), note: (s) => process.stdout.write(c.dim(s) + '\n'), warn: (s) => process.stderr.write(c.red(s) + '\n'), ok: (s) => process.stdout.write(c.green(s) + '\n'), results: { installed: [], skipped: [], failed: [], detected: 0 }, }; if (opts.uninstall) { uninstall(ctx); return 0; } ctx.say('🪨 caveman installer'); ctx.note(` ${REPO}`); if (opts.dryRun) ctx.note(' (dry run — nothing will be written)'); process.stdout.write('\n'); // Detect everything once const detected = PROVIDERS.filter(p => detectMatch(p.detect)); // TTY-only multi-select prompt when no --only and no --non-interactive. if (opts.only.length === 0 && !opts.nonInteractive) { const picks = await promptForOnly(detected); if (picks) opts.only = picks; } const want = (id) => opts.only.length === 0 || opts.only.includes(id); const explicit = (id) => opts.only.includes(id); // Run installs in declared order. Soft providers (no reliable detect probe) // are auto-skipped — user must opt in via `--only `. Stops the installer // from firing `npx skills add ...` against agents the user never installed // just because some other tool created `~/.foo` along the way. for (const prov of PROVIDERS) { if (!want(prov.id)) continue; if (prov.soft && !explicit(prov.id)) continue; // Auto-detect mode: skip providers we can't see. With --only the user // is explicitly opting in, so trust them and let the per-provider installer // bail itself if its preconditions aren't met (e.g. opencode bails when // no repo clone is available; openclaw bails when the workspace dir is // missing without --force). if (!explicit(prov.id) && !detectMatch(prov.detect)) continue; if (prov.id === 'claude') { await installClaude(ctx); continue; } if (prov.id === 'gemini') { installGemini(ctx); continue; } if (prov.id === 'opencode') { installOpencode(ctx); continue; } if (prov.id === 'openclaw') { installOpenclaw(ctx); continue; } if (prov.id === 'hermes') { installHermes(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'); // --yes --all for the same reason as installViaSkills above (issue #370): // skip the interactive skill picker so curl|bash actually installs. const r = runSpawn('npx', ['-y', 'skills', 'add', REPO, '--yes', '--all'], null, opts.dryRun); if (spawnOk(r)) 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 (await runInit(ctx)) ctx.results.installed.push(`caveman-init (${process.cwd()})`); else ctx.results.failed.push(['caveman-init', 'src/tools/caveman-init.js failed']); process.stdout.write('\n'); } else if (ctx.results.installed.length || ctx.results.skipped.length) { ctx.note(' tip: re-run inside a repo with --all (or --with-init) to also write per-repo'); ctx.note(' Cursor/Windsurf/Cline/Copilot/AGENTS.md rule files.'); } // Summary process.stdout.write('\n'); ctx.say('🪨 done'); if (ctx.results.installed.length) { ctx.ok(' installed:'); for (const a of ctx.results.installed) process.stdout.write(` • ${a}\n`); } if (ctx.results.skipped.length) { process.stdout.write(' skipped:\n'); for (const [id, why] of ctx.results.skipped) process.stdout.write(` • ${id} — ${why}\n`); } if (ctx.results.failed.length) { ctx.warn(' failed:'); for (const [id, why] of ctx.results.failed) process.stderr.write(` • ${id} — ${why}\n`); } if (!ctx.results.installed.length && !ctx.results.skipped.length && !ctx.results.failed.length) { process.stdout.write(' nothing detected. run with --list to see all 30+ supported agents,\n'); process.stdout.write(' or pass --only to force a specific target.\n'); } process.stdout.write('\n'); ctx.note(" start any session and say 'caveman mode', or run /caveman in Claude Code"); ctx.note(' measure what caveman save you: run /caveman-stats (numbers are estimates)'); ctx.note(' verified team savings coming soon — join waitlist: https://caveman.so'); 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); });