mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-09-14 18:02:35 +08:00
b82c0ad42c
Bump the bootstrap pin lockstep (install.sh, install.ps1, bin/install.js, package.json, README, INSTALL) to the v2.6.0 release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2f7Lf7G9erA7YNn5SXPEj
1707 lines
80 KiB
JavaScript
Executable File
1707 lines
80 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 OWNED = require('./lib/owned-install');
|
|
const { transformOpencodeAgentFrontmatter } = require('./lib/opencode-agent');
|
|
const PORTABLE = require('./lib/portable-process');
|
|
const PLATFORM_PATHS = require('./lib/platform-paths');
|
|
|
|
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 || 'v2.6.0';
|
|
const OPENCLAW_SKILL_VERSION = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(PINNED_REF)
|
|
? PINNED_REF.replace(/^v/, '')
|
|
: undefined;
|
|
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-parse.js',
|
|
'caveman-activate.js',
|
|
'caveman-mode-tracker.js',
|
|
'caveman-stats.js',
|
|
'caveman-statusline.sh',
|
|
'caveman-statusline.ps1',
|
|
'cavecrew-model-overrides.js',
|
|
];
|
|
|
|
// hooks/package.json is the ONE entry in HOOK_FILES with no `caveman` in its
|
|
// name, because it is not ours: it pins the module system for EVERY plugin's
|
|
// .js hooks in that directory. We used to copy it in unconditionally and delete
|
|
// it outright on uninstall, so a co-installed plugin shipping ESM hooks had its
|
|
// {"type":"module"} silently replaced with {"type":"commonjs"} — and then removed
|
|
// altogether. Install leaves a foreign manifest alone; uninstall deletes only a
|
|
// manifest whose content is exactly the one we ship.
|
|
const HOOKS_MANIFEST = 'package.json';
|
|
function hooksManifestIsOurs(p) {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
return !!parsed && parsed.type === 'commonjs' && Object.keys(parsed).length === 1;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── 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', globalSkillsDir: ['.cursor', 'skills'] },
|
|
{ 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') {
|
|
return PORTABLE.resolveWindowsCommand(cmd, process.env) !== null;
|
|
}
|
|
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 PLATFORM_PATHS.jetbrainsRoots(home).some(root => fs.existsSync(root));
|
|
}
|
|
|
|
function jetbrainsPluginPresent(needle) {
|
|
const home = os.homedir();
|
|
const roots = PLATFORM_PATHS.jetbrainsRoots(home);
|
|
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 ship as `.cmd` Node shims. Resolve
|
|
// through PATH/PATHEXT and launch their Node entrypoint directly. This keeps
|
|
// argument bytes intact without putting user-controlled paths into cmd.exe.
|
|
const IS_WIN = process.platform === 'win32';
|
|
|
|
function spawnXplat(cmd, args, opts) {
|
|
if (IS_WIN) {
|
|
try {
|
|
const invocation = PORTABLE.portableInvocation(cmd, args, {
|
|
env: (opts && opts.env) || process.env,
|
|
});
|
|
return child_process.spawnSync(invocation.command, invocation.args, opts || {});
|
|
} catch (error) {
|
|
return { status: null, signal: null, stdout: '', stderr: '', error };
|
|
}
|
|
}
|
|
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, note, warn, 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'];
|
|
// Without -g the upstream CLI writes to a PROJECT-local ./.agents/skills
|
|
// under whatever directory the installer happened to run from. For an agent
|
|
// whose skills UI reads a fixed home directory, that means the install
|
|
// reports success and the skills never appear (#836) — a `curl | bash` run
|
|
// from ~/.local/bin put them in ~/.local/bin/.agents/skills. Set
|
|
// globalSkillsDir on a provider whose skills live at a known home path.
|
|
if (prov.globalSkillsDir) {
|
|
const globalSkillsDir = path.join(os.homedir(), ...prov.globalSkillsDir);
|
|
if (opts.dryRun) {
|
|
note(` would mkdir -p ${globalSkillsDir}`);
|
|
} else {
|
|
// Belt and braces: -g should create the target itself. A failure here is
|
|
// not fatal — let the CLI run and report the real error rather than
|
|
// aborting on a directory we may not have needed.
|
|
try {
|
|
fs.mkdirSync(globalSkillsDir, { recursive: true });
|
|
} catch (error) {
|
|
warn(` could not pre-create ${globalSkillsDir}: ${error.message}`);
|
|
}
|
|
}
|
|
args.push('-g');
|
|
}
|
|
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 {
|
|
const operations = [];
|
|
for (const skillDir of HERMES_SKILL_DIRS) {
|
|
const srcDir = path.join(repoRoot, 'skills', skillDir);
|
|
if (!fs.existsSync(srcDir)) {
|
|
warn(` skill dir not found: ${srcDir}`);
|
|
continue;
|
|
}
|
|
operations.push({
|
|
relativePath: skillDir,
|
|
write: (stage) => OWNED.copyPath(srcDir, stage),
|
|
});
|
|
}
|
|
OWNED.installOwned({
|
|
root: skillsRoot,
|
|
integration: 'hermes',
|
|
operations,
|
|
force: opts.force,
|
|
note,
|
|
});
|
|
|
|
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 countOccurrences(haystack, needle) {
|
|
let n = 0;
|
|
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
|
|
return n;
|
|
}
|
|
|
|
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 opencodeConfigPath(dir) {
|
|
const jsonc = path.join(dir, 'opencode.jsonc');
|
|
const json = path.join(dir, 'opencode.json');
|
|
if (fs.existsSync(jsonc)) return jsonc;
|
|
if (fs.existsSync(json)) return json;
|
|
return jsonc;
|
|
}
|
|
|
|
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 = opencodeConfigPath(dir);
|
|
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 + caveman-parse.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 {
|
|
const pluginSrc = path.join(repoRoot, 'src', 'plugins', 'opencode');
|
|
// Preflight every same-named path before writing anything. The ownership
|
|
// journal records installed digests and force backups, so uninstall can
|
|
// remove only bytes this installer still owns.
|
|
const operations = [{
|
|
relativePath: 'plugins/caveman',
|
|
write: (stage) => {
|
|
fs.mkdirSync(stage, { recursive: true });
|
|
fs.copyFileSync(path.join(pluginSrc, 'plugin.js'), path.join(stage, 'plugin.js'));
|
|
fs.copyFileSync(path.join(pluginSrc, 'package.json'), path.join(stage, 'package.json'));
|
|
// Plugin dir is ESM; the CommonJS config bridge needs .cjs.
|
|
fs.copyFileSync(path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'), path.join(stage, 'caveman-config.cjs'));
|
|
// Shared mode parser keeps opencode and Claude hook behavior identical.
|
|
fs.copyFileSync(path.join(repoRoot, 'src', 'hooks', 'caveman-parse.js'), path.join(stage, 'caveman-parse.cjs'));
|
|
},
|
|
}];
|
|
|
|
const cmdSrcDir = path.join(pluginSrc, 'commands');
|
|
for (const f of OPENCODE_COMMAND_FILES) {
|
|
const src = path.join(cmdSrcDir, f);
|
|
if (!fs.existsSync(src)) continue; // defense-in-depth: skip a missing command file rather than crash (#434)
|
|
operations.push({
|
|
relativePath: `commands/${f}`,
|
|
write: (stage) => fs.copyFileSync(src, stage),
|
|
});
|
|
}
|
|
|
|
// Subagents target Claude Code's `tools: [...]` schema; strip that line for
|
|
// opencode and materialize transformed bytes directly into the owned path.
|
|
// 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.
|
|
const agentSrcDir = path.join(repoRoot, 'agents');
|
|
for (const f of OPENCODE_AGENT_FILES) {
|
|
const src = path.join(agentSrcDir, f);
|
|
if (!fs.existsSync(src)) continue;
|
|
const body = transformOpencodeAgentFrontmatter(fs.readFileSync(src, 'utf8'));
|
|
operations.push({
|
|
relativePath: `agents/${f}`,
|
|
write: (stage) => fs.writeFileSync(stage, body, { mode: 0o600, flag: 'wx' }),
|
|
});
|
|
}
|
|
|
|
// Skills are journaled as whole directories. Any unjournaled same-named
|
|
// directory is user content and blocks install unless --force backs it up.
|
|
const skillSrcDir = path.join(repoRoot, 'skills');
|
|
for (const name of OPENCODE_SKILL_DIRS) {
|
|
const src = path.join(skillSrcDir, name);
|
|
if (!fs.existsSync(src)) continue;
|
|
operations.push({
|
|
relativePath: `skills/${name}`,
|
|
write: (stage) => OWNED.copyPath(src, stage),
|
|
});
|
|
}
|
|
OWNED.installOwned({
|
|
root: dir,
|
|
integration: 'opencode',
|
|
operations,
|
|
force: opts.force,
|
|
note,
|
|
});
|
|
process.stdout.write(` installed owned opencode payload under: ${dir}\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');
|
|
// Both markers present is not enough: they must be exactly one matched
|
|
// pair, in order. An END above a BEGIN (or an orphan BEGIN) made the
|
|
// slice arithmetic below run on end === -1, which re-appended the whole
|
|
// file from byte 19 and compounded on every re-run.
|
|
const begin = existing.indexOf(OPENCODE_AGENTS_MD_BEGIN);
|
|
const end = begin === -1 ? -1 : existing.indexOf(OPENCODE_AGENTS_MD_END, begin);
|
|
const alreadyFenced = begin !== -1 && end > begin
|
|
&& countOccurrences(existing, OPENCODE_AGENTS_MD_BEGIN) === 1
|
|
&& countOccurrences(existing, OPENCODE_AGENTS_MD_END) === 1;
|
|
const damagedFence = !alreadyFenced
|
|
&& (existing.includes(OPENCODE_AGENTS_MD_BEGIN) || existing.includes(OPENCODE_AGENTS_MD_END));
|
|
const alreadyByLegacySentinel = !alreadyFenced && !damagedFence
|
|
&& existing.includes(OPENCODE_AGENTS_MD_SENTINEL);
|
|
if (damagedFence) {
|
|
note(` ${agentsMd} has unmatched caveman markers — leaving it untouched`);
|
|
note(' remove the stray <!-- caveman-begin/end --> marker, then re-run');
|
|
} else if (alreadyFenced) {
|
|
// Refresh in place when the shipped ruleset has changed. The old
|
|
// presence-only check meant a ruleset edit never reached anyone who
|
|
// had already installed — the block went stale forever. Only the
|
|
// bytes between our own markers are touched; user content around
|
|
// them is preserved exactly.
|
|
const currentBlock = existing.slice(begin, end + OPENCODE_AGENTS_MD_END.length + 1);
|
|
if (currentBlock === fencedBlock) {
|
|
note(` ${agentsMd} already contains the current caveman ruleset`);
|
|
} else {
|
|
const next = existing.slice(0, begin) + fencedBlock
|
|
+ existing.slice(end + OPENCODE_AGENTS_MD_END.length).replace(/^\n/, '');
|
|
fs.writeFileSync(agentsMd, next, { mode: 0o644 });
|
|
process.stdout.write(` refreshed caveman ruleset in ${agentsMd}\n`);
|
|
}
|
|
} 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.
|
|
const ocMeta = {};
|
|
let cfg = SETTINGS.readSettings(opencodeJson, ocMeta);
|
|
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 (_) {}
|
|
}
|
|
// opencode.jsonc legitimately carries comments; we re-serialize plain JSON.
|
|
if (ocMeta.jsonc) {
|
|
warn(` note: ${opencodeJson} contains comments — rewriting it drops them.`);
|
|
warn(` Your original (with comments) is preserved at ${opencodeBak}`);
|
|
}
|
|
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,
|
|
version: OPENCLAW_SKILL_VERSION,
|
|
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 (f === HOOKS_MANIFEST && fs.existsSync(dest) && !hooksManifestIsOurs(dest)) {
|
|
warn(` ${dest} belongs to another plugin — left untouched.`);
|
|
warn(" caveman's hooks are CommonJS; if that file declares \"type\":\"module\" they will not load.");
|
|
continue;
|
|
}
|
|
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
|
|
const settingsMeta = {};
|
|
let settings = SETTINGS.readSettings(settingsPath, settingsMeta);
|
|
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 (_) {}
|
|
}
|
|
// We re-serialize plain JSON, so // and /* */ comments do not survive. Say
|
|
// so rather than deleting a user's annotations silently.
|
|
if (settingsMeta.jsonc) {
|
|
warn(` note: ${settingsPath} contains comments — rewriting it drops them.`);
|
|
warn(` Your original (with comments) is preserved at ${bak}`);
|
|
}
|
|
|
|
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: PLATFORM_PATHS.hookCommand(node, [activate]),
|
|
marker: 'caveman-activate',
|
|
timeout: 30,
|
|
statusMessage: 'Loading caveman mode...',
|
|
});
|
|
|
|
SETTINGS.addCommandHook(settings, 'UserPromptSubmit', {
|
|
command: PLATFORM_PATHS.hookCommand(node, [tracker]),
|
|
marker: 'caveman-mode-tracker',
|
|
timeout: 30,
|
|
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
|
|
? PLATFORM_PATHS.hookCommand(psHost, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(hooksDir, 'caveman-statusline.ps1')])
|
|
: PLATFORM_PATHS.hookCommand('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;
|
|
}
|
|
const scratch = privateTmpDir();
|
|
try {
|
|
const tmp = path.join(scratch, 'caveman-init.js');
|
|
await downloadTo(INIT_SCRIPT_URL, tmp);
|
|
const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' });
|
|
return spawnOk(r);
|
|
} catch (e) {
|
|
warn(' ' + e.message);
|
|
return false;
|
|
} finally {
|
|
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
// privateTmpDir returns a fresh 0700 directory with an unguessable name. The old
|
|
// predictable paths (`caveman-init-<pid>.js`, `caveman-checksums-<pid>-<ms>`)
|
|
// could be pre-planted as symlinks by any local user, and `curl -o` follows a
|
|
// symlink — so the installer wrote through it and, for the init script, then
|
|
// EXECUTED what landed there.
|
|
function privateTmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-'));
|
|
}
|
|
|
|
// ── 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 scratch = privateTmpDir();
|
|
const tmp = path.join(scratch, 'checksums.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.rmSync(scratch, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
// ── Uninstall ─────────────────────────────────────────────────────────────
|
|
function uninstall(ctx) {
|
|
const { say, note, warn, ok, opts, configDir } = ctx;
|
|
let cleanupFailed = false;
|
|
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');
|
|
// settingsClean gates the file deletion below. Deleting the hook scripts while
|
|
// settings.json still points at them is issue #471's exact shape: Claude Code
|
|
// dies with `Cannot find module …caveman-activate.js` on EVERY session start
|
|
// afterwards. So a settings.json we could not read (malformed) or could not
|
|
// write is a hard stop for the deletion, not a warning to continue past.
|
|
let settingsClean = true;
|
|
if (fs.existsSync(settingsPath)) {
|
|
const settings = SETTINGS.readSettings(settingsPath);
|
|
if (!settings) {
|
|
settingsClean = false;
|
|
cleanupFailed = true;
|
|
warn(` could not parse ${settingsPath} — leaving the hook files in place.`);
|
|
warn(' Remove the caveman entries from it by hand, then re-run --uninstall.');
|
|
}
|
|
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);
|
|
try {
|
|
if (!opts.dryRun) SETTINGS.writeSettings(settingsPath, settings);
|
|
ok(` removed ${removed} caveman hook entr${removed === 1 ? 'y' : 'ies'} from settings.json`);
|
|
} catch (e) {
|
|
settingsClean = false;
|
|
cleanupFailed = true;
|
|
warn(` could not update ${settingsPath}: ${e && e.message || e}`);
|
|
warn(' leaving the hook files in place so the entries it still holds keep resolving.');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (settingsClean && fs.existsSync(hooksDir)) {
|
|
for (const f of HOOK_FILES) {
|
|
const p = path.join(hooksDir, f);
|
|
if (!fs.existsSync(p)) continue;
|
|
if (f === HOOKS_MANIFEST && !hooksManifestIsOurs(p)) {
|
|
note(` left ${p} (another plugin's)`);
|
|
continue;
|
|
}
|
|
if (opts.dryRun) {
|
|
note(` would remove ${p}`);
|
|
} else {
|
|
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 — ownership journal is authority. Never infer
|
|
// ownership from a matching path name; pre-existing user files may use it.
|
|
const ocDir = opencodeConfigDir();
|
|
let ocOwnership = { hadJournal: false };
|
|
try {
|
|
ocOwnership = OWNED.uninstallOwned({
|
|
root: ocDir,
|
|
integration: 'opencode',
|
|
dryRun: opts.dryRun,
|
|
note,
|
|
warn,
|
|
});
|
|
} catch (error) {
|
|
warn(` opencode ownership journal invalid; left integration untouched: ${error.message}`);
|
|
}
|
|
if (ocOwnership.hadJournal) {
|
|
const ocJson = opencodeConfigPath(ocDir);
|
|
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}`);
|
|
}
|
|
}
|
|
// 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),
|
|
};
|
|
try {
|
|
const r = OPENCLAW.uninstallOpenclaw({ workspace: ocwWs, dryRun: opts.dryRun, log });
|
|
if (r.touched) ok(' pruned caveman entries from OpenClaw workspace');
|
|
} catch (error) {
|
|
cleanupFailed = true;
|
|
warn(` OpenClaw cleanup failed; continuing other cleanup: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Hermes native install — same journal/digest contract as opencode.
|
|
const hermesRoot = path.join(hermesConfigDir(), 'productivity');
|
|
try {
|
|
const hermesOwnership = OWNED.uninstallOwned({
|
|
root: hermesRoot,
|
|
integration: 'hermes',
|
|
dryRun: opts.dryRun,
|
|
note,
|
|
warn,
|
|
});
|
|
if (hermesOwnership.hadJournal) ok(' pruned owned caveman skills from Hermes');
|
|
} catch (error) {
|
|
warn(` Hermes ownership journal invalid; left integration untouched: ${error.message}`);
|
|
}
|
|
|
|
// Per-session state. Keep lifetime savings history unless user removes it.
|
|
const stateFiles = [
|
|
'.caveman-active',
|
|
'.caveman-active.prev',
|
|
'.caveman-mode-log.jsonl',
|
|
'.caveman-statusline-suffix',
|
|
'.caveman-nudge-shown',
|
|
];
|
|
for (const file of stateFiles) {
|
|
const statePath = path.join(configDir, file);
|
|
if (!fs.existsSync(statePath)) continue;
|
|
if (opts.dryRun) {
|
|
note(` would remove ${statePath}`);
|
|
} else {
|
|
try { fs.unlinkSync(statePath); } catch (_) {}
|
|
note(` removed ${statePath}`);
|
|
}
|
|
}
|
|
// Per-session mode files (one <session_id>.mode / .prev per window). Keep in
|
|
// sync with the uninstall blocks in src/hooks/uninstall.sh and uninstall.ps1.
|
|
const sessionsDir = path.join(configDir, '.caveman-sessions');
|
|
if (fs.existsSync(sessionsDir)) {
|
|
if (opts.dryRun) {
|
|
note(` would remove ${sessionsDir}`);
|
|
} else {
|
|
try {
|
|
const sessionsState = fs.lstatSync(sessionsDir);
|
|
if (sessionsState.isDirectory() && !sessionsState.isSymbolicLink()) {
|
|
fs.rmSync(sessionsDir, { recursive: true, force: true });
|
|
} else {
|
|
// Never recurse through a path swapped for a link or special file.
|
|
fs.unlinkSync(sessionsDir);
|
|
}
|
|
note(` removed ${sessionsDir}`);
|
|
} catch (error) {
|
|
cleanupFailed = true;
|
|
warn(` could not remove ${sessionsDir}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
const historyPath = path.join(configDir, '.caveman-history.jsonl');
|
|
if (fs.existsSync(historyPath)) {
|
|
note(` kept ${historyPath} (lifetime history — delete manually if unwanted)`);
|
|
}
|
|
|
|
process.stdout.write('\n');
|
|
if (cleanupFailed) {
|
|
warn('uninstall incomplete. Review cleanup warnings above.');
|
|
} else {
|
|
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');
|
|
return cleanupFailed ? 1 : 0;
|
|
}
|
|
|
|
// ── 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) return uninstall(ctx);
|
|
|
|
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); });
|