1531 lines
73 KiB
JavaScript
Executable File
1531 lines
73 KiB
JavaScript
Executable File
#!/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=<upstream cmd> (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="<upstream cmd>".
|
|
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>` — 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: '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 <repo>/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 <agent>` 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 = '<!-- caveman-begin -->';
|
|
const OPENCODE_AGENTS_MD_END = '<!-- caveman-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 || '<not in manifest>'}, 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 <upstream tokens...>` 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<basename, sha256hex>, 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> <path>" (two spaces, or
|
|
// " *<path>" 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 <agent> 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="<cmd>" 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="<upstream cmd>"
|
|
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 <path> 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 <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;
|
|
// Auto-detect mode: skip providers we can't see. With --only <id> 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 <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(' 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); });
|