mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-09-14 18:02:35 +08:00
c63362d1c8
* test(hooks): add reproduction corpus and table test for mode activation (#187) * fix(hooks): stop ordinary prose and negated phrases activating caveman The natural-language activation matcher allowed any 40 characters between the trigger verb and "caveman" (`[^.]{0,40}`), so prose that merely mentioned caveman downstream of a common verb switched the mode on: "i want to start building a caveman-themed game" is `want` … `caveman`. It also had no negation guard at all, so "don't activate caveman" was read as "activate caveman". Replace the length budget with a whitelist of the determiners and particles that real activation phrases actually put in that gap ("turn on THE caveman mode", "switch TO caveman"), and reject a match preceded by a negator. "a"/"an" are deliberately excluded from the whitelist: no genuine phrase needs one, and admitting them is what makes "start a caveman fire" read as a command. The negation guard is activation-only on purpose. The deactivation side keeps firing on "don't stop caveman" per the committed behavior in #838 — a dropped deactivation is silent and leaves the user unable to escape the mode, while a spurious activation costs one "/caveman" to undo. Fixed once in the shared parser, so it lands on every host that consumes it: the Claude Code UserPromptSubmit hook, the Codex hook that runs the same file, and the opencode plugin, which loads caveman-parse.js through `new Function`. Gemini and OpenClaw ship a context file with no natural-language parser and are unaffected; the statusline scripts do no parsing. Adopts the reproduction corpus from #992 (cases 16 and 17 flipped from known-failing to enforced) and adds unit coverage with positive controls for every activation phrasing the README promises. Co-authored-by: Poorvith M P <poorvith007@proton.me> Supersedes #992 Fixes #187 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mmc6i5j2bksz5f2dM5bfqy * fix(shrink): require no space before "(" in function-call protection (#999) PROTECTED_PATTERNS' function-call entry allowed whitespace between the identifier and the paren, so "useful (please read carefully)" matched it the same as "compress(text)". The whole span got sentinel-protected before compressProse ran, so pleasantries/fillers/hedges inside an ordinary parenthetical never got stripped. Real function-call syntax never has a space before "(", so dropping \s* from the pattern fixes the false match without touching the real one. Added a regression test asserting a pleasantry and a filler each get stripped out of a parenthetical, plus that "compress(text, opts)" still protects. Fixes #999 Signed-off-by: Amir Fathi <amirfathi.me@gmail.com> * fix(compress): compare code blocks by document position, not extraction type extract_code_blocks() concatenated all fenced blocks in document order, then appended all indented blocks in document order. That preserves within-type order but discards cross-type order, so a compression pass that swapped a fenced and an indented block's relative position, content unchanged, produced identical lists for validate_code_blocks' c1 != c2 comparison and passed validation. Both extractors now record each block's starting line, and the merged list is ordered by that position instead of by extraction type. The extraction boundaries themselves are unchanged. Fixes #997 Signed-off-by: Amir Fathi <amirfathi.me@gmail.com> * fix(redact): recognize ghs_ GitHub App installation tokens Add a ghs_ branch to the four independent secret-redaction sites (the log-line regex and its needle-prescreen gate in shared/platform/redact, the safe-term prefix list in proxy/internal/repointel, and the capture-sink key pattern in packages/subagent-tax). GitHub is migrating server-to-server installation tokens to a much longer stateless JWT, so an unredacted ghs_ token is far more likely to reach a log line than the short opaque one was. The character class admits '.', '-', '_' so it matches the JWT's dotted segments, not just the prefix. Signed-off-by: Amir Fathi <amirfathi.me@gmail.com> * docs(redact): list ghs_ in the covered-prefix comment The package doc enumerates the vendor prefixes credentialPrefixRe covers. Adding the ghs_ branch without it left the comment claiming narrower coverage than the code has. Fixes #1002 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mmc6i5j2bksz5f2dM5bfqy * fix(install): gemini extension install must not wait for input (#400) A `curl | bash` install gives the child process a non-TTY stdin. Gemini CLI asks a workspace-trust question on that stdin. Gemini CLI also asks an extension-consent question on that stdin. Gemini CLI reads no answer, and the install waits without an end. The correction has two parts. The `--consent` flag answers the extension-install warning. The GEMINI_CLI_TRUST_WORKSPACE variable trusts the directory for this process. The trust prompt then does not run, and trustedFolders.json stays unchanged. Do not use the `--skip-trust` flag. The root chat command owns that flag. After the subcommand, Gemini CLI rejects it as an unknown argument. The flag only sets the same environment variable. Do not use the `--consent` flag alone. The trust prompt uses the same callback as the consent prompt. Its yes-branch trusts the current directory permanently. The installer runs the command in a new empty directory below ~/.caveman/tmp. A trusted directory lets Gemini CLI load .gemini/ config and .env files. Gemini CLI also searches every parent directory for those files. Below the user home, every parent belongs to the user or to root. A directory below /tmp is not safe, because any local user can plant /tmp/.env. A directory below ~/.gemini/tmp is not safe, because Gemini CLI keeps project state there. The directory comes from mkdtemp, and the installer removes only that directory. If the installer cannot create the directory, it reports a failure and does not start Gemini CLI. The installer passes the two parts always. A Gemini CLI before v0.7.0 rejects `--consent` with exit code 1. The installer then reports a failure and does not wait. Commited1fbb7added `--consent` for #676 on 2026-07-21. Commit82864c8moved the installer back to bin/ and dropped the flag. This change restores the flag and adds the trust variable. New tests use a fake `gemini` on PATH. They record the variable, the cwd, and the arguments for seven cases. Fixes #400 Fixes #676 Refs #460 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Zsolt Gaspar <17578237+zsltg@users.noreply.github.com> * fix(install): probe gemini for --skip-trust before session trust (#400) The first change passed --consent and GEMINI_CLI_TRUST_WORKSPACE always. Gemini CLI before v0.11.0 rejects --consent with exit code 1. Gemini CLI before v0.41.0 ignores GEMINI_CLI_TRUST_WORKSPACE. On those versions --consent answers the trust prompt with yes. The yes-answer trusts the current directory permanently. Gemini CLI v0.10.0 to v0.12.0 also rejects an untrusted directory. So the new scratch directory failed where the old command worked. Upstream PR #25814 adds --skip-trust and the variable in one commit. The first tag with that commit is v0.41.0. The installer now runs `gemini --help` one time. If the text holds --skip-trust, this CLI reads the variable. The installer then keeps the variable, the scratch directory, and --consent. Without the flag, or after a failed probe, the installer runs the old command. The old command uses the caller directory, no variable, and no --consent. The installer prints one note line in each of those two cases. The CLI can then ask its own questions, as before the first change. The --consent flag (v0.11.0) is older than --skip-trust (v0.41.0). So a CLI with --skip-trust also has --consent. The code comment states this assumption about the upstream release order. The fake gemini now answers a bare --help. Five new tests cover the legacy install, the legacy dry run, a failed probe, help text on standard error, and an inherited variable. The test helper removes GEMINI_CLI_TRUST_WORKSPACE from the child environment. Without that removal, an inherited value looks like an installer-added value. Real Gemini CLI 0.58.0 in a throwaway HOME with stdin from /dev/null: the install ended with exit 0, no prompt, no trustedFolders.json, and no scratch directory left. Refs #400 Refs #676 Refs #993 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Zsolt Gaspar <17578237+zsltg@users.noreply.github.com> * fix(installer): preserve stable Node path * test(installer): exercise Node upgrade path Signed-off-by: CaoBochun <caobochun@bytedance.com> * fix(installer): only prefer the PATH node once it actually runs Preferring the PATH entry over process.execPath is what survives a `brew upgrade node` (#805): Homebrew runs the installer as the versioned Cellar binary, which stops existing on the next upgrade, while /opt/homebrew/bin/node is the stable symlink that follows it. But the candidate was located and never exercised. `command -v node` names a stale version-manager shim, a dangling symlink or a shell function just as readily as a real binary, and baking one of those into settings.json breaks every session — strictly worse than the drift it replaces, because process.execPath is by construction a working node: it is the one executing that line. Require the candidate to be an absolute path that reports a version before preferring it, and fall back otherwise. The result is still deliberately not symlink-resolved: the stable symlink IS the wanted answer, and realpath would walk it straight back to the Cellar path this exists to avoid. Adds a regression test that a present-but-failing PATH node loses to the running one; it fails against the unguarded lookup. Co-authored-by: CaoBochun <caobochun@bytedance.com> Supersedes #809 Fixes #805 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mmc6i5j2bksz5f2dM5bfqy * fix(hooks,installer): scope negation to the clause, floor the PATH node version Two findings from review on #1005, both reproduced first. Negated activation missed complement clauses. The guard required the negator within a fixed word window of the activation verb, but the negator does not reliably sit next to the verb it negates: in "don't want you to use caveman" it attaches to `want`, while the trigger that matches is the `use` in the complement clause. "don't ask me to enable caveman" failed the same way. Widening the window far enough to catch these starts swallowing unrelated text, so scope the guard to the clause instead — a trigger is negated when a negator sits between the last clause boundary and the trigger. That also keeps a later independent command alive: in "don't use vim, activate caveman" the comma ends the negated clause and the activation still stands. Dashes and colons count as boundaries alongside commas, so "never mind the linter — turn on caveman" activates. The check now runs over every activation phrasing rather than only the verb form, since a guard on one shape let "don't put caveman mode on" through, and it examines every match rather than the first, so a negated match cannot mask a later un-negated one. The installer persisted any PATH node that merely ran. A working interpreter below the package's `engines.node` floor would be written into settings.json and then run every hook on an unsupported runtime. Require the major version to meet the floor; process.execPath satisfies it by construction, being the one running the installer. MIN_NODE_MAJOR is hardcoded because bin/install.js also runs detached from a checkout, so a new test fails the build if it drifts from package.json. Both fixes verified against their own tests: each fails with the guard removed and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mmc6i5j2bksz5f2dM5bfqy * fix(installer): keep init on current Node Signed-off-by: CaoBochun <caobochun@bytedance.com> --------- Signed-off-by: Amir Fathi <amirfathi.me@gmail.com> Signed-off-by: Zsolt Gaspar <17578237+zsltg@users.noreply.github.com> Signed-off-by: CaoBochun <caobochun@bytedance.com> Co-authored-by: Poorvith M P <poorvith007@proton.me> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Amir Fathi <amirfathi.me@gmail.com> Co-authored-by: Zsolt Gaspar <17578237+zsltg@users.noreply.github.com> Co-authored-by: CaoBochun <caobochun@bytedance.com>
1808 lines
86 KiB
JavaScript
Executable File
1808 lines
86 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';
|
|
// Mirrors the `engines.node` floor in package.json. Hardcoded rather than read
|
|
// from disk because this file also runs detached from a checkout (the curl
|
|
// fallback path); `tests/installer/node-floor.test.mjs` fails the build if the
|
|
// two drift apart.
|
|
const MIN_NODE_MAJOR = 18;
|
|
// 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;
|
|
}
|
|
|
|
// The absolute node path baked into settings.json at install time. Preferring
|
|
// the PATH entry over process.execPath is what survives a `brew upgrade node`
|
|
// (#805): Homebrew runs the installer as the versioned Cellar binary
|
|
// (/opt/homebrew/Cellar/node/26.5.0/bin/node), which stops existing on the next
|
|
// upgrade, while /opt/homebrew/bin/node is the stable symlink that follows it.
|
|
//
|
|
// The candidate has to be EXERCISED, not just located. `command -v node` will
|
|
// happily name a stale version-manager shim, a dangling symlink, or a shell
|
|
// function, and a path that is merely on PATH but does not run is strictly
|
|
// worse than what it replaces — process.execPath is by construction a working
|
|
// node, since it is the one executing this line. So a candidate is only
|
|
// preferred once it has actually reported a version; anything else falls back.
|
|
// Same reason the result is not symlink-resolved: the stable symlink IS the
|
|
// wanted answer, and realpath would walk it straight back to the Cellar path.
|
|
function absoluteNodePath() {
|
|
if (!IS_WIN) {
|
|
try {
|
|
const r = child_process.spawnSync('/bin/sh', ['-c', 'command -v node'], { encoding: 'utf8' });
|
|
const candidate = (r.stdout || '').trim().split(/\r?\n/, 1)[0];
|
|
if (r.status === 0 && candidate && path.isAbsolute(candidate)) {
|
|
const resolved = path.resolve(candidate);
|
|
const probe = child_process.spawnSync(resolved, ['--version'], { encoding: 'utf8' });
|
|
const major = /^v(\d+)\./.exec((probe.stdout || '').trim());
|
|
// Running is necessary but not sufficient: a PATH node below the
|
|
// package's `engines` floor would be persisted into settings.json and
|
|
// run every hook on an unsupported runtime. process.execPath already
|
|
// satisfies the floor, since it is running this installer.
|
|
if (spawnOk(probe) && major && Number(major[1]) >= MIN_NODE_MAJOR) return resolved;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
// Under `curl | bash`, stdin is the script, not a terminal. Gemini CLI reads
|
|
// its workspace-trust and extension-consent answers from stdin, so the
|
|
// install never ends. See issues #400 and #676. Two parts remove the two
|
|
// prompts.
|
|
// GEMINI_CLI_TRUST_WORKSPACE=true trusts cwd for this process only. It is
|
|
// what `--skip-trust` sets. That flag belongs to the root chat command
|
|
// and never reaches the `extensions` subcommand.
|
|
// --consent answers the extension-install warning. Do not pass it without
|
|
// the variable. Alone, it also answers the trust prompt with yes, and
|
|
// that writes cwd into trustedFolders.json permanently.
|
|
// Gemini CLI v0.41.0 adds `--skip-trust` and GEMINI_CLI_TRUST_WORKSPACE in
|
|
// one commit, upstream PR #25814. An older CLI ignores the variable. So the
|
|
// installer probes the root help one time. `--skip-trust` in the text shows
|
|
// that this CLI reads the variable. Without the flag, the installer runs the
|
|
// same command as before this change: the caller directory, no variable, and
|
|
// no `--consent`. On an older CLI, `--consent` answers the trust prompt with
|
|
// yes, and that trusts the caller directory permanently.
|
|
// Gemini CLI v0.11.0 adds `--consent`, which is older than `--skip-trust`.
|
|
// So a CLI with `--skip-trust` also has `--consent`. This is an assumption
|
|
// about the upstream release order.
|
|
const help = captureSpawn('gemini', ['--help']);
|
|
const sessionTrust = help.status === 0
|
|
&& /--skip-trust\b/.test(`${help.stdout || ''}${help.stderr || ''}`);
|
|
const url = `https://github.com/${REPO}`;
|
|
let r;
|
|
if (!sessionTrust) {
|
|
const tail = "Installing from the current directory with the CLI's own trust and consent prompts.";
|
|
if (help.status === 0) {
|
|
note(` this Gemini CLI has no --skip-trust (added in v0.41.0). ${tail}`);
|
|
} else {
|
|
const code = help.error ? (help.error.code || 'spawn error')
|
|
: (help.status === null ? 'spawn error' : help.status);
|
|
note(` could not read \`gemini --help\` (exit ${code}). ${tail}`);
|
|
}
|
|
r = runSpawn('gemini', ['extensions', 'install', url], null, opts.dryRun);
|
|
} else {
|
|
// A trusted cwd lets Gemini CLI load .gemini/ config and .env files. Gemini
|
|
// CLI also searches every parent directory up to the root for those files.
|
|
// So run the install from a new empty scratch directory below the user's
|
|
// own ~/.caveman/tmp. Every parent then belongs to the user or to root, and
|
|
// trust covers nothing. A directory below /tmp is not safe, because another
|
|
// local user can plant /tmp/.env. A directory below ~/.gemini/tmp is not
|
|
// safe, because Gemini CLI keeps per-project state there under the cwd
|
|
// name. The scratch directory comes from mkdtemp, so this run owns it and
|
|
// removes only it. Never remove a fixed path, because it can hold data from
|
|
// an earlier process. If the directory cannot be made, report a failure and
|
|
// do not start Gemini CLI. A GitHub source does not use cwd.
|
|
const env = Object.assign({}, process.env, { GEMINI_CLI_TRUST_WORKSPACE: 'true' });
|
|
const scratchParent = path.join(os.homedir(), '.caveman', 'tmp');
|
|
note(` env: GEMINI_CLI_TRUST_WORKSPACE=true. Trust applies to this process only, in a new empty scratch directory below ${scratchParent}. trustedFolders.json stays unchanged.`);
|
|
let cwd;
|
|
if (!opts.dryRun) {
|
|
try {
|
|
fs.mkdirSync(scratchParent, { recursive: true });
|
|
cwd = fs.mkdtempSync(path.join(scratchParent, 'gemini-install-'));
|
|
} catch (e) {
|
|
results.failed.push(['gemini', `could not create scratch directory below ${scratchParent}: ${e.message}`]);
|
|
process.stdout.write('\n');
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
r = runSpawn('gemini', ['extensions', 'install', url, '--consent'], { env, cwd }, opts.dryRun);
|
|
} finally {
|
|
if (cwd) { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch (_) {} }
|
|
}
|
|
}
|
|
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(process.execPath, [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(process.execPath, [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); });
|