From e843518438b2cfb404edf00db2d8e64c1d975e34 Mon Sep 17 00:00:00 2001 From: Julius Brussee Date: Sun, 10 May 2026 15:50:33 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 18 +-- CONTRIBUTING.md | 9 +- INSTALL.md | 47 ++++--- README.md | 12 +- bin/install.js | 174 ++++++++++++++++++------- bin/lib/settings.js | 6 + docs/install-windows.md | 4 +- skills/caveman-compress/README.md | 14 +- skills/caveman-help/SKILL.md | 2 +- src/hooks/README.md | 32 +++-- tests/installer/opencode.test.mjs | 72 +++++++++- tests/installer/unit.argv.test.mjs | 41 ++++++ tests/installer/unit.settings.test.mjs | 11 ++ 13 files changed, 336 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 720e041..971f5c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `. 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 ` 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 ` runs `npx skills add ... -a `. + +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 ` | 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 823260f..ac9293c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/INSTALL.md b/INSTALL.md index c5ee6c1..4def7b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 ` 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 ` 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 ` | 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 ` | 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). --- diff --git a/README.md b/README.md index 918437d..3e2430b 100644 --- a/README.md +++ b/README.md @@ -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 ` | Rewrite memory file (e.g. `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens every session. Code/URLs/paths byte-preserved. | +| `/caveman-compress ` | 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 ``` diff --git a/bin/install.js b/bin/install.js index 1a4f30b..e1a5c08 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 = ''; +const OPENCODE_AGENTS_MD_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 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 Use this dir as Claude config dir (default: \$CLAUDE_CONFIG_DIR or ~/.claude). + --config-dir Claude Code config dir for hook files + settings.json. + Default: \$CLAUDE_CONFIG_DIR or ~/.claude. Does NOT + scope \`claude plugin install\`, \`gemini extensions + install\`, opencode (XDG_CONFIG_HOME), or openclaw + (OPENCLAW_WORKSPACE) — those use their own paths. --non-interactive Never prompt; use defaults. (Auto when stdin is not a TTY.) --list Print provider matrix and exit. --no-color Disable ANSI colors. @@ -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'); diff --git a/bin/lib/settings.js b/bin/lib/settings.js index 595540f..45c248d 100644 --- a/bin/lib/settings.js +++ b/bin/lib/settings.js @@ -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; diff --git a/docs/install-windows.md b/docs/install-windows.md index 2da419c..8ab5402 100644 --- a/docs/install-windows.md +++ b/docs/install-windows.md @@ -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 diff --git a/skills/caveman-compress/README.md b/skills/caveman-compress/README.md index 2c52cdc..b0fe65d 100644 --- a/skills/caveman-compress/README.md +++ b/skills/caveman-compress/README.md @@ -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 +/caveman-compress ``` 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) ↓ diff --git a/skills/caveman-help/SKILL.md b/skills/caveman-help/SKILL.md index 078e487..252221f 100644 --- a/skills/caveman-help/SKILL.md +++ b/skills/caveman-help/SKILL.md @@ -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 ` | Compress .md files to caveman prose. Saves ~46% input tokens. | +| **caveman-compress** | `/caveman-compress ` | Compress .md files to caveman prose. Saves ~46% input tokens. | | **caveman-help** | `/caveman-help` | This card. | ## Deactivate diff --git a/src/hooks/README.md b/src/hooks/README.md index f1ccb76..04e97e5 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -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`). diff --git a/tests/installer/opencode.test.mjs b/tests/installer/opencode.test.mjs index 395ad79..f2691db 100644 --- a/tests/installer/opencode.test.mjs +++ b/tests/installer/opencode.test.mjs @@ -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, //); + assert.match(agentsBody, //); 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 block should be stripped'); + assert.doesNotMatch(after, //, '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(); diff --git a/tests/installer/unit.argv.test.mjs b/tests/installer/unit.argv.test.mjs index d0db1b8..42234a1 100644 --- a/tests/installer/unit.argv.test.mjs +++ b/tests/installer/unit.argv.test.mjs @@ -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/); +}); diff --git a/tests/installer/unit.settings.test.mjs b/tests/installer/unit.settings.test.mjs index 703cd6c..c3040f6 100644 --- a/tests/installer/unit.settings.test.mjs +++ b/tests/installer/unit.settings.test.mjs @@ -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: {