Improve installer idempotency and opencode fencing

Refactor and harden the unified Node installer and related docs. Major changes:

- bin/install.js: validate --only ids, expand ~ for --config-dir, make Claude/install flows async (await hooks/init), add many CLI flags (--no-mcp-shrink, --with-hooks/--no-hooks, --skip-skills, --config-dir docs), preserve original backups once, avoid overwriting plugin/command files unless --force, and add opencode AGENTS.md fenced begin/end markers so installs/uninstalls can append/strip cleanly. Also add opencode idempotency probes for claude/gemini uninstall paths and better handling of opencode plugin payload.
- bin/lib/settings.js: safer removeCavemanHooks that validates shapes before mutating hooks.
- runInit/installHooks/downloadTo calls made async and awaited; runInit returns promise now.
- Docs and README/INSTALL/CONTRIBUTING/CLAUDE.md/src/hooks/README.md: update user-facing text to match new flags/behaviour, standardize use of $CLAUDE_CONFIG_DIR, clarify --with-init semantics, and note opencode/openclaw handling. Change command name usages from `/caveman:compress` to `/caveman-compress`.
- opencode: write fenced caveman block to AGENTS.md and handle legacy unfenced blocks; tests updated to expect fence markers.

These changes improve idempotency, safer upgrades/uninstalls, clearer UX around per-repo vs per-user init, and make the opencode rule block removable without destroying user content.
This commit is contained in:
Julius Brussee
2026-05-10 15:50:33 +02:00
parent 7b2bed2d0b
commit e843518438
13 changed files with 336 additions and 106 deletions
+10 -8
View File
@@ -83,7 +83,7 @@ caveman/
| File | What it controls |
|------|-----------------|
| `skills/caveman/SKILL.md` | Caveman behavior: intensity levels, rules, wenyan mode, auto-clarity, persistence. Only file to edit for behavior changes. |
| `src/rules/caveman-activate.md` | Always-on auto-activation rule body. Consumed by `src/tools/caveman-init.js` when a user runs `npx caveman --only <agent>`. Edit here, not in any per-agent rule copy. |
| `src/rules/caveman-activate.md` | Always-on auto-activation rule body. Consumed by `src/tools/caveman-init.js` when a user runs `npx caveman --with-init` (per-repo IDE rule files). Edit here, not in any per-agent rule copy. |
| `src/rules/caveman-openclaw-bootstrap.md` | Marker-fenced bootstrap snippet appended to `~/.openclaw/workspace/SOUL.md` by `bin/lib/openclaw.js`. Drives always-on caveman through the OpenClaw gateway. Must include the SENTINEL `Respond terse like smart caveman` and stay well under OpenClaw's 12K-per-bootstrap-file cap. |
| `bin/lib/openclaw.js` | OpenClaw install/uninstall helper. Frontmatter merge (`version`, `always: true`), SOUL.md marker append/strip, idempotent. Shared by `bin/install.js` and `src/tools/caveman-init.js`. |
| `skills/caveman-commit/SKILL.md` | Caveman commit message behavior. Fully independent skill. |
@@ -99,7 +99,9 @@ caveman/
### Auto-generated / auto-synced — do not edit directly
We removed the agent-specific dotdir mirrors at the repo root (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, root `caveman/SKILL.md`). They were never read by the installer — only used to self-apply caveman to this repo when a maintainer opened it in Cursor/Windsurf/Cline. Devs who want caveman in their editor while editing this repo should run `npx caveman --only <agent>` once. The installer generates per-user agent rule files from `src/rules/caveman-activate.md` via `src/tools/caveman-init.js`.
We removed the agent-specific dotdir mirrors at the repo root (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, root `caveman/SKILL.md`). They were never read by the installer — only used to self-apply caveman to this repo when a maintainer opened it in Cursor/Windsurf/Cline. Devs who want caveman in their editor while editing this repo should run `npx caveman --with-init` once (writes per-repo rule files from `src/rules/caveman-activate.md` via `src/tools/caveman-init.js`). For per-user installs through the upstream skills CLI, `npx caveman --only <agent>` runs `npx skills add ... -a <profile>`.
A handful of dotdir leftovers (`.junie/`, `.kiro/`, `.roo/`, `.agents/`) still hold a stale `cavecrew/SKILL.md` mirror from before the cleanup. They aren't read by anything in the current install path; remove on sight, no migration needed.
What's left is the Claude Code plugin distribution (required by the plugin loader) and the release ZIP.
@@ -111,7 +113,7 @@ What's left is the Claude Code plugin distribution (required by the plugin loade
| `plugins/caveman/agents/cavecrew-*.md` | `agents/cavecrew-*.md` |
| `dist/caveman.skill` | ZIP of `skills/caveman/` directory (gitignored; rebuilt by CI on release) |
Skills not in this table (`caveman-commit`, `caveman-review`, `caveman-help`, `caveman-stats`) are not mirrored into the Claude Code plugin distribution. They reach Claude Code through the standalone hook + skill install path, and reach other agents via `npx skills add`.
Skills not in this table (`caveman-commit`, `caveman-review`, `caveman-help`, `caveman-stats`) are not mirrored into the Claude Code plugin distribution by CI. They reach Claude Code through the standalone hook + skill install path, and reach other agents via `npx skills add`. A `plugins/caveman/skills/caveman-stats/` directory is currently checked in as a hand-committed copy; the sync workflow does not touch it, so don't rely on edits there to propagate.
---
@@ -172,7 +174,7 @@ Reads JSON from stdin. Three responsibilities:
- `/caveman` → configured default (see `caveman-config.js`, defaults to `full`)
- `/caveman lite``lite`
- `/caveman ultra``ultra`
- `/caveman wenyan` or `/caveman wenyan-full``wenyan`
- `/caveman wenyan` or `/caveman wenyan-full``wenyan` (alias) / `wenyan-full`
- `/caveman wenyan-lite``wenyan-lite`
- `/caveman wenyan-ultra``wenyan-ultra`
- `/caveman-commit``commit`
@@ -242,10 +244,10 @@ How caveman reaches each agent type:
| Gemini CLI | Extension with `GEMINI.md` context file | Yes — context file loads every session |
| opencode | Native plugin (`src/plugins/opencode/`) copied into `~/.config/opencode/plugins/caveman/` + `AGENTS.md` ruleset + skills/agents/commands directories. Plugin uses `session.created` and `tui.prompt.append` lifecycle hooks. No statusline (opencode TUI exposes no plugin-writable badge). | Yes — `session.created` writes flag, `AGENTS.md` carries always-on ruleset |
| OpenClaw | Workspace skill at `~/.openclaw/workspace/skills/caveman/SKILL.md` (frontmatter merged with `version` + `always: true`) plus a marker-fenced bootstrap block in `~/.openclaw/workspace/SOUL.md`. Both writes go through `bin/lib/openclaw.js`; workspace path is overridable via `OPENCLAW_WORKSPACE`. | Yes — SOUL.md is auto-injected each turn under "Project Context" (subject to OpenClaw's 12K-per-file / 60K-total bootstrap caps) |
| Cursor | Per-user `.cursor/rules/caveman.mdc` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only cursor`) |
| Windsurf | Per-user `.windsurf/rules/caveman.md` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only windsurf`) |
| Cline | Per-user `.clinerules/caveman.md` written by `src/tools/caveman-init.js` | Yes — Cline auto-discovers `.clinerules/` |
| Copilot | Per-user `.github/copilot-instructions.md` written by `src/tools/caveman-init.js` + `AGENTS.md` | Yes — repo-wide instructions |
| Cursor | `npx skills add ... -a cursor` (default via `--only cursor`) writes the upstream skill profile; per-repo `.cursor/rules/caveman.mdc` via `--with-init` (calls `src/tools/caveman-init.js`) | Yes — always-on rule |
| Windsurf | `npx skills add ... -a windsurf` (default via `--only windsurf`); per-repo `.windsurf/rules/caveman.md` via `--with-init` | Yes — always-on rule |
| Cline | `npx skills add ... -a cline` (default via `--only cline`); per-repo `.clinerules/caveman.md` via `--with-init` | Yes — Cline auto-discovers `.clinerules/` |
| Copilot | `npx skills add ... -a github-copilot` (soft probe — pass `--only copilot`); per-repo `.github/copilot-instructions.md` + `AGENTS.md` via `--with-init` | Yes — repo-wide instructions |
| Others (Junie, Trae, Warp, Tabnine, Mistral, Qwen, Devin, Droid, ForgeCode, Bob, Crush, iFlow, OpenHands, Qoder, Rovo Dev, Replit, Antigravity, …) | `npx skills add JuliusBrussee/caveman -a <profile>` | No — user must say `/caveman` each session |
opencode reaches Tier 1 minus the statusline (opencode's TUI has no plugin-writable badge). Mode flag lives at `~/.config/opencode/.caveman-active` for any external tooling that wants to surface it.
+4 -5
View File
@@ -58,13 +58,12 @@ on every push to `main`.
| Path | Rebuilt from |
|------|--------------|
| `plugins/caveman/skills/caveman/SKILL.md` | `skills/caveman/SKILL.md` |
| `plugins/caveman/skills/caveman-commit/SKILL.md` | `skills/caveman-commit/SKILL.md` |
| `plugins/caveman/skills/caveman-review/SKILL.md` | `skills/caveman-review/SKILL.md` |
| `plugins/caveman/skills/caveman-compress/SKILL.md` | `skills/caveman-compress/SKILL.md` |
| `plugins/caveman/skills/caveman-compress/{SKILL.md, scripts/}` | `skills/caveman-compress/{SKILL.md, scripts/}` |
| `plugins/caveman/skills/cavecrew/SKILL.md` | `skills/cavecrew/SKILL.md` |
| `plugins/caveman/agents/cavecrew-*.md` | `agents/cavecrew-*.md` |
| `dist/caveman.skill` | ZIP of `skills/caveman/` |
| `.codex-plugin/plugin.json` (inside any plugin distribution) | generated at package time |
| `dist/caveman.skill` | ZIP of `skills/caveman/` (gitignored; rebuilt by CI on each push to `main`) |
`caveman-commit`, `caveman-review`, `caveman-help`, and `caveman-stats` are **not** mirrored under `plugins/caveman/skills/` by CI. Claude Code reaches them through the standalone hook + skill install path and `npx skills` carries them to other agents. If you see `plugins/caveman/skills/caveman-stats/` checked in, treat it as a legacy hand-committed copy — the workflow in `.github/workflows/sync-skill.yml` does not touch it.
When in doubt: if the file lives under `plugins/`, `dist/`, or any agent
dotdir mirror, it's a build artifact. Edit the top-level source instead.
+28 -19
View File
@@ -41,11 +41,11 @@ If you want to install for one agent (or want to know exactly what command runs
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` | Yes |
| **opencode** | `node bin/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
| **OpenClaw** | `npx -y github:JuliusBrussee/caveman -- --only openclaw` | Yes (workspace skill + SOUL.md) |
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Yes (in this repo via `.codex/`) |
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Yes (rule file) |
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Yes (rule file) |
| **Cline** | `npx skills add JuliusBrussee/caveman -a cline` | Yes (rule file) |
| **GitHub Copilot** | `npx skills add JuliusBrussee/caveman -a github-copilot` | Yes (repo-wide instructions) |
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Per-session: `/caveman` |
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Per-session by default; `--with-init` for an always-on rule file |
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Per-session by default; `--with-init` for an always-on rule file |
| **Cline** | `npx skills add JuliusBrussee/caveman -a cline` | Per-session by default; `--with-init` for an always-on rule file |
| **GitHub Copilot** *(soft probe)* | `npx -y github:JuliusBrussee/caveman -- --only copilot --with-init` | Repo-wide instructions via `--with-init` |
| **Continue** | `npx skills add JuliusBrussee/caveman -a continue` | No — say `/caveman` |
| **Kilo Code** | `npx skills add JuliusBrussee/caveman -a kilo` | No |
| **Roo Code** | `npx skills add JuliusBrussee/caveman -a roo` | No |
@@ -72,7 +72,7 @@ If you want to install for one agent (or want to know exactly what command runs
| **Qoder** *(soft probe)* | `npx skills add JuliusBrussee/caveman -a qoder` | No |
| **Google Antigravity** *(soft probe)* | `npx skills add JuliusBrussee/caveman -a antigravity` | No |
"Soft probe" = installer won't auto-detect these without `--only <id>` because there's no reliable always-on signal. Pass the flag when you want them.
"Soft probe" = installer won't auto-detect these without `--only <id>` because there's no reliable always-on signal (Copilot subscription state is auth-gated; the others have no CLI / config-dir-only). Pass the flag when you want them.
For "auto-activates? No" agents, type `/caveman` once per session (or use natural-language triggers like "talk like caveman", "caveman mode").
@@ -105,8 +105,14 @@ Useful flags:
| `--minimal` | Plugin / extension only. No hooks, no MCP shrink, no per-repo rules. |
| `--only <id>` | One agent only. Repeatable: `--only claude --only cursor`. |
| `--dry-run` | Print every command. Write nothing. |
| `--with-init` | Drop always-on rule files into the current repo (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, `AGENTS.md`). |
| `--with-init` | Drop always-on rule files into the current repo (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`, `.opencode/AGENTS.md`, `AGENTS.md`) and, if OpenClaw is on the box, append the bootstrap block to `~/.openclaw/workspace/SOUL.md`. |
| `--with-mcp-shrink` | Register `caveman-shrink` MCP proxy. **On by default.** |
| `--no-mcp-shrink` | Skip MCP-shrink registration. |
| `--with-hooks` / `--no-hooks` | Force-on or force-off the Claude Code hook installer. (Default: on.) |
| `--skip-skills` | Don't run the npx-skills auto-detect fallback when nothing else matched. |
| `--config-dir <path>` | Claude Code config dir for hook files + `settings.json`. **Does NOT scope** `claude plugin install`, `gemini extensions install`, opencode (`XDG_CONFIG_HOME`), or openclaw (`OPENCLAW_WORKSPACE`) — those use their own paths. Default: `$CLAUDE_CONFIG_DIR` or `~/.claude`. `~` is expanded. |
| `--non-interactive` | Never prompt; use defaults. (Auto when stdin is not a TTY.) |
| `--no-color` | Disable ANSI colors. |
| `--list` | Print full agent matrix and exit. |
| `--force` | Re-run even if already installed. |
| `--uninstall` | Remove everything. See below. |
@@ -124,7 +130,7 @@ curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rule
> .cursor/rules/caveman.mdc # or .windsurf/rules/caveman.md, .clinerules/caveman.md, .github/copilot-instructions.md
```
`--with-init` writes the rule into every supported per-agent location it can detect (`.cursor/rules/`, `.windsurf/rules/`, `.clinerules/`, `.github/copilot-instructions.md`, `AGENTS.md`). Single source: [`src/rules/caveman-activate.md`](src/rules/caveman-activate.md).
`--with-init` writes the rule into every supported per-agent location it can detect (`.cursor/rules/`, `.windsurf/rules/`, `.clinerules/`, `.github/copilot-instructions.md`, `.opencode/AGENTS.md`, `AGENTS.md`). It also installs the OpenClaw workspace bootstrap (skill folder + SOUL.md marker block) when `~/.openclaw/workspace/` exists. Single source: [`src/rules/caveman-activate.md`](src/rules/caveman-activate.md).
## Verify
@@ -145,7 +151,7 @@ Open Claude Code, type `/caveman`. Response should be terse fragments — "Got i
**3. Check the flag file.**
```bash
cat ~/.claude/.caveman-active
cat "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active"
# expected output: full
```
@@ -161,15 +167,17 @@ npx -y github:JuliusBrussee/caveman -- --uninstall
What it removes:
- Caveman hook entries from `~/.claude/settings.json` (matched by the substring `caveman`).
- Hook files from `~/.claude/hooks/caveman-*`.
- Caveman hook entries from `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/`; matched by the substring `caveman`).
- Hook files in `$CLAUDE_CONFIG_DIR/hooks/` (`caveman-activate.js`, `caveman-mode-tracker.js`, `caveman-stats.js`, `caveman-config.js`, `caveman-statusline.{sh,ps1}`, plus the dir's `package.json` marker).
- The Claude Code plugin and the Gemini CLI extension (if installed).
- The opencode native plugin (`~/.config/opencode/plugins/caveman/`, the `plugin` and `mcp.caveman-shrink` entries from `opencode.json`, our skill/agent/command files, the caveman block from `AGENTS.md`, and the opencode flag file).
- The OpenClaw workspace skill folder and the marker-fenced block from `~/.openclaw/workspace/SOUL.md` (when present).
- The `.caveman-active` flag file.
What it does **not** remove:
- Skills installed via `npx skills add` — the `skills` CLI manages those. Run `npx skills remove caveman` (or use your IDE's skill manager).
- Per-repo rule files written by `--with-init` (`.cursor/rules/`, `.windsurf/rules/`, `.clinerules/`, `.github/copilot-instructions.md`, `AGENTS.md`). Delete by hand if you want.
- Per-repo rule files written by `--with-init` (`.cursor/rules/`, `.windsurf/rules/`, `.clinerules/`, `.github/copilot-instructions.md`, `.opencode/AGENTS.md`, `AGENTS.md`). Delete by hand if you want.
## Troubleshooting
@@ -186,8 +194,8 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
**"I ran the installer but Claude Code isn't talking caveman."**
1. Run `node bin/install.js --list` — confirm `claude` is on the detected list. If not, `claude` isn't on `PATH`. Fix that first.
2. Open `~/.claude/settings.json` and look for `"hooks"` containing `caveman-activate.js` and `caveman-mode-tracker.js`. If missing, re-run with `--force`.
3. Check `~/.claude/.caveman-active` exists with content `full`. If not, the SessionStart hook silent-failed — check `~/.claude/hooks/` for the JS files and try `node ~/.claude/hooks/caveman-activate.js < /dev/null` to see if it errors.
2. Open `$CLAUDE_CONFIG_DIR/settings.json` (default `~/.claude/settings.json`) and look for `"hooks"` containing `caveman-activate.js` and `caveman-mode-tracker.js`. If missing, re-run with `--force`.
3. Check `$CLAUDE_CONFIG_DIR/.caveman-active` exists with content `full`. If not, the SessionStart hook silent-failed — check `$CLAUDE_CONFIG_DIR/hooks/` for the JS files and try `node $CLAUDE_CONFIG_DIR/hooks/caveman-activate.js < /dev/null` to see if it errors.
4. Restart Claude Code. The SessionStart hook only fires on session start, not mid-session.
**"Hooks failing on Windows."**
@@ -201,7 +209,7 @@ Still broken? [Open an issue](https://github.com/JuliusBrussee/caveman/issues).
The installer uses a JSONC-tolerant parser (`bin/lib/settings.js`) so comments and trailing commas don't crash the merge. It also runs `validateHookFields()` before every write so a malformed hook can't poison the file. If something still went wrong:
1. Check for a backup at `~/.claude/settings.json.bak` (installer writes one before any merge).
1. Check for a backup at `$CLAUDE_CONFIG_DIR/settings.json.bak` (installer writes one before any merge).
2. If no backup, restore from your shell history or version control.
3. File an issue with the broken `settings.json` content (redacted) — that file passing validation but breaking Claude Code is a bug we want to fix.
@@ -225,13 +233,14 @@ The profile slug must exist in [vercel-labs/skills](https://github.com/vercel-la
## Privacy
The installer doesn't phone home. It only writes to:
The installer doesn't phone home. It writes to:
- `$CLAUDE_CONFIG_DIR` (default `~/.claude/`) — hooks, flag file, settings merge.
- The agent's own config location — Cursor's `.cursor/rules/`, Windsurf's `.windsurf/rules/`, etc.
- `$CLAUDE_CONFIG_DIR` (default `~/.claude/`) — hooks, flag file, `settings.json` merge.
- Each agent's own config location — Cursor's `.cursor/rules/`, Windsurf's `.windsurf/rules/`, opencode's `~/.config/opencode/`, etc.
- Your current working directory (only with `--with-init`) — repo-local rule files.
- `~/.openclaw/workspace/` (only with `--only openclaw` or `--with-init` when OpenClaw is detected) — the one `--with-init` side-effect outside the cwd.
No telemetry. No analytics. No network calls beyond `npm`/`npx` fetching the `skills` CLI from npm and (during install only) `git`/`curl` fetching this repo. Source: [`bin/install.js`](bin/install.js).
No telemetry. No analytics. The installer's own code makes no network calls. Network requests do happen indirectly through the per-agent CLIs it shells out to — `claude plugin marketplace add`, `claude plugin install`, `gemini extensions install`, `npm view caveman-shrink`, and `npx -y skills add`. Each fetches from its own registry (Anthropic / GitHub / npm). Source: [`bin/install.js`](bin/install.js).
---
+8 -4
View File
@@ -84,11 +84,11 @@ One line. Find every agent. Install for each.
# macOS / Linux / WSL / Git Bash
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash
# Windows (PowerShell)
# Windows (PowerShell 5.1+)
irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex
```
~30 seconds. Skip agent you no have. Safe to re-run.
~30 seconds. Needs Node ≥18. Skip agent you no have. Safe to re-run.
**Trigger:** type `/caveman` or say "talk like caveman". Stop with "normal mode".
@@ -103,7 +103,7 @@ Install break? Open agent, say *"Read CLAUDE.md and INSTALL.md, install caveman
| `/caveman-commit` | Conventional Commit messages, ≤50 char subject. Why over what. |
| `/caveman-review` | One-line PR comments: `L42: 🔴 bug: user null. Add guard.` |
| `/caveman-stats` | Real session token usage + lifetime savings + USD. Tweetable line via `--share`. |
| `/caveman:compress <file>` | Rewrite memory file (e.g. `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens every session. Code/URLs/paths byte-preserved. |
| `/caveman-compress <file>` | Rewrite memory file (e.g. `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens every session. Code/URLs/paths byte-preserved. |
| `caveman-shrink` | MCP middleware. Wraps any MCP server, compresses tool descriptions. [npm](https://www.npmjs.com/package/caveman-shrink). |
| `cavecrew-*` | Caveman subagents (investigator/builder/reviewer). ~60% fewer tokens than vanilla, main context lasts longer. |
@@ -163,9 +163,13 @@ Maintainer detail (hook architecture, file ownership, CI sync) live in [CLAUDE.m
[**OpenClaw**](https://openclaw.ai) the self-host gateway. One box, many agent inside (Claude Code, Codex, Pi, OpenCode), wired to your Slack / Discord / iMessage / Telegram / whatever. Tagline: *"The lobster way."* Lobster strong. Lobster smart. Lobster also talk a lot.
Caveman teach lobster brevity:
Caveman teach lobster brevity — same canonical installer, scoped to one agent:
```bash
# macOS / Linux / WSL
curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --only openclaw
# Windows (PowerShell): no Node? install Node ≥18 first, then
npx -y github:JuliusBrussee/caveman -- --only openclaw
```
+129 -45
View File
@@ -78,7 +78,7 @@ function parseArgs(argv) {
case '--config-dir': {
const v = argv[++i];
if (!v || v.startsWith('--')) die('error: --config-dir requires a path');
opts.configDir = v;
opts.configDir = expandHome(v);
break;
}
default:
@@ -90,6 +90,16 @@ function parseArgs(argv) {
if (opts.minimal) { opts.withHooks = false; opts.withInit = false; opts.withMcpShrink = false; }
if (opts.withHooks === 'auto') opts.withHooks = true;
if (opts.withMcpShrink === 'auto') opts.withMcpShrink = true;
// 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;
}
@@ -202,8 +212,6 @@ const PROVIDERS = [
// ── Detection ─────────────────────────────────────────────────────────────
function hasCmd(cmd) {
const which = process.platform === 'win32' ? 'where' : 'command';
const args = process.platform === 'win32' ? [cmd] : ['-v', cmd];
try {
if (process.platform === 'win32') {
const r = child_process.spawnSync('where', [cmd], { stdio: 'ignore' });
@@ -212,8 +220,6 @@ function hasCmd(cmd) {
const r = child_process.spawnSync('sh', ['-c', `command -v ${shellEscape(cmd)}`], { stdio: 'ignore' });
return r.status === 0;
} catch (_) { return false; }
// unreachable; satisfy linters
void which; void args;
}
function shellEscape(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
@@ -369,7 +375,7 @@ function absoluteNodePath() {
}
// ── Per-provider installers ────────────────────────────────────────────────
function installClaude(ctx) {
async function installClaude(ctx) {
const { say, note, warn, ok, opts, results } = ctx;
results.detected++;
say('→ Claude Code detected');
@@ -392,7 +398,7 @@ function installClaude(ctx) {
if (opts.withHooks) {
say(' → installing hooks (--with-hooks)');
const r = installHooks(ctx);
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]);
@@ -450,6 +456,11 @@ const OPENCODE_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md',
const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md'];
const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js';
const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman';
// Marker fence for the opencode AGENTS.md ruleset block. Same convention as
// bin/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when
// the user has authored content above AND below it.
const OPENCODE_AGENTS_MD_BEGIN = '<!-- caveman-begin -->';
const OPENCODE_AGENTS_MD_END = '<!-- caveman-end -->';
function opencodeConfigDir() {
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
@@ -503,16 +514,25 @@ function installOpencode(ctx) {
try {
// 1. Plugin dir — copy plugin.js, package.json, caveman-config.js (sibling).
// Same `--force` semantic as commands/agents/skills below: re-runs leave
// user edits to plugin.js alone unless --force is passed.
fs.mkdirSync(pluginDir, { recursive: true });
const pluginSrc = path.join(repoRoot, 'src', 'plugins', 'opencode');
fs.copyFileSync(path.join(pluginSrc, 'plugin.js'), path.join(pluginDir, 'plugin.js'));
fs.copyFileSync(path.join(pluginSrc, 'package.json'), path.join(pluginDir, 'package.json'));
// Renamed to .cjs because the plugin dir is "type": "module" — a bare .js
// sibling would be loaded as ESM and break the plugin's require() bridge.
fs.copyFileSync(
path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'),
path.join(pluginDir, 'caveman-config.cjs'),
);
const pluginPayload = [
[path.join(pluginSrc, 'plugin.js'), path.join(pluginDir, 'plugin.js')],
[path.join(pluginSrc, 'package.json'), path.join(pluginDir, 'package.json')],
// Renamed to .cjs because the plugin dir is "type": "module" — a bare .js
// sibling would be loaded as ESM and break the plugin's require() bridge.
[path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'),
path.join(pluginDir, 'caveman-config.cjs')],
];
for (const [src, dest] of pluginPayload) {
if (fs.existsSync(dest) && !opts.force) {
note(` skipped ${dest} (exists; --force to overwrite)`);
continue;
}
fs.copyFileSync(src, dest);
}
process.stdout.write(` installed: ${pluginDir}\n`);
// 2. Commands.
@@ -550,20 +570,36 @@ function installOpencode(ctx) {
process.stdout.write(` installed: ${dest}/\n`);
}
// 5. AGENTS.md — Tier-3 always-on ruleset. Append-with-sentinel so we
// don't clobber a user-authored AGENTS.md.
// 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');
if (!existing.includes(OPENCODE_AGENTS_MD_SENTINEL)) {
const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
fs.writeFileSync(agentsMd, existing + sep + ruleBody, { mode: 0o644 });
process.stdout.write(` appended caveman ruleset to ${agentsMd}\n`);
} else {
const alreadyFenced = existing.includes(OPENCODE_AGENTS_MD_BEGIN)
&& existing.includes(OPENCODE_AGENTS_MD_END);
const alreadyByLegacySentinel = !alreadyFenced && existing.includes(OPENCODE_AGENTS_MD_SENTINEL);
if (alreadyFenced) {
note(` ${agentsMd} already contains caveman ruleset`);
} else if (alreadyByLegacySentinel) {
note(` ${agentsMd} contains a legacy (un-fenced) caveman block — leaving as-is`);
note(' re-run with --force to replace it with a fenced block');
if (opts.force) {
// Replace the entire file with a clean fenced version. The legacy
// path didn't fence, so we can't isolate the block — full rewrite is
// the only safe option under --force.
fs.writeFileSync(agentsMd, fencedBlock, { mode: 0o644 });
process.stdout.write(` rewrote ${agentsMd} with fenced caveman block\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, ruleBody, { mode: 0o644 });
fs.writeFileSync(agentsMd, fencedBlock, { mode: 0o644 });
process.stdout.write(` installed: ${agentsMd}\n`);
}
@@ -575,8 +611,11 @@ function installOpencode(ctx) {
process.stdout.write('\n');
return;
}
if (fs.existsSync(opencodeJson)) {
try { fs.copyFileSync(opencodeJson, opencodeJson + '.bak'); } catch (_) {}
// Preserve the original on first install only — repeat installs would
// otherwise overwrite the only known-good copy with an already-merged file.
const opencodeBak = opencodeJson + '.bak';
if (fs.existsSync(opencodeJson) && !fs.existsSync(opencodeBak)) {
try { fs.copyFileSync(opencodeJson, opencodeBak); } catch (_) {}
}
if (!Array.isArray(cfg.plugin)) cfg.plugin = [];
if (!cfg.plugin.includes(OPENCODE_PLUGIN_REL)) {
@@ -637,7 +676,7 @@ function installOpenclaw(ctx) {
// ── Hooks installer ────────────────────────────────────────────────────────
// Replaces src/hooks/install.sh + src/hooks/install.ps1.
function installHooks(ctx) {
async function installHooks(ctx) {
const { note, warn, opts, repoRoot, configDir } = ctx;
const hooksDir = path.join(configDir, 'hooks');
const settingsPath = path.join(configDir, 'settings.json');
@@ -658,7 +697,7 @@ function installHooks(ctx) {
if (sourceDir && fs.existsSync(path.join(sourceDir, f))) {
fs.copyFileSync(path.join(sourceDir, f), dest);
} else {
try { downloadTo(`${HOOKS_REMOTE}/${f}`, dest); }
try { await downloadTo(`${HOOKS_REMOTE}/${f}`, dest); }
catch (e) { return `download ${f} failed: ${e.message}`; }
}
process.stdout.write(` installed: ${dest}\n`);
@@ -673,9 +712,12 @@ function installHooks(ctx) {
warn(' settings.json unparseable; will not touch it. Edit manually then re-run.');
return 'settings.json unparseable';
}
// Backup once per install run
if (fs.existsSync(settingsPath)) {
try { fs.copyFileSync(settingsPath, settingsPath + '.bak'); } catch (_) {}
// Backup once, preserved across reinstalls. Without the !fs.existsSync(bak)
// guard, the second install would overwrite the only known-good copy with
// the already-merged file, destroying recovery.
const bak = settingsPath + '.bak';
if (fs.existsSync(settingsPath) && !fs.existsSync(bak)) {
try { fs.copyFileSync(settingsPath, bak); } catch (_) {}
}
const node = absoluteNodePath();
@@ -758,7 +800,7 @@ function installMcpShrink(ctx) {
}
// ── Init writers (per-repo rule files) ────────────────────────────────────
function runInit(ctx) {
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()];
@@ -775,7 +817,7 @@ function runInit(ctx) {
}
try {
const tmp = path.join(os.tmpdir(), `caveman-init-${process.pid}.js`);
downloadTo(INIT_SCRIPT_URL, tmp);
await downloadTo(INIT_SCRIPT_URL, tmp);
const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' });
try { fs.unlinkSync(tmp); } catch (_) {}
return (r.status || 0) === 0;
@@ -846,15 +888,34 @@ function uninstall(ctx) {
// Don't rmdir hooksDir — other plugins may use it.
}
// Plugin uninstall on Claude
// 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 r = runSpawn('claude', ['plugin', 'uninstall', 'caveman@caveman'], null, opts.dryRun);
if ((r.status || 0) === 0) ok(' removed claude plugin');
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 ((r.status || 0) === 0) 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
// Gemini extension. Same idempotency probe as claude.
if (hasCmd('gemini')) {
runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun);
const probe = captureSpawn('gemini', ['extensions', 'list']);
if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) {
runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun);
} else {
note(' gemini extension not installed — skipping');
}
}
// opencode native install — strip plugin entry, MCP entry, and our files.
@@ -894,17 +955,35 @@ function uninstall(ctx) {
const p = path.join(ocDir, 'skills', name);
if (fs.existsSync(p) && !opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} }
}
// AGENTS.md — only remove if our sentinel is the entire content (we
// wrote it) or strip our block from a mixed file.
// 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');
if (body.includes(OPENCODE_AGENTS_MD_SENTINEL)) {
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 (mixed content — strip caveman block manually)`);
note(` left ${ocAgentsMd} in place (legacy mixed content — strip caveman block manually)`);
}
}
}
@@ -985,6 +1064,7 @@ 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.
--minimal Just the plugin/extension install.
@@ -995,7 +1075,11 @@ FLAGS
--with-mcp-shrink Claude Code: register caveman-shrink MCP proxy. (Default ON.)
--no-mcp-shrink Skip MCP shrink.
--uninstall, -u Remove caveman from this machine.
--config-dir <path> Use this dir as Claude config dir (default: \$CLAUDE_CONFIG_DIR or ~/.claude).
--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.
@@ -1065,7 +1149,7 @@ async function main() {
// 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') { installClaude(ctx); 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; }
@@ -1084,8 +1168,8 @@ async function main() {
// Per-repo init
if (opts.withInit) {
ctx.say(`→ writing per-repo IDE rule files into ${process.cwd()} (--with-init)`);
if (runInit(ctx)) ctx.results.installed.push(`caveman-init (${process.cwd()})`);
else ctx.results.failed.push(['caveman-init', 'src/tools/caveman-init.js failed']);
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');
+6
View File
@@ -151,10 +151,16 @@ function addCommandHook(settings, event, opts) {
// ── removeCavemanHooks ────────────────────────────────────────────────────
// Strip every entry whose any hook command mentions `marker`. Empties events.
// Tolerates malformed pre-existing settings (non-array hook lists, foreign
// shapes) — those get dropped by validateHookFields first so we never call
// .length / .filter on a non-array.
function removeCavemanHooks(settings, marker = 'caveman') {
if (!settings || !settings.hooks) return 0;
validateHookFields(settings);
if (!settings.hooks) return 0; // validate may have deleted the whole tree
let removed = 0;
for (const ev of Object.keys(settings.hooks)) {
if (!Array.isArray(settings.hooks[ev])) { delete settings.hooks[ev]; continue; }
const before = settings.hooks[ev].length;
settings.hooks[ev] = settings.hooks[ev].filter(entry => {
if (!entry || !Array.isArray(entry.hooks)) return true;
+2 -2
View File
@@ -1,6 +1,6 @@
# Windows install fallback
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run `hooks/install.ps1` after this.
If `irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex` fails on Windows (issues #249, #199, #72), set up plugin-skill activation by hand. This does **not** install the standalone hooks or the statusline — for those, run the unified Node installer afterwards: `npx -y github:JuliusBrussee/caveman -- --only claude` (or `node bin/install.js --only claude` from a clone).
```powershell
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
@@ -8,7 +8,7 @@ $PluginSkillDir = Join-Path $ClaudeDir ".agents\plugins\caveman\skills\caveman"
$MarketplaceDir = Join-Path $ClaudeDir ".agents\plugins"
$MarketplaceFile = Join-Path $MarketplaceDir "marketplace.json"
# Copy SKILL.md into the plugin path
# Copy SKILL.md into the plugin path (run from a clone of the repo)
New-Item -ItemType Directory -Path $PluginSkillDir -Force | Out-Null
Copy-Item ".\skills\caveman\SKILL.md" "$PluginSkillDir\SKILL.md" -Force
+7 -7
View File
@@ -17,7 +17,7 @@ Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman m
## What It Do
```
/caveman:compress CLAUDE.md
/caveman-compress CLAUDE.md
```
```
@@ -71,7 +71,7 @@ All validations passed ✅ — headings, code blocks, URLs, file paths preserved
## Install
Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman:compress`.
Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman-compress`.
If you need local files, the compress skill lives at:
@@ -84,14 +84,14 @@ caveman-compress/
## Usage
```
/caveman:compress <filepath>
/caveman-compress <filepath>
```
Examples:
```
/caveman:compress CLAUDE.md
/caveman:compress docs/preferences.md
/caveman:compress todos.md
/caveman-compress CLAUDE.md
/caveman-compress docs/preferences.md
/caveman-compress todos.md
```
### What files work
@@ -106,7 +106,7 @@ Examples:
## How It Work
```
/caveman:compress CLAUDE.md
/caveman-compress CLAUDE.md
detect file type (no tokens)
+1 -1
View File
@@ -29,7 +29,7 @@ Mode stick until changed or session end.
|-------|---------|-----------|
| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. |
| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` |
| **caveman-compress** | `/caveman:compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
| **caveman-compress** | `/caveman-compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
| **caveman-help** | `/caveman-help` | This card. |
## Deactivate
+18 -14
View File
@@ -2,27 +2,29 @@
These hooks are **bundled with the caveman plugin** and activate automatically when the plugin is installed. No manual setup required.
If you installed caveman standalone (without the plugin), you can use `bash hooks/install.sh` to wire them into your settings.json manually.
If you installed caveman standalone (without the plugin), the unified Node installer at `bin/install.js` wires them into your `settings.json` for you — run `node bin/install.js --only claude` from a clone, or `npx -y github:JuliusBrussee/caveman -- --only claude` for the curl-pipe path.
## What's Included
### `caveman-activate.js` — SessionStart hook
- Runs once when Claude Code starts
- Writes `full` to `~/.claude/.caveman-active` (flag file)
- Writes `full` to `$CLAUDE_CONFIG_DIR/.caveman-active` (default `~/.claude/.caveman-active`) via the symlink-safe `safeWriteFlag` helper
- Emits caveman rules as hidden SessionStart context
- Detects missing statusline config and emits setup nudge (Claude will offer to help)
### `caveman-mode-tracker.js` — UserPromptSubmit hook
- Fires on every user prompt, checks for `/caveman` commands
- Writes the active mode to the flag file when a caveman command is detected
- Supports: `full`, `lite`, `ultra`, `wenyan`, `wenyan-lite`, `wenyan-ultra`, `commit`, `review`, `compress`
- Fires on every user prompt, checks for `/caveman` commands and natural-language activation/deactivation phrases ("talk like caveman", "stop caveman", "normal mode")
- Writes the active mode to the flag file when a caveman command is detected; deletes it on deactivation
- Emits a small per-turn reinforcement reminder when the flag is set to a non-independent mode (`lite`/`full`/`ultra`/`wenyan*`)
- Supports: `lite`, `full`, `ultra`, `wenyan`, `wenyan-lite`, `wenyan-full`, `wenyan-ultra`, `commit`, `review`, `compress`
### `caveman-statusline.sh` / `caveman-statusline.ps1` — Statusline badge script
- Reads `~/.claude/.caveman-active` and outputs a colored badge
- Reads `$CLAUDE_CONFIG_DIR/.caveman-active` (default `~/.claude/.caveman-active`) and outputs a colored badge
- Shows `[CAVEMAN]`, `[CAVEMAN:ULTRA]`, `[CAVEMAN:WENYAN]`, etc.
- Appends the lifetime savings suffix `⛏ 12.4k` from `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` (written by `caveman-stats.js` on each `/caveman-stats` run; absent until the first run, so fresh installs render no fake number). Opt out with `CAVEMAN_STATUSLINE_SAVINGS=0`.
## Statusline Badge
@@ -32,7 +34,7 @@ The statusline badge shows which caveman mode is active directly in your Claude
If you already have a custom statusline, caveman does not overwrite it and Claude stays quiet. Add the badge snippet to your existing script instead.
**Standalone users:** `install.sh` / `install.ps1` wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
**Standalone users:** the unified installer (`bin/install.js`, invoked by the `install.sh` / `install.ps1` shims at the repo root) wires the statusline automatically if you do not already have a custom statusline. If you do, the installer leaves it alone and prints the merge note.
**Manual setup:** If you need to configure it yourself, add one of these to `~/.claude/settings.json`:
@@ -60,7 +62,7 @@ Replace the path with the actual script location (e.g. `~/.claude/hooks/` for st
```bash
caveman_text=""
caveman_flag="$HOME/.claude/.caveman-active"
caveman_flag="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active"
if [ -f "$caveman_flag" ]; then
caveman_mode=$(cat "$caveman_flag" 2>/dev/null)
if [ "$caveman_mode" = "full" ] || [ -z "$caveman_mode" ]; then
@@ -82,7 +84,7 @@ Badge examples:
## How It Works
```
SessionStart hook ──writes "full"──▶ ~/.claude/.caveman-active ◀──writes mode── UserPromptSubmit hook
SessionStart hook ──writes "full"──▶ $CLAUDE_CONFIG_DIR/.caveman-active ◀──writes mode── UserPromptSubmit hook
reads
@@ -96,12 +98,14 @@ SessionStart stdout is injected as hidden system context — Claude sees it, use
If installed via plugin: disable the plugin — hooks deactivate automatically.
If installed via `install.sh`:
If installed via the standalone Node installer:
```bash
bash hooks/uninstall.sh
npx -y github:JuliusBrussee/caveman -- --uninstall
# or, from a clone:
node bin/install.js --uninstall
```
Or manually:
1. Remove `~/.claude/hooks/caveman-activate.js`, `~/.claude/hooks/caveman-mode-tracker.js`, and the matching statusline script (`caveman-statusline.sh` on macOS/Linux or `caveman-statusline.ps1` on Windows)
2. Remove the SessionStart, UserPromptSubmit, and statusLine entries from `~/.claude/settings.json`
3. Delete `~/.claude/.caveman-active`
1. Remove the caveman hook files from `$CLAUDE_CONFIG_DIR/hooks/` (default `~/.claude/hooks/`): `caveman-activate.js`, `caveman-mode-tracker.js`, `caveman-stats.js`, `caveman-config.js`, and `caveman-statusline.{sh,ps1}`.
2. Remove the SessionStart, UserPromptSubmit, and statusLine entries from `$CLAUDE_CONFIG_DIR/settings.json`.
3. Delete `$CLAUDE_CONFIG_DIR/.caveman-active` (and `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` if you ran `/caveman-stats`).
+71 -1
View File
@@ -79,7 +79,12 @@ test('opencode fresh install drops plugin, commands, agents, skills, AGENTS.md,
assert.ok(fs.existsSync(path.join(ocDir, 'skills', name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
}
assert.ok(fs.existsSync(path.join(ocDir, 'AGENTS.md')), 'AGENTS.md missing');
assert.match(fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8'), /Respond terse like smart caveman/);
const agentsBody = fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8');
assert.match(agentsBody, /Respond terse like smart caveman/);
// Block must be wrapped in begin/end markers so uninstall can isolate it
// from user-authored content above and below.
assert.match(agentsBody, /<!-- caveman-begin -->/);
assert.match(agentsBody, /<!-- caveman-end -->/);
const cfgPath = path.join(ocDir, 'opencode.json');
assert.ok(fs.existsSync(cfgPath), 'opencode.json missing');
@@ -117,6 +122,71 @@ test('opencode idempotent install does not duplicate plugin entries', () => {
}
});
// ── 2b. Plugin payload not overwritten on re-install (without --force) ────
test('opencode re-install preserves user edits to plugin.js without --force', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r1 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r1.status, 2);
const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
const tweak = '\n// USER-TWEAK-DO-NOT-OVERWRITE\n';
fs.appendFileSync(pluginPath, tweak);
const beforeBytes = fs.readFileSync(pluginPath, 'utf8');
const r2 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r2.status, 2);
const afterBytes = fs.readFileSync(pluginPath, 'utf8');
assert.equal(afterBytes, beforeBytes, 'second install should not overwrite plugin.js without --force');
assert.match(afterBytes, /USER-TWEAK-DO-NOT-OVERWRITE/);
// With --force, the file should be replaced (no tweak afterward).
const r3 = runInstaller(['--only', 'opencode', '--force'], env);
assert.notEqual(r3.status, 2);
const forced = fs.readFileSync(pluginPath, 'utf8');
assert.doesNotMatch(forced, /USER-TWEAK-DO-NOT-OVERWRITE/, '--force should overwrite plugin.js');
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 2c. AGENTS.md fence preserves user content above and below ───────────
test('opencode uninstall strips fenced AGENTS.md block, preserving user prefix and suffix', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r1 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r1.status, 2);
const agentsMd = path.join(xdg, 'opencode', 'AGENTS.md');
const installed = fs.readFileSync(agentsMd, 'utf8');
// Sandwich the caveman block between user prefix and suffix.
const userPrefix = '# my project\n\nuse 2-space indent.\n\n';
const userSuffix = '\n## extra\n\nkeep PRs small.\n';
fs.writeFileSync(agentsMd, userPrefix + installed.trimEnd() + '\n' + userSuffix);
const r2 = runInstaller(['--uninstall'], env);
assert.notEqual(r2.status, 2);
const after = fs.readFileSync(agentsMd, 'utf8');
assert.doesNotMatch(after, /<!-- caveman-begin -->/, 'caveman block should be stripped');
assert.doesNotMatch(after, /<!-- caveman-end -->/, 'caveman end marker should be stripped');
assert.doesNotMatch(after, /Respond terse like smart caveman/, 'caveman body should be stripped');
assert.match(after, /# my project/, 'user prefix should survive');
assert.match(after, /use 2-space indent/, 'user prefix body should survive');
assert.match(after, /## extra/, 'user suffix should survive');
assert.match(after, /keep PRs small/, 'user suffix body should survive');
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 3. Tolerates JSONC opencode.json (#249-class regression guard) ───────
test('opencode install tolerates JSONC opencode.json (comments + trailing commas)', () => {
const xdg = freshTmpDir();
+41
View File
@@ -68,3 +68,44 @@ test('aider alias rewrites to aider-desk in dry-run output', () => {
// No detection means no install lines, but the script should not crash.
assert.equal(r.status, 0);
});
test('--only with unknown agent id exits 2', () => {
const r = run('--only', 'definitely-not-an-agent', '--non-interactive');
assert.equal(r.status, 2);
assert.match(r.stderr, /unknown agent: definitely-not-an-agent/);
assert.match(r.stderr, /caveman --list/);
});
test('--only known id passes argv validation', () => {
// Dry-run + --only claude exits 0 even if the claude binary isn't on PATH.
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', '/tmp/__cm_only_test');
assert.equal(r.status, 0);
});
test('--config-dir expands ~ to home directory', async () => {
// Pass `~/cm-test-…` and assert the dry-run plan resolves it relative to $HOME.
// Use a unique suffix so the assertion is unambiguous.
const suffix = `cm-test-${process.pid}`;
const r = run('--dry-run', '--only', 'claude', '--non-interactive', '--config-dir', `~/${suffix}`);
assert.equal(r.status, 0);
// If the literal `~` had survived, we'd see `~/cm-test-…/hooks` in the plan.
// The fix expands it in parseArgs, so we expect the absolute home path.
assert.doesNotMatch(r.stdout, /~\/cm-test-/);
// The plan only includes the hooks dir if claude is detected. Skip the
// positive assertion when claude isn't on PATH on the runner.
if (/Claude Code detected/.test(r.stdout)) {
const { homedir } = await import('node:os');
assert.match(r.stdout, new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/' + suffix));
}
});
test('--help discloses --config-dir scope', () => {
const r = run('--help');
assert.equal(r.status, 0);
// Disclosure: --config-dir does NOT scope third-party CLI invocations.
// Help text wraps mid-phrase, so collapse whitespace before matching.
const collapsed = r.stdout.replace(/\s+/g, ' ');
assert.match(collapsed, /Does NOT scope/);
assert.match(collapsed, /XDG_CONFIG_HOME/);
assert.match(collapsed, /OPENCLAW_WORKSPACE/);
});
+11
View File
@@ -113,6 +113,17 @@ test('hasCavemanHook detects via substring', () => {
assert.equal(SETTINGS.hasCavemanHook(s, 'UserPromptSubmit'), false);
});
test('removeCavemanHooks tolerates malformed hook event values without throwing', () => {
// Pre-fix bug: settings.hooks.SessionStart = "oops" (string, not array)
// would crash on .filter(...) inside the filter loop. Fix delegates to
// validateHookFields first + adds Array.isArray guard.
const s = { hooks: { SessionStart: "oops", UserPromptSubmit: { not: 'an array either' } } };
let removed;
assert.doesNotThrow(() => { removed = SETTINGS.removeCavemanHooks(s, 'caveman'); });
assert.equal(removed, 0);
assert.equal(s.hooks, undefined);
});
test('removeCavemanHooks strips by marker and cleans empties', () => {
const s = {
hooks: {