commit acf542cac62286700b62fc873d12c134ebd3383d Author: wehub-resource-sync Date: Mon Jul 13 12:07:10 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..07105c0 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "caveman", + "description": "Ultra-compressed communication mode for Claude Code. Cuts 65% of output tokens (measured) while keeping full technical accuracy.", + "owner": { + "name": "Julius Brussee", + "url": "https://github.com/JuliusBrussee" + }, + "plugins": [ + { + "name": "caveman", + "description": "Talk like caveman. Cut 65% output tokens (measured). Keep all technical accuracy.", + "source": "./", + "category": "productivity" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..84cdaba --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "caveman", + "description": "Ultra-compressed communication mode. Cuts 65% of output tokens (measured) while keeping full technical accuracy by speaking like a caveman.", + "author": { + "name": "Julius Brussee", + "url": "https://github.com/JuliusBrussee" + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hooks/caveman-activate.js\"", + "timeout": 5, + "statusMessage": "Loading caveman mode..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/hooks/caveman-mode-tracker.js\"", + "timeout": 5, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } +} diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..146af7e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[features] +hooks = true diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..4bbedc0 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "echo 'CAVEMAN MODE ACTIVE. Rules: Drop articles/filler/pleasantries/hedging. Fragments OK. Short synonyms. Pattern: [thing] [action] [reason]. [next step]. Not: Sure! I would be happy to help you with that. Yes: Bug in auth middleware. Fix: Code/commits/security: write normal. User says stop caveman or normal mode to deactivate.'", + "timeout": 5, + "statusMessage": "Loading caveman mode" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4f31bd8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: JuliusBrussee +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a8ecf04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Caveman speak wrong or break +labels: bug +--- + +**What happen** + + +**Expected** + + +**Before/after example** +``` +Input: [your prompt] +Got: [what caveman said] +Want: [what caveman should say] +``` + +**Platform** +- [ ] Claude Code +- [ ] Codex +- [ ] Other: ___ + +**Version / install method** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..e8f9d9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Make caveman better +labels: enhancement +--- + +**What you want** + + +**Before/after example** +``` +Before: [current behavior] +After: [desired behavior] +``` + +**Why good** + diff --git a/.github/workflows/sync-skill.yml b/.github/workflows/sync-skill.yml new file mode 100644 index 0000000..4fa6253 --- /dev/null +++ b/.github/workflows/sync-skill.yml @@ -0,0 +1,70 @@ +name: Sync SKILL.md and rules + +on: + push: + branches: [main] + paths: + - skills/caveman/SKILL.md + - skills/cavecrew/SKILL.md + - agents/cavecrew-*.md + - skills/caveman-compress/SKILL.md + - skills/caveman-compress/scripts/** + +concurrency: + group: sync-skill + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - name: Pull latest before making changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git pull --rebase origin main + + - name: Sync SKILL.md copies + run: | + cp skills/caveman/SKILL.md plugins/caveman/skills/caveman/SKILL.md + + - name: Sync caveman-compress skill to plugin + run: | + # Plugin distribution mirrors source verbatim — no rename, no sed. + mkdir -p plugins/caveman/skills/caveman-compress + cp skills/caveman-compress/SKILL.md plugins/caveman/skills/caveman-compress/SKILL.md + rm -rf plugins/caveman/skills/caveman-compress/scripts + cp -r skills/caveman-compress/scripts plugins/caveman/skills/caveman-compress/scripts + rm -rf plugins/caveman/skills/caveman-compress/scripts/__pycache__ + + - name: Sync cavecrew skill + agents to plugin + run: | + mkdir -p plugins/caveman/skills/cavecrew plugins/caveman/agents + cp skills/cavecrew/SKILL.md plugins/caveman/skills/cavecrew/SKILL.md + cp agents/cavecrew-investigator.md plugins/caveman/agents/cavecrew-investigator.md + cp agents/cavecrew-builder.md plugins/caveman/agents/cavecrew-builder.md + cp agents/cavecrew-reviewer.md plugins/caveman/agents/cavecrew-reviewer.md + + - name: Rebuild caveman.skill ZIP + run: mkdir -p dist && cd skills && zip -r ../dist/caveman.skill caveman/ + + - name: Commit and push if changed + run: | + git add \ + skills/caveman-compress/ \ + plugins/caveman/skills/caveman-compress/ \ + plugins/caveman/skills/caveman/SKILL.md \ + plugins/caveman/skills/cavecrew/SKILL.md \ + plugins/caveman/agents/cavecrew-investigator.md \ + plugins/caveman/agents/cavecrew-builder.md \ + plugins/caveman/agents/cavecrew-reviewer.md \ + dist/caveman.skill + git diff --staged --quiet && exit 0 + git commit -m "chore: sync SKILL.md copies [skip ci]" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd7bd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +benchmarks/results/*.json +__pycache__/ +*.pyc +.venv/ +.env.local +**/.DS_Store +.claude/worktrees/ +evals/snapshots/*.html +evals/snapshots/*.png +context/refs/research-brief-caveman-code-efficiency.md + +# Build artifacts +dist/* +!dist/caveman.skill diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03df523 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +@./skills/caveman/SKILL.md +@./skills/caveman-commit/SKILL.md +@./skills/caveman-review/SKILL.md +@./skills/caveman-compress/SKILL.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e26a668 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,306 @@ +# CLAUDE.md — caveman + +## README is a product artifact + +README = product front door. Non-technical people read it to decide if caveman worth install. Treat like UI copy. + +**Rules for any README change:** + +- Readable by non-AI-agent users. If you write "SessionStart hook injects system context," invisible to most — translate it. +- Keep Before/After examples first. That the pitch. +- Install table always complete + accurate. One broken install command costs real user. +- What You Get table must sync with actual code. Feature ships or removed → update table. +- Preserve voice. Caveman speak in README on purpose. "Brain still big." "Cost go down forever." "One rock. That it." — intentional brand. Don't normalize. +- Benchmark numbers from real runs in `benchmarks/` and `evals/`. Never invent or round. Re-run if doubt. +- Adding new agent to install table → add detail block in `
` section below. +- Readability check before any README commit: would non-programmer understand + install within 60 seconds? + +--- + +## Project overview + +Caveman makes AI coding agents respond in compressed caveman-style prose — cuts 65% output tokens (measured), full technical accuracy. Ships as Claude Code plugin, Codex plugin, Gemini CLI extension, agent rule files for Cursor, Windsurf, Cline, Copilot, 40+ others via `npx skills`. + +--- + +## What lives where + +Post-cleanup layout. Sources of truth at the top, distribution mirrors below, build outputs in `dist/`, human docs alongside each skill. + +``` +caveman/ +├── README.md # Front door (product pitch) +├── INSTALL.md # Per-agent install commands +├── CONTRIBUTING.md # Dev guide +├── CLAUDE.md # This file (maintainer instructions) +├── AGENTS.md / GEMINI.md # Autodiscovery files (must stay at root) +│ +├── install.sh / install.ps1 # 30-line shims → bin/install.js +│ +├── bin/ # Unified installer +│ ├── install.js # Single source for all 30+ agents (PROVIDERS array) +│ └── lib/settings.js # JSONC-tolerant settings.json reader/writer +│ +├── skills/ # ALL skills, single source of truth +│ ├── caveman/{SKILL.md, README.md} +│ ├── caveman-commit/{SKILL.md, README.md} +│ ├── caveman-review/{SKILL.md, README.md} +│ ├── caveman-help/{SKILL.md, README.md} +│ ├── caveman-stats/{SKILL.md, README.md} +│ ├── caveman-compress/{SKILL.md, README.md, scripts/} +│ └── cavecrew/{SKILL.md, README.md} +│ +├── agents/ # cavecrew subagents (single source — kept at root for plugin auto-discovery) +├── commands/ # Codex/Gemini TOML command stubs (root for plugin auto-discovery) +│ +├── src/ # Internal source — not auto-discovered by plugin +│ ├── hooks/ # Claude Code hooks (installer reads here) +│ ├── rules/ # Auto-activation rule body (single source) +│ ├── tools/ # caveman-init.js (per-repo rule writer) +│ └── mcp-servers/ # caveman-shrink npm-published MCP middleware +│ +├── .claude-plugin/ # Claude Code plugin manifest (REQUIRED at root) +├── plugins/caveman/ # Claude Code plugin distribution (CI-mirrored) +│ ├── skills/ # ← from skills/ +│ └── agents/ # ← from agents/ +│ +├── dist/ # Build artifacts (gitignored) +│ └── caveman.skill # ZIP of skills/caveman/, rebuilt by CI +│ +├── tests/ # All tests (Node + Python) +├── benchmarks/ # Real token measurements through Claude API +├── evals/ # Three-arm eval harness +├── docs/ # User-facing docs site +└── .github/workflows/ # CI sync +``` + +--- + +## File structure and what owns what + +### Single source of truth files — edit only these + +| 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 --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. | +| `skills/caveman-review/SKILL.md` | Caveman code review behavior. Fully independent skill. | +| `skills/caveman-help/SKILL.md` | Quick-reference card. One-shot display, not a persistent mode. | +| `skills/caveman-compress/SKILL.md` | Compress sub-skill behavior. | +| `skills/cavecrew/SKILL.md` | Cavecrew decision guide — when to delegate to caveman subagents vs vanilla. Edit only here. | +| `agents/cavecrew-investigator.md` | Read-only locator subagent (haiku). Output contract: `path:line — symbol — note`. | +| `agents/cavecrew-builder.md` | Surgical 1-2 file editor subagent. Refuses 3+ file scope. | +| `agents/cavecrew-reviewer.md` | Diff/file reviewer subagent (haiku). One-line findings with severity emoji. | +| `src/plugins/opencode/plugin.js` | opencode native plugin. ESM Bun module — `session.created` writes flag, `tui.prompt.append` parses slash/natural-language activation and appends per-prompt reinforcement. Reuses `caveman-config.js` via `createRequire`. | +| `src/plugins/opencode/commands/*.md` | Six opencode slash-command prompt templates (`/caveman`, `/caveman-{commit,review,compress,stats,help}`). | + +### 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 --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. + +| File | Synced from | +|------|-------------| +| `plugins/caveman/skills/caveman/SKILL.md` | `skills/caveman/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/` 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 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. + +--- + +## CI sync workflow + +`.github/workflows/sync-skill.yml` triggers on main push when `skills/**/SKILL.md` or `agents/cavecrew-*.md` changes. + +What it does: +1. Copies `skills/caveman/SKILL.md` and `skills/cavecrew/SKILL.md` into their `plugins/caveman/skills//` mirrors so the Claude Code plugin loader sees the latest behavior. +2. Copies `skills/caveman-compress/SKILL.md` and its `scripts/` into `plugins/caveman/skills/caveman-compress/`. +3. Copies `agents/cavecrew-*.md` into `plugins/caveman/agents/`. +4. Rebuilds `dist/caveman.skill` (ZIP of `skills/caveman/`) for the release artifact. +5. Commits and pushes with `[skip ci]` to avoid loops. + +CI bot commits as `github-actions[bot]`. After PR merge, wait for workflow before declaring release complete. + +The old steps that mirrored SKILL.md and rules into root dotdirs (`.cursor/`, `.windsurf/`, `.clinerules/`, `.github/copilot-instructions.md`) are gone — those mirrors no longer exist. The old `caveman-compress/` → `skills/compress/` rename-on-sync is also gone now that compress lives at `skills/caveman-compress/`. + +--- + +## Hook system (Claude Code) + +Three hooks in `src/hooks/` plus a `caveman-config.js` shared module and a `package.json` CommonJS marker. Communicate via flag file at `$CLAUDE_CONFIG_DIR/.caveman-active` (falls back to `~/.claude/.caveman-active`). + +``` +SessionStart hook ──writes "full"──▶ $CLAUDE_CONFIG_DIR/.caveman-active ◀──writes mode── UserPromptSubmit hook + │ + reads + ▼ + caveman-statusline.sh + [CAVEMAN] / [CAVEMAN:ULTRA] / ... +``` + +`src/hooks/package.json` pins the directory to `{"type": "commonjs"}` so the `.js` hooks resolve as CJS even when an ancestor `package.json` (e.g. `~/.claude/package.json` from another plugin) declares `"type": "module"`. Without this, `require()` blows up with `ReferenceError: require is not defined in ES module scope`. + +All hooks honor `CLAUDE_CONFIG_DIR` for non-default Claude Code config locations. + +### `src/hooks/caveman-config.js` — shared module + +Exports: +- `getDefaultMode()` — resolves default mode in order: `CAVEMAN_DEFAULT_MODE` env var → repo-local config (`/.caveman/config.json` or `/.caveman.json`, walking up to the filesystem root) → user config (`$XDG_CONFIG_HOME/caveman/config.json` / `~/.config/caveman/config.json` / `%APPDATA%\caveman\config.json`) → `'full'`. The env var short-circuits before any cwd walk. Repo-local config lets a team check in a per-project default without polluting every contributor's env or user config. +- `findRepoConfigPath(start)` — walks up from `start` (default `process.cwd()`) looking for the first `.caveman/config.json` or `.caveman.json`. Bounded to 64 ancestors. Refuses symlinked files (symmetric with `safeWriteFlag` / `readFlag`). +- `safeWriteFlag(flagPath, content)` — symlink-safe flag write. Refuses if flag target or its immediate parent is a symlink. Opens with `O_NOFOLLOW` where supported. Atomic temp + rename. Creates with `0600`. Protects against local attackers replacing the predictable flag path with a symlink to clobber files writable by the user. Used by both write hooks. Silent-fails on all filesystem errors. + +### `src/hooks/caveman-activate.js` — SessionStart hook + +Runs once per Claude Code session start. Three things: +1. Writes the active mode to `$CLAUDE_CONFIG_DIR/.caveman-active` via `safeWriteFlag` (creates if missing) +2. Emits caveman ruleset as hidden stdout — Claude Code injects SessionStart hook stdout as system context, invisible to user +3. Checks `settings.json` for statusline config; if missing, appends nudge to offer setup on first interaction + +Silent-fails on all filesystem errors — never blocks session start. + +### `src/hooks/caveman-mode-tracker.js` — UserPromptSubmit hook + +Reads JSON from stdin. Three responsibilities: + +**1. Slash-command activation.** If prompt starts with `/caveman`, writes mode to flag file via `safeWriteFlag`: +- `/caveman` → configured default (see `caveman-config.js`, defaults to `full`) +- `/caveman lite` → `lite` +- `/caveman ultra` → `ultra` +- `/caveman wenyan` or `/caveman wenyan-full` → `wenyan` (alias) / `wenyan-full` +- `/caveman wenyan-lite` → `wenyan-lite` +- `/caveman wenyan-ultra` → `wenyan-ultra` +- `/caveman-commit` → `commit` +- `/caveman-review` → `review` +- `/caveman-compress` → `compress` + +**2. Natural-language activation/deactivation.** Matches phrases like "activate caveman", "turn on caveman mode", "talk like caveman" and writes the configured default mode. Matches "stop caveman", "disable caveman", "normal mode", "deactivate caveman" etc. and deletes the flag file. README promises these triggers, the hook enforces them. + +**3. Per-turn reinforcement.** When flag is set to a non-independent mode (i.e. not `commit`/`review`/`compress`), emits a small `hookSpecificOutput` JSON reminder so the model keeps caveman style after other plugins inject competing instructions mid-conversation. The full ruleset still comes from SessionStart — this is just an attention anchor. + +### `src/hooks/caveman-statusline.sh` — Statusline badge + +Reads flag file at `$CLAUDE_CONFIG_DIR/.caveman-active`. Outputs colored badge string for Claude Code statusline: +- `full` or empty → `[CAVEMAN]` (orange) +- anything else → `[CAVEMAN:]` (orange) + +Then appends the lifetime-savings suffix (`⛏ 12.4k`) read from `$CLAUDE_CONFIG_DIR/.caveman-statusline-suffix` — written by `caveman-stats.js` on every `/caveman-stats` run. **Default on**; users opt out with `CAVEMAN_STATUSLINE_SAVINGS=0`. The suffix file is absent until `/caveman-stats` runs at least once, so fresh installs render no fake number. + +Configured in `settings.json` under `statusLine.command`. PowerShell counterpart at `src/hooks/caveman-statusline.ps1` for Windows. Both scripts symlink-refuse and whitelist-validate the flag/suffix file contents — never echo arbitrary bytes. + +### Hook installation + +**Plugin install** — hooks wired automatically by plugin system. + +**Standalone install** — `bin/install.js` (the unified Node installer) copies hook files into `$CLAUDE_CONFIG_DIR/hooks/` and merges SessionStart + UserPromptSubmit + statusline into `settings.json`. Uses the JSONC-tolerant helpers in `bin/lib/settings.js` so a commented `settings.json` no longer crashes the merge. Defensive `validateHookFields` runs before every write to prevent a single malformed hook from poisoning the entire file (Claude Code Zod silently discards the whole `settings.json` on schema mismatch). + +The `install.sh` / `install.ps1` shims at the repo root delegate to `bin/install.js` via `node` (local clone) or `npx -y github:JuliusBrussee/caveman` (curl|bash). No legacy fallback path remains — earlier `install.sh.legacy` / `install.ps1.legacy` files were removed. + +**Uninstall** — `npx -y github:JuliusBrussee/caveman -- --uninstall` (or `node bin/install.js --uninstall` from a clone). Strips caveman hook entries from `settings.json` via substring marker `caveman`, deletes hook files, and removes the Claude plugin / Gemini extension. Skill installs done via `npx skills add` must be removed via the IDE's skill manager (we don't track them). + +--- + +## Skill system + +Skills = Markdown files with YAML frontmatter consumed by Claude Code's skill/plugin system and by `npx skills` for other agents. + +Each skill has a human-facing `README.md` alongside the LLM-facing `SKILL.md`. The README explains what the skill does for users browsing GitHub; the SKILL.md is the prompt body the agent loads. Don't merge them — different audiences, different formats. + +### Intensity levels + +Defined in `skills/caveman/SKILL.md`. Six levels: `lite`, `full` (default), `ultra`, `wenyan-lite`, `wenyan-full`, `wenyan-ultra`. Persists until changed or session ends. + +### Auto-clarity rule + +Caveman drops to normal prose for: security warnings, irreversible action confirmations, multi-step sequences where fragment ambiguity risks misread, user confused or repeating question. Resumes after. Defined in skill — preserve in any SKILL.md edit. + +### caveman-compress + +Sub-skill in `skills/caveman-compress/SKILL.md`. Takes file path, compresses prose to caveman style, writes to original path, saves backup at `.original.md`. Validates headings, code blocks, URLs, file paths, commands preserved. Retries up to 2 times on failure with targeted patches only. Requires Python 3.10+. + +The slash command is `/caveman-compress` everywhere — same name in plugin and standalone install. CI no longer renames the directory on sync (the old `caveman-compress/` → `skills/compress/` sed rename is gone now that the source lives at `skills/caveman-compress/`). + +### caveman-commit / caveman-review + +Independent skills in `skills/caveman-commit/SKILL.md` and `skills/caveman-review/SKILL.md`. Both have own `description` and `name` frontmatter so they load independently. caveman-commit: Conventional Commits, ≤50 char subject. caveman-review: one-line comments in `L: . .` format. + +--- + +## Agent distribution + +How caveman reaches each agent type: + +| Agent | Mechanism | Auto-activates? | +|-------|-----------|----------------| +| Claude Code | Plugin (hooks + skills) or standalone hooks | Yes — SessionStart hook injects rules | +| Codex | Plugin in `plugins/caveman/` plus repo `.codex/hooks.json` and `.codex/config.toml` | Yes on macOS/Linux — SessionStart hook | +| 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 | `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. + +For agents without hook systems, the always-on snippet lives in `INSTALL.md`'s "Want it always on?" section — keep current with `src/rules/caveman-activate.md`. + +**Adding a new agent.** Edit the `PROVIDERS` array in `bin/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection). + +1. The profile slug must exist in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills). Verify against the README before merging — wrong slugs cause `npx skills add` to fail at runtime, not at install-script load. +2. Run `node bin/install.js --list` to confirm the new row renders correctly. +3. Soft probes (config-dir-only) are fine but tag them with `soft: true`. They render with `(soft)` in `--list` so users know detection is best-effort. + +--- + +## Evals + +`evals/` has three-arm harness: +- `__baseline__` — no system prompt +- `__terse__` — `Answer concisely.` +- `` — `Answer concisely.\n\n{SKILL.md}` + +Honest delta = **skill vs terse**, not skill vs baseline. Baseline comparison conflates skill with generic terseness — that cheating. Harness designed to prevent this. + +`llm_run.py` calls `claude -p --system-prompt ...` per (prompt, arm), saves to `evals/snapshots/results.json`. `measure.py` reads snapshot offline with tiktoken (OpenAI BPE — approximates Claude tokenizer, ratios meaningful, absolute numbers approximate). + +Add skill: drop `skills//SKILL.md`. Harness auto-discovers. Add prompt: append line to `evals/prompts/en.txt`. + +Snapshots committed to git. CI reads without API calls. Only regenerate when SKILL.md or prompts change. + +--- + +## Benchmarks + +`benchmarks/` runs real prompts through Claude API (not Claude Code CLI), records raw token counts. Results committed as JSON in `benchmarks/results/`. Benchmark table in README generated from results — update when regenerating. + +To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `.env.local`). + +--- + +## Key rules for agents working here + +- Edit `skills//SKILL.md` for behavior changes. Never edit synced copies under `plugins/caveman/skills/`. +- Edit `src/rules/caveman-activate.md` for auto-activation rule changes. Never edit any per-agent rule copy a user has on their machine. +- Edit `src/rules/caveman-openclaw-bootstrap.md` for the OpenClaw SOUL.md bootstrap snippet. Keep the `` / `` markers and the `Respond terse like smart caveman` sentinel — `bin/lib/openclaw.js` keys idempotency off both. If you change the embedded fallback in `bin/lib/openclaw.js`, keep it byte-equivalent to the file. +- Per-skill human docs live in `skills//README.md`. The LLM-facing body is in `SKILL.md`. Don't merge them — different audiences. +- Build artifacts go in `dist/`. Never check files into `dist/` manually — CI rebuilds them on push, and `dist/` is gitignored. +- README most important file for user-facing impact. Optimize for non-technical readers. Preserve caveman voice. +- `INSTALL.md` is the per-agent install reference. Keep the install table in `README.md` short and link out to `INSTALL.md` for the full matrix. +- Benchmark and eval numbers must be real. Never fabricate or estimate. +- CI workflow commits back to main after merge. Account for when checking branch state. +- Hook files must silent-fail on all filesystem errors. Never let hook crash block session start. +- Any new flag file write must go through `safeWriteFlag()` in `caveman-config.js`. Direct `fs.writeFileSync` on predictable user-owned paths reopens the symlink-clobber attack surface. +- Hooks must respect `CLAUDE_CONFIG_DIR` env var, not hardcode `~/.claude`. Same for `bin/install.js` / statusline scripts. +- `bin/install.js` is the only installer source. `install.sh` / `install.ps1` at repo root are 30-line shims that delegate to it. Never re-add per-OS install logic to the shims — that's how we got the Windows quoting bug (#249). +- Any settings.json read in installer or hooks must go through `bin/lib/settings.js` `readSettings()` so JSONC comments don't crash the merge. Any settings.json write must run through `validateHookFields()` first. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3373945 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,55 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best for the overall community, not just for us as + individuals + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and unwelcome sexual attention or + advances +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Reporting & Contact + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the repository owners. All complaints will be reviewed and investigated +promptly and fairly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac9293c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,192 @@ +# Contributing to caveman + +Thanks for considering a contribution. Caveman is a multi-agent skill that +makes 30+ AI coding agents talk in compressed caveman-style prose. Most +contributions fall into one of three buckets: + +1. **Editing skill prose** — change how caveman speaks, what intensity levels do, what slash commands trigger. +2. **Adding a new agent** — wire a fresh editor/CLI/IDE into the unified installer. +3. **Fixing the hooks or installer** — Claude Code hooks, the Node installer, the per-repo init script. + +Caveman like simple. Small focused PR > big rewrite. + +--- + +## Quick orientation + +The repo distributes one skill (caveman) plus a handful of sub-skills +(caveman-commit, caveman-review, caveman-compress, cavecrew-*) to many +agents through different distribution mechanisms (Claude Code plugin, Codex +plugin, Gemini extension, Cursor/Windsurf/Cline rule files, `npx skills` for +the long tail). A single Node installer at `bin/install.js` detects which +agents are on the user's machine and installs the right thing for each. + +Sources of truth live at the **top level** of the repo. Agent-specific +copies live under `plugins/caveman/` and similar mirror dirs — those are +**rebuilt by CI** and edits there are reverted. + +--- + +## What to edit (sources of truth) + +| I want to change... | Edit this file | +|---|---| +| Caveman behavior (intensity levels, voice, rules) | `skills/caveman/SKILL.md` | +| Caveman commit-message format | `skills/caveman-commit/SKILL.md` | +| Caveman code-review format | `skills/caveman-review/SKILL.md` | +| Caveman compress logic | `skills/caveman-compress/SKILL.md` and `skills/caveman-compress/scripts/` | +| Caveman quick-reference card | `skills/caveman-help/SKILL.md` | +| Cavecrew decision guide (when to delegate to subagents) | `skills/cavecrew/SKILL.md` | +| cavecrew subagent definitions | `agents/cavecrew-investigator.md`, `agents/cavecrew-builder.md`, `agents/cavecrew-reviewer.md` | +| Auto-activation rule body (Cursor/Windsurf/Cline/Copilot) | `src/rules/caveman-activate.md` | +| Add support for a new agent | `bin/install.js` (PROVIDERS array) | +| Per-repo init script (drops rule files into a user's repo) | `src/tools/caveman-init.js` | +| Claude Code hooks | `src/hooks/caveman-activate.js`, `src/hooks/caveman-mode-tracker.js`, `src/hooks/caveman-config.js`, `src/hooks/caveman-statusline.sh`, `src/hooks/caveman-statusline.ps1` | +| Settings.json read/write helpers | `bin/lib/settings.js` | +| MCP shrink server | `src/mcp-servers/caveman-shrink/` | + +That's it. Every other markdown file with `SKILL.md` in the path is a copy. + +--- + +## What NOT to edit (CI-generated mirrors) + +Edits to these files are wiped by the next CI run. The +`.github/workflows/sync-skill.yml` job rebuilds them from the sources above +on every push to `main`. + +| Path | Rebuilt from | +|------|--------------| +| `plugins/caveman/skills/caveman/SKILL.md` | `skills/caveman/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/` (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. + +--- + +## Adding a new agent + +The unified Node installer at `bin/install.js` is the **single source of +truth** for the supported-agent list. The README and `INSTALL.md` install +tables mirror it by hand — bash and PowerShell shims at the repo root just +delegate to it. + +1. Confirm the agent has a distribution path. Either: + - it has a profile slug in upstream [vercel-labs/skills](https://github.com/vercel-labs/skills) (most common), or + - it has a native plugin / extension / rule-file mechanism we can target. +2. Append a row to the `PROVIDERS` array in `bin/install.js`. Each row needs: + - `id` — short kebab-case identifier (e.g. `windsurf`) + - `label` — human display name (e.g. `Windsurf`) + - `mech` — distribution mechanism (`plugin`, `extension`, `rules-file`, `skills-cli`, …) + - `detect` — clause spec like `command:foo||dir:$HOME/x` describing how to detect the agent + - `profile` — the vercel-labs/skills slug, if applicable + - `soft: true` — set when detection is config-dir-only (best-effort) +3. Run `node bin/install.js --list` and confirm the new row renders correctly. Soft probes should show as `(soft)`. +4. Add a row to the install tables in `README.md` and `INSTALL.md`. +5. No CI changes needed — the workflow re-reads `bin/install.js` automatically. + +Bad slug? `npx skills add` fails at install **runtime**, not at install-script +load. Always verify the slug against the vercel-labs/skills README before +merging. + +--- + +## Adding a new skill + +1. Create `skills//SKILL.md` with frontmatter: + ```yaml + --- + name: + description: + --- + ``` +2. Create `skills//README.md` — human-facing summary, install hint, example. +3. Add `skills//scripts/` if the skill ships helpers (Python or Node). +4. If the skill should be in the Claude Code plugin, add a sync step to `.github/workflows/sync-skill.yml` so CI mirrors it into `plugins/caveman/skills//`. +5. If it's user-invocable as a slash command, add a row to the slash-command table in `README.md` and `INSTALL.md`. +6. Add an eval prompt to `evals/prompts/en.txt` if you want the eval harness to score it. + +--- + +## Running tests + +```bash +# Installer unit + e2e tests (Node) +npm test + +# Compress-skill safety tests (Python) +python3 -m unittest tests.test_compress_safety + +# Per-repo init tests +node tests/test_caveman_init.js + +# Flag-file symlink-safety tests +node tests/test_symlink_flag.js +``` + +CI runs all of the above on every PR. If any test depends on a network or +external SDK, it must skip cleanly when the dependency is missing — never +gate the whole suite on optional creds. + +--- + +## Running benchmarks and evals + +Benchmarks hit the real Claude API and record raw token counts: + +```bash +uv run python benchmarks/run.py # needs ANTHROPIC_API_KEY in .env.local +``` + +Evals are a three-arm offline harness (`__baseline__`, `__terse__`, each skill): + +```bash +python evals/llm_run.py # regenerates evals/snapshots/results.json +python evals/measure.py # reads snapshot, prints token deltas +``` + +Snapshots are committed to git. Only regenerate when a `SKILL.md` or +`evals/prompts/en.txt` changes. Numbers in `README.md` and any docs come from +real runs — never invent or round. + +--- + +## Pull-request guidelines + +- **Conventional Commits** for the commit subject. See `skills/caveman-commit/SKILL.md` for the format we use here. +- **One concern per PR.** A README copy-edit and an installer fix go in separate PRs. +- **Update `package.json` `files`** if you add a new top-level directory the installer needs to ship to npm. Files outside that array don't get published. +- **Show before/after** for prose changes to any `SKILL.md`. One sentence on why the new wording is better. +- **Mention the CI sync.** If you edited a source-of-truth file, note it: "CI will resync `plugins/caveman/skills/...` on merge." + +PR descriptions don't need to be long. Caveman style fine. Just say what change, why. + +--- + +## Code style + +A handful of invariants that have bitten us before. Keep them. + +- **Hooks must silent-fail on filesystem errors.** A `try/catch` that swallows the error is correct here. A hook that throws blocks Claude Code session start — that's user-facing breakage. See existing patterns in `src/hooks/caveman-activate.js`. +- **Settings.json reads and writes go through `bin/lib/settings.js`.** It tolerates JSONC comments. Direct `JSON.parse` on a user's `settings.json` will crash on a single `// comment`. +- **Validate hook entries before writing.** Use `validateHookFields()` in `bin/lib/settings.js`. Claude Code's Zod schema silently discards the **entire** `settings.json` on a single bad hook entry — one malformed write poisons the user's whole config. +- **Symlink-safe flag writes via `safeWriteFlag()`** in `src/hooks/caveman-config.js`. The flag file lives at a predictable path under `$CLAUDE_CONFIG_DIR/`; without `O_NOFOLLOW` and a parent-symlink check, a local attacker can clobber any file the user can write. +- **Honor `CLAUDE_CONFIG_DIR`.** Hooks, the installer, and the statusline scripts must respect it — never hardcode `~/.claude`. +- **`install.sh` and `install.ps1` at the repo root are 30-line shims** that delegate to `bin/install.js`. Don't re-add per-OS install logic to them. Quoting bugs that way lie. + +--- + +## Ideas + +See [issues labeled `good first issue`](../../issues?q=label%3A%22good+first+issue%22) +for starter tasks. Or grep `TODO` / `FIXME` in `src/hooks/`, `bin/`, `src/tools/` — +each one is a real lead. + +Caveman like contribution. You bring rock, caveman put rock in pile. Pile +get bigger. Brain still big. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..03df523 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,4 @@ +@./skills/caveman/SKILL.md +@./skills/caveman-commit/SKILL.md +@./skills/caveman-review/SKILL.md +@./skills/caveman-compress/SKILL.md diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..dbfe3eb --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,261 @@ +# Install caveman + +One install. Works for every AI coding agent on your machine. + +If just want it to work, run the one-liner. If want to know what gets touched, scroll down. + +## One-liner + +**macOS / Linux / WSL / Git Bash** + +```bash +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash +``` + +**Windows (PowerShell 5.1+)** + +```powershell +irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex +``` + +> Piping a script straight into a shell runs it sight-unseen. If you'd rather read it first, download then run: `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh -o install.sh` (review it) `&& bash install.sh`. The installer downloads hook files from a pinned release tag and verifies them against a committed SHA-256 manifest before writing. + +What it does: + +- Auto-detects every supported agent installed on your machine (Claude Code, Cursor, Codex, etc.). +- For each one, runs that agent's native install path (plugin / extension / rule file / `npx skills add`). +- Wires Claude Code hooks and statusline badge on top. (`caveman-shrink` MCP middleware is opt-in via `--with-mcp-shrink` — see flag table below.) +- Skips anything you don't have. Safe to re-run. ~30 seconds end-to-end. + +Want to preview before installing? Use `--dry-run`: + +```bash +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --dry-run +``` + +## Per-agent install + +If you want to install for one agent (or want to know exactly what command runs under the hood), use the table below. Every row also works as `--only ` to the unified installer. + +| Agent | Install command | Auto-activates? | +|---|---|:-:| +| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` | Yes | +| **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) | +| **Hermes Agent** | `npx -y github:JuliusBrussee/caveman -- --only hermes` *(or `node bin/install.js --only hermes` from a clone)* | Yes (native skills, enabled on load) | +| **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 | +| **Augment Code** | `npx skills add JuliusBrussee/caveman -a augment` | No | +| **Aider Desk** | `npx skills add JuliusBrussee/caveman -a aider-desk` | No | +| **Sourcegraph Amp** | `npx skills add JuliusBrussee/caveman -a amp` | No | +| **IBM Bob** | `npx skills add JuliusBrussee/caveman -a bob` | No | +| **Crush** | `npx skills add JuliusBrussee/caveman -a crush` | No | +| **Devin (terminal)** | `npx skills add JuliusBrussee/caveman -a devin` | No | +| **Droid (Factory)** | `npx skills add JuliusBrussee/caveman -a droid` | No | +| **ForgeCode** | `npx skills add JuliusBrussee/caveman -a forgecode` | No | +| **Block Goose** | `npx skills add JuliusBrussee/caveman -a goose` | No | +| **iFlow CLI** | `npx skills add JuliusBrussee/caveman -a iflow-cli` | No | +| **Kiro CLI** | `npx skills add JuliusBrussee/caveman -a kiro-cli` | No | +| **Mistral Vibe** | `npx skills add JuliusBrussee/caveman -a mistral-vibe` | No | +| **OpenHands** | `npx skills add JuliusBrussee/caveman -a openhands` | No | +| **Qwen Code** | `npx skills add JuliusBrussee/caveman -a qwen-code` | No | +| **Atlassian Rovo Dev** | `npx skills add JuliusBrussee/caveman -a rovodev` | No | +| **Tabnine CLI** | `npx skills add JuliusBrussee/caveman -a tabnine-cli` | No | +| **Trae** | `npx skills add JuliusBrussee/caveman -a trae` | No | +| **Warp** | `npx skills add JuliusBrussee/caveman -a warp` | No | +| **Replit Agent** | `npx skills add JuliusBrussee/caveman -a replit` | No | +| **JetBrains Junie** *(soft probe)* | `npx skills add JuliusBrussee/caveman -a junie` | No | +| **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 (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"). + +**Finding a profile slug for `npx skills add ... -a `?** Either read the table above, or print the live matrix from the installer: + +```bash +# Either of these works (install.sh / install.ps1 are thin shims that +# forward all flags to bin/install.js): +bash install.sh --list # macOS / Linux / WSL, from a local clone +pwsh install.ps1 --list # Windows / PowerShell, from a local clone +node bin/install.js --list # any platform, from a local clone +npx -y github:JuliusBrussee/caveman -- --list # no clone needed +``` + +Each row prints the agent id, profile slug (where applicable), and whether it was auto-detected on your machine. Full agent matrix (with detection rules) is also defined in `bin/install.js` under the `PROVIDERS` array. + +## Manual install (no `curl | bash`) + +If you'd rather see exactly what runs: + +```bash +# Clone the repo +git clone https://github.com/JuliusBrussee/caveman.git +cd caveman + +# Preview every command the installer would run +node bin/install.js --dry-run --all + +# Inspect the agent matrix +node bin/install.js --list + +# Install for everything detected +node bin/install.js --all +``` + +Useful flags: + +| Flag | What | +|---|---| +| `--all` | Plugin + hooks + statusline + per-repo rule files in `$PWD`. (MCP shrink is opt-in — see `--with-mcp-shrink` below.) | +| `--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`, `.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 wrapping the given upstream MCP server. **Off by default.** A value is required — caveman-shrink is a proxy and exits immediately without one. Example: `--with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp"`. The value is split on whitespace; for paths-with-spaces, install via `node bin/install.js` from a clone or edit `~/.claude.json` after a stub install. | +| `--no-mcp-shrink` | Skip MCP-shrink registration. (Default.) | +| `--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. | + +## Always-on rules + +For agents without a hook system (Cursor, Windsurf, Cline, Copilot, and friends), the always-on path is a static rule file. Two ways: + +```bash +# Drop rule files into the current repo +node bin/install.js --with-init + +# Or pull the rule body straight in (manual) +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md \ + > .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`, `.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 + +After install, three quick checks: + +**1. See what got installed.** + +```bash +node bin/install.js --list +``` + +You should see ~30 rows. Detected agents are marked. Anything you wanted but isn't marked → not detected (likely the binary isn't on `PATH`). + +**2. Talk to Claude Code.** + +Open Claude Code, type `/caveman`. Response should be terse fragments — "Got it. Caveman mode on." or similar. Try a real question: "What is closures in JS?" — answer should drop articles and read like grunts. + +**3. Check the flag file.** + +```bash +cat "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active" +# expected output: full +``` + +If it's missing or empty, the SessionStart hook didn't fire. See troubleshooting below. + +Statusline should show `[CAVEMAN]` (orange) at the bottom of Claude Code. After your first `/caveman-stats` run it appends a savings counter like `[CAVEMAN] ⛏ 12.4k`. + +## Uninstall + +```bash +npx -y github:JuliusBrussee/caveman -- --uninstall +``` + +What it removes: + +- 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`, `.opencode/AGENTS.md`, `AGENTS.md`). Delete by hand if you want. + +## Troubleshooting + +**"Install script broke. What now?"** + +Open your agent in this repo and say: + +> "Read CLAUDE.md and INSTALL.md. Install caveman for me." + +Agent read repo. Agent run install. Caveman make agent talk less — agent first job is install caveman to talk less. Snake eat tail. + +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_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."** + +- Use `install.ps1`, not `install.sh`. Git Bash works for the shell version, but the hook side wires PowerShell counterparts (`caveman-statusline.ps1`). +- PowerShell 5.1 minimum. Check with `$PSVersionTable.PSVersion`. +- If `irm | iex` blocks on execution policy: `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` for the install session, then re-run. +- Long-running issues: see `docs/install-windows.md` in the repo for manual fallback. + +**"My `settings.json` got mangled."** + +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_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. + +**"I'm in a managed env where I can't install hooks."** + +Use the rule-file-only path. Hooks are Claude Code-specific; everything else works via static rule files: + +```bash +# Just install for one agent, no Claude hooks +node bin/install.js --only cursor + +# Or write rule files into the current repo only (no global state) +node bin/install.js --with-init --only cursor --only windsurf +``` + +This drops `.cursor/rules/caveman.mdc` (and friends) into your repo. No hooks, no global config, nothing outside the repo. + +**"`npx skills add` errored on a profile slug."** + +The profile slug must exist in [vercel-labs/skills](https://github.com/vercel-labs/skills). If a row in the table above 404s, the upstream profile was renamed or removed — open an issue, we'll update. + +## Privacy + +The installer doesn't phone home. It writes to: + +- `$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. Run from a clone or via npx, the installer's own code makes no network calls — files are copied locally. One exception: run detached from any checkout (the rare curl-fallback path), it downloads the hook files from raw.githubusercontent.com pinned to an immutable release tag and verifies each against a SHA-256 manifest before wiring anything. Network requests also 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). After install: zero network calls, ever — full statement in [SECURITY.md](./SECURITY.md#privacy--telemetry). + +--- + +Stuck? Open an issue: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8c0ee8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Julius Brussee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8f7cf2 --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +

+ Caveman +

+ +

+ why use many token when few do trick +

+ +

+ Make your AI coding agent talk like a caveman.
+ Same answers, 65% fewer output tokens. Brain still big. Mouth small. +

+ +

+ Stars + 30+ agents + Last commit + License +

+ +

+ See it · + Install · + Levels · + What you get · + Benchmarks · + Ecosystem · + Caveman 2 +

+ +--- + +Caveman is a skill/plugin for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Codex, Gemini, Cursor, Windsurf, Cline, Copilot, and 30+ other agents. Install once. Agent drops the filler and answers in tight caveman-speak, keeping code, commands, and errors byte-for-byte exact. You save output tokens on every reply, forever. + +## Before / After + + + + + + + + + + + + + + +
🗣️ Normal agent — 69 tokens Caveman agent — 19 tokens
+ +> The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object. + + + +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +
+ +> Sure! I'd be happy to help you with that. The issue you're experiencing is most likely caused by your authentication middleware not properly validating the token expiry. Let me take a look and suggest a fix. + + + +> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix: + +
+ +Same fix. Third of the words. Nothing technical lost. + +``` +┌────────────────────────────────────────────┐ +│ output tokens saved █████████ 65% │ +│ input tokens saved ░░░░░░░░░ 0% │ +│ technical accuracy █████████ 100% │ +│ vibes █████████ OOG │ +└────────────────────────────────────────────┘ +``` + +Caveman no make brain smaller. Caveman make *mouth* smaller. Shrinks what the agent **says**, not what it knows. + +## Install + +**One command. Finds every agent on your machine. Installs for each.** + +```bash +# macOS · Linux · WSL · Git Bash +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash +``` + +```powershell +# Windows · PowerShell 5.1+ +irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex +``` + +~30 seconds. Needs Node ≥18. Skips agents you no have. Safe to re-run. + +> [!TIP] +> **Turn it on:** type `/caveman` or say *"talk like caveman"*. **Turn it off:** say *"normal mode"*. On Claude Code, Codex, and Gemini it's already on from message one. No command needed. + +
+Install for one agent, or any of 30+ others + +
+ +Every agent has its own path (plugin, extension, rule file, or `npx skills add`). The full per-agent matrix, all flags, dry-run, and uninstall live in **[INSTALL.md](./INSTALL.md)**. A few common ones: + +```bash +# Claude Code plugin +claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman + +# Gemini CLI extension +gemini extensions install https://github.com/JuliusBrussee/caveman + +# Cursor / Windsurf / Cline / Codex / 30+ more, via the skills registry +npx skills add JuliusBrussee/caveman -a cursor +``` + +**Install broke?** Open your agent in this repo and say: *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent read repo, agent fix own brain. Snake eat tail. + +
+ +## Pick your grunt + +Six levels. Switch anytime with `/caveman `. Level sticks until you change it or the session ends. + +| Level | Same sentence, shrunk | +|---|---| +| *normal agent* | You should wrap the object in `useMemo`, since a new reference is created on every render. | +| `lite` | Wrap object in `useMemo`. New ref created every render. | +| `full` *(default)* | New ref each render. Wrap object in `useMemo`. | +| `ultra` | New ref/render. `useMemo` it. | +| `wenyan` | New ref every render, so wrap in `useMemo` — rendered in classical Chinese, shorter still. | + +> [!NOTE] +> **Speak your tongue.** Caveman keeps your language. Write Portuguese, caveman grunt Portuguese. Spanish, French, same. It compresses the *style*, never translates. `wenyan` mode is the exception on purpose: classical Chinese packs the most meaning per token. + +## What you get + +| Command | What it does | +|---|---| +| `/caveman [lite\|full\|ultra\|wenyan]` | Compress every reply. Level sticks for the session. | +| `/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 with `--share`. | +| `/caveman-compress ` | Rewrite a memory file (like `CLAUDE.md`) into caveman-speak. Cuts ~46% input tokens **every session after**. Code, URLs, paths byte-preserved. | +| `caveman-shrink` | MCP middleware. Wraps any MCP server, compresses its tool descriptions. [npm](https://www.npmjs.com/package/caveman-shrink). | +| `cavecrew-*` | Caveman subagents (investigator, builder, reviewer). ~60% fewer tokens than vanilla, so main context lasts longer. | + +> [!TIP] +> On Claude Code the statusline shows `[CAVEMAN] ⛏ 12.4k` — that's your lifetime tokens saved, updated on every `/caveman-stats`. Silence it with `CAVEMAN_STATUSLINE_SAVINGS=0`. + +## Benchmarks + +Real token counts from the Claude API. Average **65% output reduction** across 10 prompts (range 22–87%), measured against default verbose replies. Output tokens only, committed and reproducible in [`benchmarks/`](./benchmarks/) and [`evals/`](./evals/). + + +| Task | Normal | Caveman | Saved | +|------|-------:|--------:|------:| +| Explain React re-render bug | 1180 | 159 | 87% | +| Fix auth middleware token expiry | 704 | 121 | 83% | +| Set up PostgreSQL connection pool | 2347 | 380 | 84% | +| Explain git rebase vs merge | 702 | 292 | 58% | +| Refactor callback to async/await | 387 | 301 | 22% | +| Architecture: microservices vs monolith | 446 | 310 | 30% | +| Review PR for security issues | 678 | 398 | 41% | +| Docker multi-stage build | 1042 | 290 | 72% | +| Debug PostgreSQL race condition | 1200 | 232 | 81% | +| Implement React error boundary | 3454 | 456 | 87% | +| **Average** | **1214** | **294** | **65%** | + + +> [!IMPORTANT] +> **Honest number warning.** Caveman only shrinks **output** tokens. Input and reasoning tokens are untouched, and the skill itself adds ~1–1.5k input tokens per turn. So whole-session savings run smaller than the output number, and on already-terse workloads they can go net-negative. The real win is **readability and speed**. Cost savings are the bonus. When caveman wins, when it loses, and how to measure it yourself: **[docs/HONEST-NUMBERS.md](./docs/HONEST-NUMBERS.md)**. + +Turns out short isn't just cheaper. A March 2026 paper, [*Brevity Constraints Reverse Performance Hierarchies in Language Models*](https://arxiv.org/abs/2604.00025), tested 31 models and found that constraining large models to brief answers **improved accuracy by ~26 points** on some benchmarks. Sometimes less word = more correct. + +
+caveman-compress receipts — real memory files, cutting input tokens forever + +
+ +| File | Original | Compressed | Saved | +|---|---:|---:|---:| +| `claude-md-preferences.md` | 706 | 285 | **59.6%** | +| `project-notes.md` | 1145 | 535 | **53.3%** | +| `claude-md-project.md` | 1122 | 636 | **43.3%** | +| `todo-list.md` | 627 | 388 | **38.1%** | +| `mixed-with-code.md` | 888 | 560 | **36.9%** | +| **Average** | **898** | **481** | **46%** | + +Every session after, that file loads ~46% smaller. Input tokens saved forever, not just one reply. + +
+ +## The whole cave + + + +
+ +### Want the whole agent, not just its mouth? → caveman-code + +This skill shrinks what an agent **says**. **[caveman-code](https://github.com/JuliusBrussee/caveman-code)** shrinks **everything** — a full terminal coding agent, caveman top to bottom. **~2× fewer tokens than Codex** on identical tasks. 20+ providers, plan mode, autopilot goal loop, MIT. + +```bash +npm install -g @juliusbrussee/caveman-code +``` + +[**▶ Try caveman-code →**](https://github.com/JuliusBrussee/caveman-code) + +
+ +Five tools, one idea: **agent do more with less.** + +| Repo | What it shrinks | +|------|------| +| [**caveman**](https://github.com/JuliusBrussee/caveman) *(you here)* | What the agent **says** | +| [**caveman-code**](https://github.com/JuliusBrussee/caveman-code) | The **whole agent**, end to end | +| [**cavemem**](https://github.com/JuliusBrussee/cavemem) | What the agent **remembers**, across sessions | +| [**cavekit**](https://github.com/JuliusBrussee/cavekit) | The **build loop** — spec-driven, no guessing | +| [**cavegemma**](https://github.com/JuliusBrussee/finetune-caveman) | The compression **baked into weights** (Gemma fine-tune) | + +
+Also: five sibling skills, one install + +
+ +[**JuliusBrussee/skills**](https://github.com/JuliusBrussee/skills) — works in Claude Code, Cursor, Gemini, Cline, Copilot, 40+ agents: + +| Skill | What | +|------|------| +| [**caveman**](https://github.com/JuliusBrussee/skills/tree/main/skills/caveman) | This one. Speak less, say more. | +| [**grill-me**](https://github.com/JuliusBrussee/skills/tree/main/skills/grill-me) | Agent grills your plan *before* you build the wrong thing. | +| [**interface-kit**](https://github.com/JuliusBrussee/skills/tree/main/skills/interface-kit) | Build UI that looks good, loads fast, works for everyone. | +| [**junior-to-senior**](https://github.com/JuliusBrussee/skills/tree/main/skills/junior-to-senior) | Adversarial review pass. Junior output in, senior output out. | +| [**loop-factory**](https://github.com/JuliusBrussee/skills/tree/main/skills/loop-factory) | Spec-driven task loop — inbox → active → archive. | + +```bash +npx skills@latest add JuliusBrussee/skills +``` + +
+ +
+🦞 Teach the lobster brevity — OpenClaw integration + +
+ +[**OpenClaw**](https://openclaw.ai) is a self-host gateway: one box, many agents inside, wired to Slack / Discord / iMessage / Telegram. Lobster strong. Lobster smart. Lobster also talk a lot. + +Same installer, scoped to one agent: + +```bash +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --only openclaw +``` + +Two things happen, no more: a caveman skill lands in the workspace, and a tiny marker-fenced block is appended to `SOUL.md` (OpenClaw injects it every turn, so the lobster is terse from message one — no `/caveman` per session). Custom path? `OPENCLAW_WORKSPACE=/your/path`. Uninstall with the same line plus `--uninstall`; your other workspace content stays untouched. Lobster claw still sharp. Lobster mouth now small. + +
+ +## Caveman 2 + +**Caveman make token small. Caveman 2 make it _provable_.** + +Today's savings numbers (including `/caveman-stats`) are local estimates. Caveman 2 measures and verifies them across a whole team — real receipts, real dashboard, real proof the tokens went down. Building it now. + +[**Join the waitlist → caveman.so**](https://caveman.so) + +## How it works + +1. Install drops a skill file into your agent. +2. Skill tells agent: drop filler, keep substance, use fragments — but never touch code, commands, or errors. +3. On Claude Code, a hook writes a tiny flag file each session, so the agent talks caveman from message one without `/caveman`. +4. `/caveman-stats` reads your session log, counts tokens saved, writes the number to your statusline. +5. `/caveman-compress` rewrites memory files (like `CLAUDE.md`) so every future session starts with a smaller context. Save tokens forever, not just once. + +Hook architecture, file ownership, and CI sync are documented for maintainers in [CLAUDE.md](./CLAUDE.md). + +## Privacy + +Caveman no phone home. No telemetry, no analytics, no accounts, no backend. After install, zero network calls — the skill is a prompt, the hooks are local scripts, and `/caveman-stats` reads a log already on your disk. Install-time fetches (GitHub plus your agents' own registries) are spelled out in [SECURITY.md](./SECURITY.md#privacy--telemetry). + +## Sponsors + +Caveman free forever. Sponsors keep the rock sharp. + +

+ + + + Atlas Cloud + + +

+ +

+ Atlas Cloud — full-modal AI inference platform, one API. +

+ +

+ Want your rock here? → Sponsor caveman +

+ +## Star this repo + +Caveman save you token, save you money. Star cost zero. Fair trade. ⭐ + +[![Star History Chart](https://api.star-history.com/svg?repos=JuliusBrussee/caveman&type=Date)](https://star-history.com/#JuliusBrussee/caveman&Date) + +--- + + +Docs: +Install matrix · +Honest numbers · +Contributing · +Maintainer guide · +Issues +
+Also by Julius Brussee: +Revu — local-first macOS study app with FSRS spaced repetition (revu.cards) +

+MIT — free like mass mammoth on open plain. +
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..028570e --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`JuliusBrussee/caveman` +- 原始仓库:https://github.com/JuliusBrussee/caveman +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..056fd87 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security Policy + +## Supported Versions + +Only the latest stable release builds are supported with security patches. + +## Reporting a Vulnerability + +If you identify a security vulnerability in caveman (such as arbitrary shell execution, workspace folder escapes, token/credentials hijack via prompts, or malicious JSON parsing flaws in extension settings), please do **not** open a public issue. + +Please report vulnerabilities privately by emailing the maintainers or using [GitHub's private vulnerability reporting](https://github.com/JuliusBrussee/caveman/security/advisories/new). + +## Privacy & Telemetry + +**Caveman has no telemetry. Zero.** No analytics, no crash reporting, no phone-home, no accounts, no API keys collected. There is no caveman backend — nothing to send data to. + +### After install: zero network calls + +Once installed, nothing in caveman touches the network. Verified against the code (audit it yourself — every file is in this repo): + +- **The skill itself** (`skills/caveman/SKILL.md`) is a markdown prompt. It contains no code. +- **The hooks** (`src/hooks/*.js`, statusline scripts) are local Node/shell scripts. They read and write local files only (flag file, session log, statusline savings file). No `http`/`https`/`fetch` anywhere in them. +- **`/caveman-stats`** reads Claude Code's session JSONL from your local disk and prints counts. USD figures come from pricing constants hardcoded in the script. Nothing leaves your machine. +- **`caveman-shrink`** (MCP middleware) spawns the MCP server *you* configure, locally, and compresses its output in-process. It makes no network calls of its own; any network activity belongs to the server you wrapped. +- **`/caveman-compress`** rewrites a local file you name and saves a `.original.md` backup next to it. Local file I/O only. + +### At install time: exactly these network requests, nothing else + +- `curl … install.sh | bash` (or `irm … install.ps1 | iex`) fetches the shim from raw.githubusercontent.com, which delegates to `npx -y github:JuliusBrussee/caveman` — npm fetches this repo from GitHub. +- The installer shells out to per-agent CLIs which fetch from their own registries: `claude plugin marketplace add` / `claude plugin install` (Anthropic/GitHub), `gemini extensions install`, `npm view caveman-shrink`, `npx -y skills add` (npm). +- **Rare fallback:** if the installer runs detached from a repo checkout, it downloads the hook files from raw.githubusercontent.com **pinned to an immutable release tag** and verifies each against a published SHA-256 manifest before wiring anything (a mismatch aborts). From a normal clone or npx run, files are copied locally — offline installs work. + +Nothing is uploaded in any of these steps. Details and the full list of paths written: [INSTALL.md → Privacy](./INSTALL.md#privacy). + +### What stays on your machine + +Everything. Skill/rule files in your agents' config dirs, the mode flag file and merged `settings.json` under `~/.claude/` (or `$CLAUDE_CONFIG_DIR`), the lifetime-savings statusline file, and `.original.md` backups from `/caveman-compress`. Uninstall removes what the installer wrote: `npx -y github:JuliusBrussee/caveman -- --uninstall`. + +### Enterprise / air-gapped use + +Caveman is self-contained after install and fully functional offline. There is no license server, no external backend, and no data flow to audit beyond the install-time fetches above. For air-gapped environments, clone the repo internally and run the installer from the clone — no network needed. + +## About scanner warnings + +- **Windows Defender / SmartScreen on `install.ps1` (#383):** piping a script from the internet into `iex` and writing into agent config directories matches generic dropper heuristics, so AV tools may warn. The script is short and readable in this repo; the hook files it installs are SHA-256-verified against the pinned release manifest. If you'd rather not pipe-to-shell, clone the repo and run `node bin/install.js` — same result, fully inspectable first. +- **Snyk "High Risk" on `caveman-compress` (#28):** the compress skill instructs the agent to read a file you name, rewrite it in place, and save a backup. In-place file rewriting is exactly what generic risk scoring flags. It is a real capability, not hidden — but there is no network access, no shell execution beyond what's documented in [`skills/caveman-compress/`](./skills/caveman-compress/), and it never touches files you didn't name. diff --git a/agents/cavecrew-builder.md b/agents/cavecrew-builder.md new file mode 100644 index 0000000..65750df --- /dev/null +++ b/agents/cavecrew-builder.md @@ -0,0 +1,47 @@ +--- +name: cavecrew-builder +description: > + Surgical 1-2 file edit. Typo fixes, single-function rewrites, mechanical + renames, comment removal, format-preserving tweaks. Hard refuses 3+ file + scope. Returns caveman diff receipt. Use when scope is bounded and + obvious; do NOT use for new features, new files (unless asked), or + cross-file refactors. +tools: [Read, Edit, Write, Grep, Glob] +--- + +Caveman-ultra. Drop articles/filler. Code/paths exact, backticked. No narration. + +## Scope + +1 file ideal. 2 OK. 3+ → refuse. +Edit existing only (new file iff user asked). +No new abstractions. No drive-by refactors. No comment additions. +No `Bash` available — cannot shell out, cannot push, cannot delete. + +## Workflow + +1. `Read` target(s). Never edit blind. +2. `Edit` smallest diff that work. +3. Re-`Read` to verify. +4. Return receipt. + +## Output (receipt) + +``` +. +. +verified: . +``` + +Diff is the artifact. Receipt is the proof. No exploration story. + +## Refusals (terminal lines) + +3+ files → `too-big. split: .` +Destructive needed → `needs-confirm. op: .` +Spec ambiguous → `ambiguous. ask: .` +Tests fail post-edit, can't fix in scope → `regressed. revert path:line. cause: .` + +## Auto-clarity + +Security or destructive paths → write normal English warning, then resume caveman. diff --git a/agents/cavecrew-investigator.md b/agents/cavecrew-investigator.md new file mode 100644 index 0000000..be27498 --- /dev/null +++ b/agents/cavecrew-investigator.md @@ -0,0 +1,57 @@ +--- +name: cavecrew-investigator +description: > + Read-only code locator. Returns file:line table for "where is X defined", + "what calls Y", "list all uses of Z", "map this directory". Output is + caveman-compressed so the main thread eats ~60% fewer tokens than + vanilla Explore. Refuses to suggest fixes. +tools: [Read, Grep, Glob, Bash] +model: haiku +--- + +Caveman-ultra. Drop articles/filler/hedging. Code/symbols/paths exact, backticked. Lead with answer. + +## Job + +Locate. Report. Stop. Never edit, never propose fix. + +## Output + +``` + — `` — <≤6 word note> + — `` — <≤6 word note> +``` + +Group with one-word header when 3+ rows: `Defs:` / `Refs:` / `Callers:` / `Tests:` / `Imports:` / `Sites:`. +Single hit → one line, no header. +Zero hits → `No match.` +Last line → totals: `2 defs, 5 refs.` (omit if 0 or 1). + +## Tools + +`Grep` for symbols/strings. `Glob` for paths. `Read` only specific ranges. `Bash` for `git log -S`/`git grep`/`find` when faster. + +## Refusals + +Asked to fix → `Read-only. Spawn cavecrew-builder.` +Asked to design → `Read-only. Spawn cavecrew-builder or use main thread.` + +## Auto-clarity + +Security warnings, destructive ops → write normal English. Resume after. + +## Example + +Q: "where symlink-safe flag write?" + +``` +Defs: +- hooks/caveman-config.js:81 — `safeWriteFlag` — atomic write w/ O_NOFOLLOW +- hooks/caveman-config.js:160 — `readFlag` — paired reader +Callers: +- hooks/caveman-mode-tracker.js:33,87 +- hooks/caveman-activate.js:40 +Tests: +- tests/test_symlink_flag.js — 12 cases +2 defs, 3 callers, 1 test file. +``` diff --git a/agents/cavecrew-reviewer.md b/agents/cavecrew-reviewer.md new file mode 100644 index 0000000..97049ce --- /dev/null +++ b/agents/cavecrew-reviewer.md @@ -0,0 +1,48 @@ +--- +name: cavecrew-reviewer +description: > + Diff/branch/file reviewer. One line per finding, severity-tagged, no praise, + no scope creep. Output format `path:line: : . .` + Use for "review this PR", "review my diff", "audit this file". Skips + formatting nits unless they change meaning. +tools: [Read, Grep, Bash] +model: haiku +--- + +Caveman-ultra. Findings only. No "looks good", no "I'd suggest", no preamble. + +## Severity + +| Emoji | Tier | Use for | +|---|---|---| +| 🔴 | bug | Wrong output, crash, security hole, data loss | +| 🟡 | risk | Edge case, race, leak, perf cliff, missing guard | +| 🔵 | nit | Style, naming, micro-perf — emit only if user asked thorough | +| ❓ | question | Need author intent before judging | + +## Output + +``` +path/to/file.ts:42: 🔴 bug: token expiry uses `<` not `<=`. Off-by-one allows expired tokens 1 tick. +path/to/file.ts:118: 🟡 risk: pool not closed on error path. Add `try/finally`. +src/utils.ts:7: ❓ question: why duplicate `.trim()` here? +totals: 1🔴 1🟡 1❓ +``` + +Zero findings → `No issues.` +File order, ascending line numbers within file. + +## Boundaries + +- Review only what's in front of you. No "while we're here". +- No big-refactor proposals. +- Need more context → append `(see L in )`. Don't guess. +- Formatting nits skipped unless they change meaning. + +## Tools + +`Bash` only for `git diff`/`git log -p`/`git show`. No mutating commands. + +## Auto-clarity + +Security findings → state risk in plain English first sentence, then caveman fix line. diff --git a/benchmarks/prompts.json b/benchmarks/prompts.json new file mode 100644 index 0000000..1db983c --- /dev/null +++ b/benchmarks/prompts.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "prompts": [ + { + "id": "react-rerender", + "category": "debugging", + "prompt": "Why is my React component re-rendering on every state update even though the props haven't changed? I'm passing an object as a prop." + }, + { + "id": "auth-middleware-fix", + "category": "bugfix", + "prompt": "My Express auth middleware is letting expired JWT tokens through. The expiry check uses Date.now() compared to the token's exp field. What's wrong and how do I fix it?" + }, + { + "id": "postgres-pool", + "category": "setup", + "prompt": "How do I set up a PostgreSQL connection pool in Node.js with proper timeout and error handling configuration?" + }, + { + "id": "git-rebase-merge", + "category": "explanation", + "prompt": "Explain the difference between git rebase and git merge. When should I use each one and what are the tradeoffs?" + }, + { + "id": "async-refactor", + "category": "refactor", + "prompt": "Refactor this callback-based Node.js function to use async/await:\n\nfunction getUser(id, callback) {\n db.query('SELECT * FROM users WHERE id = ?', [id], function(err, rows) {\n if (err) return callback(err);\n if (!rows.length) return callback(new Error('Not found'));\n callback(null, rows[0]);\n });\n}" + }, + { + "id": "microservices-monolith", + "category": "architecture", + "prompt": "We have a monolithic Django app that's getting slow. The team is debating microservices. What are the key factors to consider before splitting up the monolith?" + }, + { + "id": "pr-security-review", + "category": "code-review", + "prompt": "Review this Express route handler for security issues:\n\napp.get('/api/users/:id', (req, res) => {\n const query = `SELECT * FROM users WHERE id = ${req.params.id}`;\n db.query(query).then(user => res.json(user));\n});" + }, + { + "id": "docker-multi-stage", + "category": "devops", + "prompt": "Write a multi-stage Dockerfile for a Node.js TypeScript application that minimizes the final image size. The app uses npm and needs to compile TypeScript before running." + }, + { + "id": "race-condition-debug", + "category": "debugging", + "prompt": "My Node.js API endpoint that increments a counter in PostgreSQL sometimes returns the same value for concurrent requests. How do I fix this race condition?" + }, + { + "id": "error-boundary", + "category": "implementation", + "prompt": "Implement a React error boundary component that catches render errors, shows a fallback UI with a retry button, and logs the error details." + } + ] +} diff --git a/benchmarks/requirements.txt b/benchmarks/requirements.txt new file mode 100644 index 0000000..2db0540 --- /dev/null +++ b/benchmarks/requirements.txt @@ -0,0 +1 @@ +anthropic>=0.40.0 diff --git a/benchmarks/results/.gitkeep b/benchmarks/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 0000000..3a2c2c8 --- /dev/null +++ b/benchmarks/run.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Benchmark caveman vs normal Claude output token counts.""" + +import argparse +import hashlib +import json +import os +import statistics +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import anthropic + +# The only env var this benchmark needs: the anthropic SDK reads it in +# anthropic.Anthropic(). Read it — and ONLY it — from repo-root .env.local. +# Deliberately narrow (issue #528): the old loader setdefault'ed EVERY key in +# .env.local into os.environ, which security scanners rightly flag as an +# exfiltration surface. Nothing else from the file is ever read or exported. +_API_KEY_VAR = "ANTHROPIC_API_KEY" + +_env_file = Path(__file__).parent.parent / ".env.local" +if _API_KEY_VAR not in os.environ and _env_file.exists(): + for line in _env_file.read_text().splitlines(): + line = line.strip() + if line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() == _API_KEY_VAR: + os.environ.setdefault(_API_KEY_VAR, value.strip()) + break + +SCRIPT_VERSION = "1.0.0" +SCRIPT_DIR = Path(__file__).parent +REPO_DIR = SCRIPT_DIR.parent +PROMPTS_PATH = SCRIPT_DIR / "prompts.json" +SKILL_PATH = REPO_DIR / "skills" / "caveman" / "SKILL.md" +README_PATH = REPO_DIR / "README.md" +RESULTS_DIR = SCRIPT_DIR / "results" + +NORMAL_SYSTEM = "You are a helpful assistant." +BENCHMARK_START = "" +BENCHMARK_END = "" + + +def load_prompts(): + with open(PROMPTS_PATH) as f: + data = json.load(f) + return data["prompts"] + + +def load_caveman_system(): + return SKILL_PATH.read_text() + + +def sha256_file(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def call_api(client, model, system, prompt, max_retries=3): + delays = [5, 10, 20] + for attempt in range(max_retries + 1): + try: + response = client.messages.create( + model=model, + max_tokens=4096, + temperature=0, + system=system, + messages=[{"role": "user", "content": prompt}], + ) + return { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + "text": response.content[0].text, + "stop_reason": response.stop_reason, + } + except anthropic.RateLimitError: + if attempt < max_retries: + delay = delays[min(attempt, len(delays) - 1)] + print(f" Rate limited, retrying in {delay}s...", file=sys.stderr) + time.sleep(delay) + else: + raise + + +def run_benchmarks(client, model, prompts, caveman_system, trials): + results = [] + total = len(prompts) + + for i, prompt_entry in enumerate(prompts, 1): + pid = prompt_entry["id"] + prompt_text = prompt_entry["prompt"] + entry = { + "id": pid, + "category": prompt_entry["category"], + "prompt": prompt_text, + "normal": [], + "caveman": [], + } + + for mode, system in [("normal", NORMAL_SYSTEM), ("caveman", caveman_system)]: + for t in range(1, trials + 1): + print( + f" [{i}/{total}] {pid} | {mode} | trial {t}/{trials}", + file=sys.stderr, + ) + result = call_api(client, model, system, prompt_text) + entry[mode].append(result) + time.sleep(0.5) + + results.append(entry) + + return results + + +def compute_stats(results): + rows = [] + all_savings = [] + + for entry in results: + normal_medians = statistics.median( + [t["output_tokens"] for t in entry["normal"]] + ) + caveman_medians = statistics.median( + [t["output_tokens"] for t in entry["caveman"]] + ) + savings = 1 - (caveman_medians / normal_medians) if normal_medians > 0 else 0 + all_savings.append(savings) + + rows.append( + { + "id": entry["id"], + "category": entry["category"], + "prompt": entry["prompt"], + "normal_median": int(normal_medians), + "caveman_median": int(caveman_medians), + "savings_pct": round(savings * 100), + } + ) + + avg_savings = round(statistics.mean(all_savings) * 100) + min_savings = round(min(all_savings) * 100) + max_savings = round(max(all_savings) * 100) + avg_normal = round(statistics.mean([r["normal_median"] for r in rows])) + avg_caveman = round(statistics.mean([r["caveman_median"] for r in rows])) + + return rows, { + "avg_savings": avg_savings, + "min_savings": min_savings, + "max_savings": max_savings, + "avg_normal": avg_normal, + "avg_caveman": avg_caveman, + } + + +def format_prompt_label(prompt_id): + labels = { + "react-rerender": "Explain React re-render bug", + "auth-middleware-fix": "Fix auth middleware token expiry", + "postgres-pool": "Set up PostgreSQL connection pool", + "git-rebase-merge": "Explain git rebase vs merge", + "async-refactor": "Refactor callback to async/await", + "microservices-monolith": "Architecture: microservices vs monolith", + "pr-security-review": "Review PR for security issues", + "docker-multi-stage": "Docker multi-stage build", + "race-condition-debug": "Debug PostgreSQL race condition", + "error-boundary": "Implement React error boundary", + } + return labels.get(prompt_id, prompt_id) + + +def format_table(rows, summary): + lines = [ + "| Task | Normal (tokens) | Caveman (tokens) | Saved |", + "|------|---------------:|----------------:|------:|", + ] + for r in rows: + label = format_prompt_label(r["id"]) + lines.append( + f"| {label} | {r['normal_median']} | {r['caveman_median']} | {r['savings_pct']}% |" + ) + lines.append( + f"| **Average** | **{summary['avg_normal']}** | **{summary['avg_caveman']}** | **{summary['avg_savings']}%** |" + ) + lines.append("") + lines.append( + f"*Range: {summary['min_savings']}%–{summary['max_savings']}% savings across prompts.*" + ) + return "\n".join(lines) + + +def save_results(results, rows, summary, model, trials, skill_hash): + ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + output = { + "metadata": { + "script_version": SCRIPT_VERSION, + "model": model, + "date": datetime.now(timezone.utc).isoformat(), + "trials": trials, + "skill_md_sha256": skill_hash, + }, + "summary": summary, + "rows": rows, + "raw": results, + } + path = RESULTS_DIR / f"benchmark_{ts}.json" + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(output, f, indent=2) + return path + + +def update_readme(table_md): + content = README_PATH.read_text() + start_idx = content.find(BENCHMARK_START) + end_idx = content.find(BENCHMARK_END) + if start_idx == -1 or end_idx == -1: + print( + "ERROR: Benchmark markers not found in README.md", + file=sys.stderr, + ) + sys.exit(1) + + before = content[: start_idx + len(BENCHMARK_START)] + after = content[end_idx:] + new_content = before + "\n" + table_md + "\n" + after + README_PATH.write_text(new_content) + print("README.md updated.", file=sys.stderr) + + +def dry_run(prompts, model, trials): + print(f"Model: {model}") + print(f"Trials: {trials}") + print(f"Prompts: {len(prompts)}") + print(f"Total API calls: {len(prompts) * 2 * trials}") + print() + for p in prompts: + print(f" [{p['id']}] ({p['category']})") + preview = p["prompt"][:80] + if len(p["prompt"]) > 80: + preview += "..." + print(f" {preview}") + print() + print("Dry run complete. No API calls made.") + + +def main(): + parser = argparse.ArgumentParser(description="Benchmark caveman vs normal Claude") + parser.add_argument("--trials", type=int, default=3, help="Trials per prompt per mode (default: 3)") + parser.add_argument("--dry-run", action="store_true", help="Print config, no API calls") + parser.add_argument("--update-readme", action="store_true", help="Update README.md benchmark table") + parser.add_argument("--model", default="claude-sonnet-4-20250514", help="Model to use") + args = parser.parse_args() + + prompts = load_prompts() + + if args.dry_run: + dry_run(prompts, args.model, args.trials) + return + + caveman_system = load_caveman_system() + skill_hash = sha256_file(SKILL_PATH) + + client = anthropic.Anthropic() + + print(f"Running benchmarks: {len(prompts)} prompts x 2 modes x {args.trials} trials", file=sys.stderr) + print(f"Model: {args.model}", file=sys.stderr) + print(file=sys.stderr) + + results = run_benchmarks(client, args.model, prompts, caveman_system, args.trials) + rows, summary = compute_stats(results) + table_md = format_table(rows, summary) + + json_path = save_results(results, rows, summary, args.model, args.trials, skill_hash) + print(f"\nResults saved to {json_path}", file=sys.stderr) + + if args.update_readme: + update_readme(table_md) + + print(table_md) + + +if __name__ == "__main__": + main() diff --git a/bin/install.js b/bin/install.js new file mode 100755 index 0000000..3e0fffc --- /dev/null +++ b/bin/install.js @@ -0,0 +1,1530 @@ +#!/usr/bin/env node +// caveman — unified cross-platform installer. +// +// One Node script replaces the old install.sh + install.ps1 + src/hooks/install.sh +// + src/hooks/install.ps1 quartet. Single source of truth. Works on macOS, Linux, +// and Windows (PowerShell or cmd) without any of the bash/PS1 quoting bugs +// that previously broke the JSON merge step (issue #249). +// +// Distribution: +// Local clone: node bin/install.js [flags] +// curl|bash: delegated from install.sh shim → npx -y github:JuliusBrussee/caveman -- [flags] +// Windows: pwsh install.ps1 [flags] → same npx delegation +// +// Pure stdlib, zero npm runtime deps. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const child_process = require('child_process'); +const readline = require('readline'); +const crypto = require('crypto'); + +const SETTINGS = require('./lib/settings'); +const OPENCLAW = require('./lib/openclaw'); +const { stripOpencodeAgentTools } = require('./lib/opencode-agent'); + +const REPO = 'JuliusBrussee/caveman'; +// Pin remote fetches to an immutable release tag, not the moving `main` +// branch (issue #261). A push to main must never silently change what a +// curl|bash / detached-script install downloads and executes. Bump this to +// the new tag on every release (CI release step) AFTER regenerating +// src/hooks/checksums.sha256 so the integrity manifest matches the ref. +// Overridable via CAVEMAN_REF for testing against a branch. +const PINNED_REF = process.env.CAVEMAN_REF || 'v1.9.1'; +const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${PINNED_REF}`; +const HOOKS_REMOTE = `${RAW_BASE}/src/hooks`; +const INIT_SCRIPT_URL = `${RAW_BASE}/src/tools/caveman-init.js`; +const MCP_SHRINK_PKG = 'caveman-shrink'; +// Hook files to copy. Statusline ships in both .sh (macOS/Linux) and .ps1 +// (Windows) flavors — copy both regardless of host OS so a roaming +// $CLAUDE_CONFIG_DIR (e.g. dotfiles repo) keeps working across platforms. +const HOOK_FILES = [ + 'package.json', + 'caveman-config.js', + 'caveman-activate.js', + 'caveman-mode-tracker.js', + 'caveman-stats.js', + 'caveman-statusline.sh', + 'caveman-statusline.ps1', + 'cavecrew-model-overrides.js', +]; + +// ── Argv ─────────────────────────────────────────────────────────────────── +function parseArgs(argv) { + const opts = { + dryRun: false, force: false, skipSkills: false, + withHooks: 'auto', withInit: false, withMcpShrink: false, + all: false, minimal: false, listOnly: false, noColor: false, + only: [], uninstall: false, nonInteractive: false, + configDir: null, help: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + // --with-mcp-shrink= (handled before the switch so the + // GNU-style =value form is recognized). Bare --with-mcp-shrink falls + // through to the switch and is rejected — caveman-shrink is a proxy + // and a stub registration just lands the user in a broken-MCP loop (#474). + if (a.startsWith('--with-mcp-shrink=')) { + const raw = a.slice('--with-mcp-shrink='.length); + const tokens = raw.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + die('error: --with-mcp-shrink requires an upstream command\n' + + ' example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"'); + } + opts.withMcpShrink = tokens; + continue; + } + switch (a) { + case '--dry-run': opts.dryRun = true; break; + case '--force': opts.force = true; break; + case '--skip-skills': opts.skipSkills = true; break; + case '--with-hooks': opts.withHooks = true; break; + case '--no-hooks': opts.withHooks = false; break; + case '--with-init': opts.withInit = true; break; + case '--with-mcp-shrink': { + const v = argv[i + 1]; + if (v && !v.startsWith('--')) { + i++; + const tokens = v.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + die('error: --with-mcp-shrink requires an upstream command\n' + + ' example: --with-mcp-shrink "npx @modelcontextprotocol/server-filesystem /path"'); + } + opts.withMcpShrink = tokens; + } else { + die('error: --with-mcp-shrink requires an upstream command — caveman-shrink\n' + + ' is a proxy and exits immediately without one. Pass the upstream:\n' + + ' --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /path"'); + } + break; + } + case '--no-mcp-shrink': opts.withMcpShrink = false; break; + case '--all': opts.all = true; break; + case '--minimal': opts.minimal = true; break; + case '--list': opts.listOnly = true; break; + case '--no-color': opts.noColor = true; break; + case '--uninstall': case '-u': opts.uninstall = true; break; + case '--non-interactive': opts.nonInteractive = true; break; + case '-h': case '--help': opts.help = true; break; + // POSIX end-of-options marker. Older curl|bash flows pipe `-- --only foo` + // through npx; some npx versions forward the literal `--`. Accept and + // ignore so we never regress on the headline install command. + case '--': break; + case '--only': { + const v = argv[++i]; + if (!v) die('error: --only requires an argument'); + opts.only.push(v === 'aider' ? 'aider-desk' : v); + break; + } + case '--config-dir': { + const v = argv[++i]; + if (!v || v.startsWith('--')) die('error: --config-dir requires a path'); + opts.configDir = expandHome(v); + break; + } + default: + die(`error: unknown flag: ${a}\nrun 'caveman --help' for usage`); + } + } + if (opts.all && opts.minimal) die('error: --all and --minimal are mutually exclusive'); + // --all turns on per-repo init only. It deliberately does NOT force: + // • withHooks — left at 'auto' so installClaude() can skip standalone + // settings.json wiring when the plugin manifest already wires the hooks + // (duplicate registration fires both per event — issue #392). + // • withMcpShrink — caveman-shrink is a proxy that needs an upstream + // command, so there's no sensible "everything on" default (issue #474). + // Opt in explicitly with --with-mcp-shrink="". + if (opts.all) { opts.withInit = true; } + if (opts.minimal) { opts.withHooks = false; opts.withInit = false; opts.withMcpShrink = false; } + // Validate --only ids against the provider matrix. PROVIDERS is defined later + // in the file but is in scope by the time this function runs. + if (opts.only.length) { + const knownIds = new Set(PROVIDERS.map(p => p.id)); + for (const id of opts.only) { + if (!knownIds.has(id)) { + die(`error: unknown agent: ${id}\n see 'caveman --list' for valid ids`); + } + } + } + return opts; +} + +function die(msg) { process.stderr.write(msg + '\n'); process.exit(2); } + +// ── Color helpers ────────────────────────────────────────────────────────── +function makeChalk(noColor) { + const useColor = !noColor && process.stdout.isTTY && !process.env.NO_COLOR; + const wrap = (codes) => (s) => useColor ? `\x1b[${codes}m${s}\x1b[0m` : s; + return { + orange: wrap('38;5;172'), dim: wrap('2'), red: wrap('31'), + green: wrap('32'), yellow: wrap('33'), + }; +} + +// ── Env guards ───────────────────────────────────────────────────────────── +function checkWslWindowsNode() { + if (process.platform !== 'win32') return; + // Windows-Node executing inside WSL has homedir like /mnt/c/Users/... which + // breaks every config-dir resolution. Detect and abort with a clear hint. + if (process.env.WSL_DISTRO_NAME) { + die('caveman: detected Windows Node.js running inside WSL.\n' + + ' Install Linux-native Node inside your WSL distro and re-run there.\n' + + ' (WSL_DISTRO_NAME=' + process.env.WSL_DISTRO_NAME + ')'); + } + try { + const v = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); + if (v.includes('microsoft') || v.includes('wsl')) { + die('caveman: detected Windows Node.js running inside WSL (/proc/version).\n' + + ' Install Linux-native Node inside your WSL distro and re-run there.'); + } + } catch (_) { /* /proc/version absent on real Windows — fine */ } +} + +function checkNodeVersion() { + const major = parseInt(process.versions.node.split('.')[0], 10); + if (major < 18) die(`caveman: Node ${process.versions.node} too old. Need Node ≥18. https://nodejs.org`); +} + +// ── Provider matrix ──────────────────────────────────────────────────────── +// Single source of truth. Replaces the 6 parallel bash arrays in old install.sh. +// +// Detection rules: +// - `command:` — bin on PATH. Most reliable signal. +// - `vscode-ext:` / `cursor-ext:` — extension dir name match. +// - `jetbrains-plugin:` — JetBrains plugin dir match. +// - `dir:` / `file:` — kept ONLY for agents that ship no CLI +// and no extension marker (true dir-only signal). +// +// `soft: true` means detection is best-effort (config-dir only or no +// reliable probe). Soft providers are EXCLUDED from auto-detect and only +// install when the user passes `--only `. This stops the installer from +// firing `npx skills add ...` against agents the user has never installed +// just because some other tool created `~/.foo` along the way. +const PROVIDERS = [ + { id: 'claude', label: 'Claude Code', mech: 'claude plugin install', detect: 'command:claude' }, + { id: 'gemini', label: 'Gemini CLI', mech: 'gemini extensions install', detect: 'command:gemini' }, + { id: 'opencode', label: 'opencode', mech: 'native opencode plugin', detect: 'command:opencode' }, + { id: 'openclaw', label: 'OpenClaw', mech: 'workspace skill + SOUL.md', detect: 'command:openclaw||dir:$HOME/.openclaw/workspace' }, + { id: 'codex', label: 'Codex CLI', mech: 'npx skills add (codex)', detect: 'command:codex', profile: 'codex' }, + + // IDE / VS Code-family — extension probes are precise. Cursor/Windsurf also + // ship CLI binaries; we drop the dir fallback because the dir lingers after + // uninstall and false-positives heavily. + { id: 'cursor', label: 'Cursor', mech: 'npx skills add (cursor)', detect: 'command:cursor||macapp:Cursor', profile: 'cursor' }, + { id: 'windsurf', label: 'Windsurf', mech: 'npx skills add (windsurf)', detect: 'command:windsurf||macapp:Windsurf', profile: 'windsurf' }, + { id: 'cline', label: 'Cline', mech: 'npx skills add (cline)', detect: 'vscode-ext:cline', profile: 'cline' }, + { id: 'continue', label: 'Continue', mech: 'npx skills add (continue)', detect: 'vscode-ext:continue.continue||vscode-ext:continue', profile: 'continue' }, + { id: 'kilo', label: 'Kilo Code', mech: 'npx skills add (kilo)', detect: 'vscode-ext:kilocode', profile: 'kilo' }, + { id: 'roo', label: 'Roo Code', mech: 'npx skills add (roo)', detect: 'vscode-ext:roo||vscode-ext:rooveterinaryinc.roo-cline||cursor-ext:roo', profile: 'roo' }, + { id: 'augment', label: 'Augment Code', mech: 'npx skills add (augment)', detect: 'vscode-ext:augment||jetbrains-plugin:augment', profile: 'augment' }, + + // GitHub Copilot: detected via VS Code / Cursor extension dirs (no `gh` CLI + // needed). The old `command:copilot` soft probe never fired for most users + // because Copilot ships as an editor extension, not a CLI (issue #336). + { id: 'copilot', label: 'GitHub Copilot', mech: 'npx skills add (github-copilot)', detect: 'vscode-ext:github.copilot||vscode-ext:github.copilot-chat||cursor-ext:github.copilot', profile: 'github-copilot' }, + + // CLI agents — require the binary. The `||dir:~/.foo` fallbacks were the + // main source of false positives (warp, kiro, junie etc. leave config dirs + // behind on uninstall). + { id: 'hermes', label: 'Hermes Agent', mech: 'native hermes skills copy', detect: 'command:hermes' }, + { id: 'aider-desk', label: 'Aider Desk', mech: 'npx skills add (aider-desk)', detect: 'command:aider', profile: 'aider-desk' }, + { id: 'amp', label: 'Sourcegraph Amp', mech: 'npx skills add (amp)', detect: 'command:amp', profile: 'amp' }, + { id: 'bob', label: 'IBM Bob', mech: 'npx skills add (bob)', detect: 'command:bob', profile: 'bob' }, + { id: 'crush', label: 'Crush', mech: 'npx skills add (crush)', detect: 'command:crush', profile: 'crush' }, + { id: 'devin', label: 'Devin (terminal)', mech: 'npx skills add (devin)', detect: 'command:devin', profile: 'devin' }, + { id: 'droid', label: 'Droid (Factory)', mech: 'npx skills add (droid)', detect: 'command:droid', profile: 'droid' }, + { id: 'forgecode', label: 'ForgeCode', mech: 'npx skills add (forgecode)', detect: 'command:forge', profile: 'forgecode' }, + { id: 'goose', label: 'Block Goose', mech: 'npx skills add (goose)', detect: 'command:goose', profile: 'goose' }, + { id: 'iflow', label: 'iFlow CLI', mech: 'npx skills add (iflow-cli)', detect: 'command:iflow', profile: 'iflow-cli' }, + { id: 'kiro', label: 'Kiro CLI', mech: 'npx skills add (kiro-cli)', detect: 'command:kiro', profile: 'kiro-cli' }, + { id: 'mistral', label: 'Mistral Vibe', mech: 'npx skills add (mistral-vibe)', detect: 'command:mistral', profile: 'mistral-vibe' }, + { id: 'openhands', label: 'OpenHands', mech: 'npx skills add (openhands)', detect: 'command:openhands', profile: 'openhands' }, + { id: 'qwen', label: 'Qwen Code', mech: 'npx skills add (qwen-code)', detect: 'command:qwen', profile: 'qwen-code' }, + { id: 'rovodev', label: 'Atlassian Rovo Dev', mech: 'npx skills add (rovodev)', detect: 'command:rovodev', profile: 'rovodev' }, + { id: 'tabnine', label: 'Tabnine CLI', mech: 'npx skills add (tabnine-cli)', detect: 'command:tabnine', profile: 'tabnine-cli' }, + { id: 'trae', label: 'Trae', mech: 'npx skills add (trae)', detect: 'command:trae', profile: 'trae' }, + { id: 'warp', label: 'Warp', mech: 'npx skills add (warp)', detect: 'command:warp', profile: 'warp' }, + { id: 'replit', label: 'Replit Agent', mech: 'npx skills add (replit)', detect: 'command:replit', profile: 'replit' }, + + // Soft (opt-in via --only) — no reliable always-on probe. + // junie: ships only as a JetBrains plugin; jetbrains-plugin probe walks + // ~/.config/JetBrains looking for "junie" — fires on stale plugin caches. + // qoder: dir-only. + // antigravity: lives at ~/.gemini/antigravity which is created by the + // gemini CLI on first use — not a reliable signal of antigravity itself. + { id: 'junie', label: 'JetBrains Junie', mech: 'npx skills add (junie)', detect: 'jetbrains-plugin:junie', profile: 'junie', soft: true }, + { id: 'qoder', label: 'Qoder', mech: 'npx skills add (qoder)', detect: 'dir:$HOME/.qoder', profile: 'qoder', soft: true }, + { id: 'antigravity',label: 'Google Antigravity', mech: 'npx skills add (antigravity)', detect: 'dir:$HOME/.gemini/antigravity', profile: 'antigravity', soft: true }, +]; + +// ── Detection ───────────────────────────────────────────────────────────── +function hasCmd(cmd) { + try { + if (process.platform === 'win32') { + const r = child_process.spawnSync('where', [cmd], { stdio: 'ignore' }); + return r.status === 0; + } + const r = child_process.spawnSync('sh', ['-c', `command -v ${shellEscape(cmd)}`], { stdio: 'ignore' }); + return r.status === 0; + } catch (_) { return false; } +} + +function shellEscape(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } + +function expandHome(p) { return p.replace(/^\$HOME/, os.homedir()).replace(/^~/, os.homedir()); } + +function vscodeExtPresent(needle) { + const home = os.homedir(); + const roots = [ + path.join(home, '.vscode/extensions'), + path.join(home, '.vscode-server/extensions'), + path.join(home, '.cursor/extensions'), + path.join(home, '.windsurf/extensions'), + ]; + const re = new RegExp(needle, 'i'); + for (const r of roots) { + if (!fs.existsSync(r)) continue; + let entries; + try { entries = fs.readdirSync(r); } catch (_) { continue; } + if (entries.some(e => re.test(e))) return true; + } + return false; +} + +function cursorExtPresent(needle) { + const dir = path.join(os.homedir(), '.cursor/extensions'); + if (!fs.existsSync(dir)) return false; + const re = new RegExp(needle, 'i'); + try { return fs.readdirSync(dir).some(e => re.test(e)); } catch (_) { return false; } +} + +function jetbrainsPresent() { + const home = os.homedir(); + return fs.existsSync(path.join(home, 'Library/Application Support/JetBrains')) + || fs.existsSync(path.join(home, '.config/JetBrains')); +} + +function jetbrainsPluginPresent(needle) { + const home = os.homedir(); + const roots = [ + path.join(home, 'Library/Application Support/JetBrains'), + path.join(home, '.config/JetBrains'), + ]; + const re = new RegExp(needle, 'i'); + for (const r of roots) { + if (!fs.existsSync(r)) continue; + if (walkDir(r, 4).some(p => re.test(path.basename(p)))) return true; + } + return false; +} + +function walkDir(root, depth) { + const out = []; + if (depth < 0) return out; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { return out; } + for (const e of entries) { + const full = path.join(root, e.name); + if (e.isDirectory()) { out.push(full); out.push(...walkDir(full, depth - 1)); } + } + return out; +} + +function macAppPresent(name) { + if (process.platform !== 'darwin') return false; + const candidates = [ + `/Applications/${name}.app`, + path.join(os.homedir(), 'Applications', `${name}.app`), + ]; + return candidates.some(p => fs.existsSync(p)); +} + +function detectMatch(spec) { + if (!spec) return false; + for (const clause of spec.split('||')) { + const c = clause.trim(); + if (!c) continue; + const colon = c.indexOf(':'); + const kind = colon === -1 ? c : c.slice(0, colon); + const val = colon === -1 ? '' : expandHome(c.slice(colon + 1)); + let ok = false; + switch (kind) { + case 'command': ok = hasCmd(val); break; + case 'dir': ok = safeStat(val, 'isDirectory'); break; + case 'file': ok = safeStat(val, 'isFile'); break; + case 'macapp': ok = macAppPresent(val); break; + case 'vscode-ext': ok = vscodeExtPresent(val); break; + case 'cursor-ext': ok = cursorExtPresent(val); break; + case 'jetbrains-config': ok = jetbrainsPresent(); break; + case 'jetbrains-plugin': ok = jetbrainsPluginPresent(val); break; + } + if (ok) return true; + } + return false; +} + +function safeStat(p, method) { + try { return fs.statSync(p)[method](); } catch (_) { return false; } +} + +// ── Repo root resolution ─────────────────────────────────────────────────── +function detectRepoRoot() { + // bin/install.js sits at /bin/install.js. Walk up one. + const here = path.dirname(__filename); + const root = path.resolve(here, '..'); + if (fs.existsSync(path.join(root, 'src', 'hooks')) && + fs.existsSync(path.join(root, 'agents')) && + fs.existsSync(path.join(root, 'skills'))) { + return root; + } + return null; +} + +// ── Run helpers ──────────────────────────────────────────────────────────── +// On Windows, npm/npx/claude/gemini/codex etc. ship as `.cmd` batch shims. +// Node's spawnSync('claude', ...) returns ENOENT for these unless we either +// (a) set shell:true (cmd.exe respects PATHEXT) or +// (b) resolve the actual `.cmd` path before spawning. +// We pick (a) — simpler, fewer cross-version corner cases. The cost is that +// args with spaces need quoting; we quote them defensively below. +const IS_WIN = process.platform === 'win32'; + +function quoteWinArg(a) { + if (!IS_WIN) return a; + if (a === '' || /[\s"]/.test(a)) { + // Standard CommandLineToArgvW escaping + return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + return a; +} + +function spawnXplat(cmd, args, opts) { + if (IS_WIN) { + const quoted = args.map(quoteWinArg).join(' '); + return child_process.spawnSync(`${cmd} ${quoted}`, [], Object.assign({ shell: true }, opts || {})); + } + return child_process.spawnSync(cmd, args, opts || {}); +} + +function runSpawn(cmd, args, opts, dry) { + if (dry) { process.stdout.write(` would run: ${cmd} ${args.join(' ')}\n`); return { status: 0 }; } + process.stdout.write(` $ ${cmd} ${args.join(' ')}\n`); + return spawnXplat(cmd, args, Object.assign({ stdio: 'inherit' }, opts || {})); +} + +// Create env with TMPDIR pointing to a temp dir inside configDir. +// Workaround for Claude Code plugin install EXDEV bug: it tries to rename +// from ~/.claude/plugins/cache/ to /tmp/ which fails when /tmp is on a +// different filesystem (common on Linux). Setting TMPDIR to a directory +// on the same filesystem as ~/.claude/ avoids the cross-device link error. +function sameFilesystemTmpEnv(configDir) { + const tmpDir = path.join(configDir, 'tmp'); + try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (_) {} + return Object.assign({}, process.env, { + TMPDIR: tmpDir, // Unix + TEMP: tmpDir, // Windows + TMP: tmpDir, // Windows alternate + }); +} + +function captureSpawn(cmd, args) { + try { return spawnXplat(cmd, args, { encoding: 'utf8' }); } + catch (_) { return { status: 1, stdout: '', stderr: '' }; } +} + +// spawnSync reports a missing binary as { status: null, error }, so the old +// `(r.status || 0) === 0` checks read ENOENT as success — a machine without +// the `claude` CLI got "installed: claude" with nothing installed and the +// standalone-hook fallback skipped (issue #592). Every spawn result must pass +// through here before being treated as "it worked". +function spawnOk(r) { + return !!r && !r.error && r.status === 0; +} + +function absoluteNodePath() { + return process.execPath; +} + +// ── Per-provider installers ──────────────────────────────────────────────── +async function installClaude(ctx) { + const { say, note, warn, ok, opts, results, configDir } = ctx; + results.detected++; + say('→ Claude Code detected'); + + // Plugin install (idempotent unless --force) + let alreadyInstalled = false; + if (!opts.force) { + const r = captureSpawn('claude', ['plugin', 'list']); + if (r.status === 0 && /caveman/i.test(r.stdout || '')) alreadyInstalled = true; + } + let pluginInstallSucceeded = false; + if (alreadyInstalled) { + note(' caveman plugin already installed (use --force to reinstall)'); + results.skipped.push(['claude', 'plugin already installed']); + pluginInstallSucceeded = true; + } else { + // Use a temp dir on the same filesystem as configDir to avoid EXDEV errors + // when Claude Code's plugin installer tries to rename across filesystems (#585). + const pluginEnv = sameFilesystemTmpEnv(configDir); + const r1 = runSpawn('claude', ['plugin', 'marketplace', 'add', REPO], { env: pluginEnv }, opts.dryRun); + const r2 = runSpawn('claude', ['plugin', 'install', 'caveman@caveman'], { env: pluginEnv }, opts.dryRun); + if (spawnOk(r1) && spawnOk(r2)) { + results.installed.push('claude'); + pluginInstallSucceeded = true; + } else { + if (r1.error || r2.error) { + warn(' claude CLI not found on PATH (or could not be spawned)'); + } + results.failed.push(['claude', 'claude plugin install failed']); + } + } + + // Self-heal: drop managed settings.json hook/statusLine entries whose target + // script no longer exists (issue #471). Migrating an old manual install to + // the plugin leaves settings.json pointing at removed ~/.claude/hooks/ + // caveman-*.js scripts, so Claude Code crashes every SessionStart / + // UserPromptSubmit with `loader:1478 — Cannot find module …`. Runs + // unconditionally so it repairs an already-dirty config even when we then + // skip standalone wiring because the plugin manifest handles hooks. + { + const settingsPath = path.join(configDir, 'settings.json'); + const settings = SETTINGS.readSettings(settingsPath); + if (settings) { + const pruned = SETTINGS.pruneOrphanedManagedHooks(settings, configDir); + if (pruned > 0) { + note(` removed ${pruned} orphaned caveman hook entr${pruned === 1 ? 'y' : 'ies'} from settings.json (target script missing)`); + if (!opts.dryRun) { + SETTINGS.validateHookFields(settings); + SETTINGS.writeSettings(settingsPath, settings); + } + } + } + } + + // Hook wiring decision matrix (issue #392 — avoid double-firing): + // --no-hooks → skip + // --with-hooks → wire (warn if the plugin manifest also wires them) + // default / --all → wire only if the plugin install did NOT succeed. + // The plugin manifest already wires SessionStart + UserPromptSubmit when the + // plugin install succeeds; wiring them again in settings.json fires both per + // event (two CAVEMAN MODE blocks, two reinforcement lines). + let shouldWireHooks; + if (opts.withHooks === false) { + shouldWireHooks = false; + } else if (opts.withHooks === true) { + shouldWireHooks = true; + if (pluginInstallSucceeded) { + warn(' --with-hooks wires hooks in settings.json alongside the plugin manifest.'); + warn(' Both will fire on every event. Pass --no-hooks to keep only the plugin path.'); + } + } else { + // 'auto' + shouldWireHooks = !pluginInstallSucceeded; + if (!shouldWireHooks) { + note(' hooks: plugin manifest handles SessionStart + UserPromptSubmit'); + note(' (pass --with-hooks to also wire standalone hooks in settings.json)'); + results.skipped.push(['claude-hooks', 'plugin manifest handles hooks']); + } else { + note(' hooks: plugin install did not succeed; falling back to standalone wiring'); + } + } + + if (shouldWireHooks) { + say(' → installing hooks'); + const r = await installHooks(ctx); + if (r === 'ok') results.installed.push('claude-hooks'); + else if (r === 'skip') results.skipped.push(['claude-hooks', 'already wired']); + else results.failed.push(['claude-hooks', r]); + } + + if (opts.withMcpShrink) { + say(' → wiring caveman-shrink MCP proxy (--with-mcp-shrink)'); + const r = installMcpShrink(ctx); + if (r.kind === 'ok') results.installed.push('caveman-shrink'); + if (r.kind === 'skip') results.skipped.push(['caveman-shrink', r.why]); + if (r.kind === 'fail') results.failed.push(['caveman-shrink', r.why]); + } + + process.stdout.write('\n'); +} + +function installGemini(ctx) { + const { say, note, opts, results } = ctx; + results.detected++; + say('→ Gemini CLI detected'); + + if (!opts.force) { + const r = captureSpawn('gemini', ['extensions', 'list']); + if (r.status === 0 && /caveman/i.test(r.stdout || '')) { + note(' caveman extension already installed (use --force to reinstall)'); + results.skipped.push(['gemini', 'extension already installed']); + process.stdout.write('\n'); + return; + } + } + const r = runSpawn('gemini', ['extensions', 'install', `https://github.com/${REPO}`], null, opts.dryRun); + if (spawnOk(r)) results.installed.push('gemini'); + else results.failed.push(['gemini', 'gemini extensions install failed']); + process.stdout.write('\n'); +} + +function installViaSkills(ctx, prov) { + const { say, opts, results } = ctx; + results.detected++; + say(`→ ${prov.label} detected`); + // --skill '*' --yes: skip the upstream skill-selection TUI and confirmation + // prompts. Without --skill, `curl|bash` (no TTY on stdin) renders an empty + // checkbox list the user can't interact with, then exits 0 with zero skills + // installed — and our installer happily reports success. See issue #370. + // + // We pass `--skill '*'` rather than `--all` because the upstream `skills` CLI + // interprets `--all` as "all skills from the source to *all* agents", which + // ignores the `-a prov.profile` selection and writes every skill through + // every agent adapter (see issue #389). `--skill '*' -a ` is the + // documented form for "install every skill into a specific agent". + const args = ['-y', 'skills', 'add', REPO, '--skill', '*', '-a', prov.profile, '--yes']; + const r = runSpawn('npx', args, null, opts.dryRun); + if (spawnOk(r)) results.installed.push(prov.id); + else results.failed.push([prov.id, `npx skills add (${prov.profile}) failed`]); + process.stdout.write('\n'); +} + +// ── hermes native install ────────────────────────────────────────────────── +// Drops the caveman skills into ~/.hermes/skills/productivity/ (or HERMES_HOME if set). +const HERMES_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']; + +function hermesConfigDir() { + // Hermes uses ~/.hermes by default, or HERMES_HOME env var. + if (process.env.HERMES_HOME) return path.join(process.env.HERMES_HOME, 'skills'); + return path.join(os.homedir(), '.hermes', 'skills'); +} + +function installHermes(ctx) { + const { say, note, warn, opts, repoRoot, results } = ctx; + results.detected++; + say('→ Hermes Agent detected'); + + if (!repoRoot) { + warn(' Hermes native install requires a local clone of the caveman repo.'); + note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only hermes'); + results.failed.push(['hermes', 'native install requires local repo clone']); + process.stdout.write('\n'); + return; + } + + const skillsRoot = path.join(hermesConfigDir(), 'productivity'); + + if (opts.dryRun) { + note(` would mkdir ${skillsRoot}/`); + note(` would copy ${HERMES_SKILL_DIRS.length} skill dirs into ${skillsRoot}/`); + results.installed.push('hermes'); + process.stdout.write('\n'); + return; + } + + try { + fs.mkdirSync(skillsRoot, { recursive: true }); + + for (const skillDir of HERMES_SKILL_DIRS) { + const srcDir = path.join(repoRoot, 'skills', skillDir); + const destDir = path.join(skillsRoot, skillDir); + if (fs.existsSync(srcDir)) { + // Remove existing to ensure clean copy + if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true }); + copyDirRecursive(srcDir, destDir); + note(` copied ${skillDir} → ${destDir}`); + } else { + warn(` skill dir not found: ${srcDir}`); + } + } + + results.installed.push('hermes'); + } catch (err) { + results.failed.push(['hermes', 'copy failed: ' + err.message]); + } + + process.stdout.write('\n'); +} + +// ── opencode native install ─────────────────────────────────────────────── +// Drops the in-repo plugin (src/plugins/opencode/) plus skills, agents, +// commands, and an AGENTS.md ruleset into ~/.config/opencode/. Patches +// opencode.json with a "plugin" array entry. Mirrors the Claude Code hook +// architecture as closely as opencode allows — only the statusline is missing +// (opencode's TUI exposes no plugin-writable badge). +const OPENCODE_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']; +const OPENCODE_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']; +const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md']; +const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js'; +const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman'; +// Marker fence for the opencode AGENTS.md ruleset block. Same convention as +// bin/lib/openclaw.js for SOUL.md — lets us strip our block cleanly even when +// the user has authored content above AND below it. +const OPENCODE_AGENTS_MD_BEGIN = ''; +const OPENCODE_AGENTS_MD_END = ''; + +function opencodeConfigDir() { + // opencode uses ~/.config/opencode on every platform (on Windows that's + // %USERPROFILE%\.config\opencode via os.homedir()), NOT %APPDATA% (#376). + if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'opencode'); + return path.join(os.homedir(), '.config', 'opencode'); +} + +function copyDirRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyDirRecursive(s, d); + else if (entry.isFile()) fs.copyFileSync(s, d); + } +} + +function installOpencode(ctx) { + const { say, note, warn, opts, repoRoot, results } = ctx; + results.detected++; + say('→ opencode detected'); + + if (!repoRoot) { + warn(' opencode native install requires a local clone of the caveman repo.'); + note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only opencode'); + results.failed.push(['opencode', 'native install requires local repo clone']); + process.stdout.write('\n'); + return; + } + + const dir = opencodeConfigDir(); + const pluginDir = path.join(dir, 'plugins', 'caveman'); + const commandsDir = path.join(dir, 'commands'); + const agentsDir = path.join(dir, 'agents'); + const skillsDir = path.join(dir, 'skills'); + const opencodeJson = path.join(dir, 'opencode.json'); + const agentsMd = path.join(dir, 'AGENTS.md'); + + if (opts.dryRun) { + note(` would mkdir ${pluginDir}/, ${commandsDir}/, ${agentsDir}/, ${skillsDir}/`); + note(` would copy plugin.js + package.json + caveman-config.cjs into ${pluginDir}/`); + note(` would copy ${OPENCODE_COMMAND_FILES.length} command files into ${commandsDir}/`); + note(` would copy ${OPENCODE_AGENT_FILES.length} cavecrew agents into ${agentsDir}/`); + note(` would copy ${OPENCODE_SKILL_DIRS.length} skill dirs into ${skillsDir}/`); + note(` would patch ${opencodeJson} with "plugin" entry${opts.withMcpShrink ? ' + caveman-shrink MCP' : ''}`); + note(` would write Tier-3 ruleset to ${agentsMd}`); + results.installed.push('opencode'); + process.stdout.write('\n'); + return; + } + + try { + // 1. Plugin dir — copy plugin.js, package.json, caveman-config.js (sibling). + // Same `--force` semantic as commands/agents/skills below: re-runs leave + // user edits to plugin.js alone unless --force is passed. + fs.mkdirSync(pluginDir, { recursive: true }); + const pluginSrc = path.join(repoRoot, 'src', 'plugins', 'opencode'); + const pluginPayload = [ + [path.join(pluginSrc, 'plugin.js'), path.join(pluginDir, 'plugin.js')], + [path.join(pluginSrc, 'package.json'), path.join(pluginDir, 'package.json')], + // Renamed to .cjs because the plugin dir is "type": "module" — a bare .js + // sibling would be loaded as ESM and break the plugin's require() bridge. + [path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'), + path.join(pluginDir, 'caveman-config.cjs')], + ]; + for (const [src, dest] of pluginPayload) { + if (fs.existsSync(dest) && !opts.force) { + note(` skipped ${dest} (exists; --force to overwrite)`); + continue; + } + fs.copyFileSync(src, dest); + } + process.stdout.write(` installed: ${pluginDir}\n`); + + // 2. Commands. + fs.mkdirSync(commandsDir, { recursive: true }); + const cmdSrcDir = path.join(pluginSrc, 'commands'); + for (const f of OPENCODE_COMMAND_FILES) { + const src = path.join(cmdSrcDir, f); + const dest = path.join(commandsDir, f); + if (!fs.existsSync(src)) continue; // defense-in-depth: skip a missing command file rather than crash (#434) + if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } + fs.copyFileSync(src, dest); + process.stdout.write(` installed: ${dest}\n`); + } + + // 3. Subagents. Source files target Claude Code's schema (`tools: [...]` + // YAML array); opencode rejects that form and refuses to boot until the + // file is removed. Strip the `tools:` line on copy — opencode falls back + // to its default tool set, and subagent prompts already self-restrict in + // the body. Issue #386. + fs.mkdirSync(agentsDir, { recursive: true }); + const agentSrcDir = path.join(repoRoot, 'agents'); + for (const f of OPENCODE_AGENT_FILES) { + const src = path.join(agentSrcDir, f); + const dest = path.join(agentsDir, f); + if (!fs.existsSync(src)) continue; + if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } + fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8'))); + process.stdout.write(` installed: ${dest}\n`); + } + + // 4. Skills — opencode auto-discovers SKILL.md from ~/.config/opencode/skills/. + fs.mkdirSync(skillsDir, { recursive: true }); + const skillSrcDir = path.join(repoRoot, 'skills'); + for (const name of OPENCODE_SKILL_DIRS) { + const src = path.join(skillSrcDir, name); + const dest = path.join(skillsDir, name); + if (!fs.existsSync(src)) continue; + if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest}/ (exists; --force to overwrite)`); continue; } + copyDirRecursive(src, dest); + process.stdout.write(` installed: ${dest}/\n`); + } + + // 5. AGENTS.md — Tier-3 always-on ruleset. Wrapped in begin/end markers so + // a later --uninstall can strip our block cleanly even if the user has + // authored content above AND below it. Idempotency check uses the begin + // marker (the legacy sentinel still matches old installs). + const ruleBody = fs.readFileSync(path.join(repoRoot, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n'; + const fencedBlock = `${OPENCODE_AGENTS_MD_BEGIN}\n${ruleBody}${OPENCODE_AGENTS_MD_END}\n`; + if (fs.existsSync(agentsMd)) { + const existing = fs.readFileSync(agentsMd, 'utf8'); + const alreadyFenced = existing.includes(OPENCODE_AGENTS_MD_BEGIN) + && existing.includes(OPENCODE_AGENTS_MD_END); + const alreadyByLegacySentinel = !alreadyFenced && existing.includes(OPENCODE_AGENTS_MD_SENTINEL); + if (alreadyFenced) { + note(` ${agentsMd} already contains caveman ruleset`); + } else if (alreadyByLegacySentinel) { + if (!opts.force) { + note(` ${agentsMd} contains a legacy (un-fenced) caveman block — leaving as-is`); + note(' re-run with --force to migrate it to a fenced block'); + } + if (opts.force) { + // Migrate, don't wipe (issue #594): the old code replaced the whole + // file, destroying any user-authored content around the legacy + // block. Back up once, then remove only the legacy block: exact + // match of the current rule body when possible, otherwise cut from + // the sentinel's paragraph start to EOF (the legacy path APPENDED + // the block, so user content precedes it; anything after lives on + // in the backup). + const agentsBak = agentsMd + '.bak'; + if (!fs.existsSync(agentsBak)) { + try { fs.copyFileSync(agentsMd, agentsBak); } catch (_) {} + } + const bodyTrim = ruleBody.trimEnd(); + let userPart; + const exact = existing.indexOf(bodyTrim); + if (exact !== -1) { + userPart = (existing.slice(0, exact) + existing.slice(exact + bodyTrim.length)).trim(); + } else { + const sentinelAt = existing.indexOf(OPENCODE_AGENTS_MD_SENTINEL); + const cutAt = existing.lastIndexOf('\n\n', sentinelAt); + userPart = cutAt === -1 ? '' : existing.slice(0, cutAt).trim(); + note(` legacy block did not match the current ruleset — everything from the sentinel down was replaced; original kept at ${agentsBak}`); + } + const next = (userPart ? userPart + '\n\n' : '') + fencedBlock; + fs.writeFileSync(agentsMd, next, { mode: 0o644 }); + process.stdout.write(` migrated ${agentsMd} legacy block to fenced (backup: ${agentsBak})\n`); + } + } else { + const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); + fs.writeFileSync(agentsMd, existing + sep + fencedBlock, { mode: 0o644 }); + process.stdout.write(` appended caveman ruleset to ${agentsMd}\n`); + } + } else { + fs.writeFileSync(agentsMd, fencedBlock, { mode: 0o644 }); + process.stdout.write(` installed: ${agentsMd}\n`); + } + + // 6. opencode.json — add plugin entry; optional caveman-shrink MCP. + let cfg = SETTINGS.readSettings(opencodeJson); + if (cfg === null) { + warn(` ${opencodeJson} unparseable; will not touch it. Edit manually then re-run.`); + results.failed.push(['opencode', 'opencode.json unparseable']); + process.stdout.write('\n'); + return; + } + // Preserve the original on first install only — repeat installs would + // otherwise overwrite the only known-good copy with an already-merged file. + const opencodeBak = opencodeJson + '.bak'; + if (fs.existsSync(opencodeJson) && !fs.existsSync(opencodeBak)) { + try { fs.copyFileSync(opencodeJson, opencodeBak); } catch (_) {} + } + if (!Array.isArray(cfg.plugin)) cfg.plugin = []; + if (!cfg.plugin.includes(OPENCODE_PLUGIN_REL)) { + cfg.plugin.push(OPENCODE_PLUGIN_REL); + } + if (opts.withMcpShrink) { + // opts.withMcpShrink is the array of upstream-cmd tokens parseArgs + // produced. caveman-shrink is a proxy — it crashes without an upstream, + // so we always wire one through. + if (!cfg.mcp || typeof cfg.mcp !== 'object') cfg.mcp = {}; + if (!cfg.mcp['caveman-shrink']) { + cfg.mcp['caveman-shrink'] = { + type: 'local', + command: ['npx', '-y', MCP_SHRINK_PKG, ...opts.withMcpShrink], + enabled: true, + }; + process.stdout.write(` registered caveman-shrink MCP server (wraps: ${opts.withMcpShrink.join(' ')})\n`); + } + } + SETTINGS.writeSettings(opencodeJson, cfg); + process.stdout.write(` patched: ${opencodeJson}\n`); + + results.installed.push('opencode'); + } catch (e) { + warn(' opencode install failed: ' + (e && e.message || e)); + results.failed.push(['opencode', (e && e.message) || 'unknown error']); + } + process.stdout.write('\n'); +} + +// ── OpenClaw native install ─────────────────────────────────────────────── +// Drops skills/caveman/ into the OpenClaw workspace and appends a small +// auto-injected bootstrap block to the workspace SOUL.md. Always-on behavior +// comes from SOUL.md (auto-injected each turn); the skill folder makes +// caveman discoverable via `openclaw skills list`. See bin/lib/openclaw.js +// for the actual file writes. +function installOpenclaw(ctx) { + const { say, note, warn, opts, repoRoot, results } = ctx; + results.detected++; + say('→ OpenClaw detected'); + + const log = { + write: (s) => process.stdout.write(s), + note: (s) => note(s), + warn: (s) => warn(s), + }; + + const r = OPENCLAW.installOpenclaw({ + workspace: process.env.OPENCLAW_WORKSPACE || undefined, + repoRoot, + dryRun: opts.dryRun, + force: opts.force, + log, + }); + + if (r.ok) results.installed.push('openclaw'); + else results.failed.push(['openclaw', r.reason || 'install failed']); + + process.stdout.write('\n'); +} + +// ── Hooks installer ──────────────────────────────────────────────────────── +// Replaces src/hooks/install.sh + src/hooks/install.ps1. +async function installHooks(ctx) { + const { note, warn, opts, repoRoot, configDir } = ctx; + const hooksDir = path.join(configDir, 'hooks'); + const settingsPath = path.join(configDir, 'settings.json'); + const sourceDir = repoRoot ? path.join(repoRoot, 'src', 'hooks') : null; + + if (opts.dryRun) { + note(` would mkdir -p ${hooksDir}`); + for (const f of HOOK_FILES) note(` would install ${path.join(hooksDir, f)}`); + note(` would merge SessionStart + UserPromptSubmit + statusline into ${settingsPath}`); + return 'ok'; + } + + fs.mkdirSync(hooksDir, { recursive: true }); + + // Copy or download each hook file. Local-clone-first for offline installs. + // Downloaded files (the rare detached-script / curl fallback) are verified + // against the SHA-256 manifest published at the pinned release ref (#262); + // a mismatch aborts before the file is wired into settings.json. Local + // copies are trusted — they come from the same package as this script. + let checksums; // undefined = not yet loaded; null = unavailable for this ref + let warnedNoChecksums = false; + for (const f of HOOK_FILES) { + const dest = path.join(hooksDir, f); + if (sourceDir && fs.existsSync(path.join(sourceDir, f))) { + fs.copyFileSync(path.join(sourceDir, f), dest); + } else { + try { await downloadTo(`${HOOKS_REMOTE}/${f}`, dest); } + catch (e) { return `download ${f} failed: ${e.message}`; } + if (checksums === undefined) checksums = await loadRemoteHookChecksums(); + if (checksums) { + const want = checksums.get(f); + const got = sha256File(dest); + if (!want || want !== got) { + try { fs.unlinkSync(dest); } catch (_) {} + return `integrity check failed for ${f} (expected ${want || ''}, got ${got}) — ` + + `refusing to install a hook that doesn't match pinned release ${PINNED_REF}`; + } + } else if (!warnedNoChecksums) { + warnedNoChecksums = true; + warn(` note: no integrity manifest at ${PINNED_REF} — downloaded hooks installed unverified.`); + } + } + process.stdout.write(` installed: ${dest}\n`); + } + + // chmod statusline (no-op on Windows) + try { fs.chmodSync(path.join(hooksDir, 'caveman-statusline.sh'), 0o755); } catch (_) {} + + // Merge into settings.json + let settings = SETTINGS.readSettings(settingsPath); + if (settings === null) { + warn(' settings.json unparseable; will not touch it. Edit manually then re-run.'); + return 'settings.json unparseable'; + } + // Backup once, preserved across reinstalls. Without the !fs.existsSync(bak) + // guard, the second install would overwrite the only known-good copy with + // the already-merged file, destroying recovery. + const bak = settingsPath + '.bak'; + if (fs.existsSync(settingsPath) && !fs.existsSync(bak)) { + try { fs.copyFileSync(settingsPath, bak); } catch (_) {} + } + + const node = absoluteNodePath(); + const activate = path.join(hooksDir, 'caveman-activate.js'); + const tracker = path.join(hooksDir, 'caveman-mode-tracker.js'); + const statusline = path.join(hooksDir, 'caveman-statusline.sh'); + + // Migrate any legacy bare-`node` invocations of our managed scripts. + SETTINGS.rewriteLegacyManagedHookCommands(settings, node); + + SETTINGS.addCommandHook(settings, 'SessionStart', { + command: `"${node}" "${activate}"`, + marker: 'caveman-activate', + timeout: 5, + statusMessage: 'Loading caveman mode...', + }); + + SETTINGS.addCommandHook(settings, 'UserPromptSubmit', { + command: `"${node}" "${tracker}"`, + marker: 'caveman-mode-tracker', + timeout: 5, + statusMessage: 'Tracking caveman mode...', + }); + + // Statusline — set if absent or already pointing at our script. + // Windows: prefer pwsh (PowerShell 7+, cross-platform), fall back to + // powershell.exe (Windows PowerShell 5.1, ships with every Windows install). + // Use -ExecutionPolicy Bypass so users without RemoteSigned policy can run. + const psHost = IS_WIN && hasCmd('pwsh') ? 'pwsh' : (IS_WIN ? 'powershell' : null); + const slCmd = IS_WIN + ? `${psHost} -NoProfile -ExecutionPolicy Bypass -File "${path.join(hooksDir, 'caveman-statusline.ps1')}"` + : `bash "${statusline}"`; + if (!settings.statusLine) { + settings.statusLine = { type: 'command', command: slCmd }; + process.stdout.write(' statusline badge configured.\n'); + } else { + const existing = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (existing.includes(statusline) || existing.includes('caveman-statusline')) { + process.stdout.write(' statusline badge already configured.\n'); + } else { + process.stdout.write(' NOTE: existing statusline detected — caveman badge NOT added.\n'); + process.stdout.write(' See src/hooks/README.md to add the badge to your existing statusline.\n'); + } + } + + // Defensive validation before write — Claude Code Zod will discard the + // entire settings.json if any single hook is malformed (#249-class footgun). + SETTINGS.validateHookFields(settings); + SETTINGS.writeSettings(settingsPath, settings); + process.stdout.write(` hooks wired in ${settingsPath}\n`); + return 'ok'; +} + +// ── MCP shrink wiring ───────────────────────────────────────────────────── +function installMcpShrink(ctx) { + const { note, warn, opts } = ctx; + // Probe npm first — registry outage = clean skip with manual snippet. + const probe = captureSpawn('npm', ['view', MCP_SHRINK_PKG, 'name']); + if (probe.status !== 0) { + warn(` 'npm view ${MCP_SHRINK_PKG}' returned no metadata — registry unreachable or package missing.`); + note(' Skipping registration. Re-run --with-mcp-shrink when the registry is reachable.'); + return { kind: 'skip', why: 'npm registry probe failed' }; + } + // Detect modern `claude mcp add` + const help = captureSpawn('claude', ['mcp', '--help']); + if (help.status !== 0) { + note(" 'claude mcp add' not available on this CLI. Add the snippet from"); + note(' src/hooks/README.md to your Claude Code MCP config manually.'); + return { kind: 'skip', why: 'manual config required' }; + } + // opts.withMcpShrink is always an array of upstream-cmd tokens by the + // time we get here; parseArgs rejects bare --with-mcp-shrink. The proxy + // gets `npx -y caveman-shrink ` so it has something + // to wrap. + const upstream = opts.withMcpShrink; + const r = runSpawn( + 'claude', + ['mcp', 'add', 'caveman-shrink', '--', 'npx', '-y', MCP_SHRINK_PKG, ...upstream], + null, opts.dryRun + ); + if (spawnOk(r)) { + note(` registered, wrapping: ${upstream.join(' ')}`); + note(` Edit ~/.claude.json mcpServers["caveman-shrink"] to change the upstream,`); + note(' or `claude mcp remove caveman-shrink` to drop it.'); + note(` Docs: https://github.com/${REPO}/tree/main/src/mcp-servers/caveman-shrink`); + return { kind: 'ok' }; + } + return { kind: 'fail', why: 'claude mcp add failed' }; +} + +// ── Init writers (per-repo rule files) ──────────────────────────────────── +async function runInit(ctx) { + const { note, warn, opts, repoRoot } = ctx; + const local = repoRoot && path.join(repoRoot, 'src/tools/caveman-init.js'); + const args = [process.cwd()]; + if (opts.dryRun) args.push('--dry-run'); + if (opts.force) args.push('--force'); + if (local && fs.existsSync(local)) { + const r = runSpawn(absoluteNodePath(), [local, ...args], null, opts.dryRun); + return spawnOk(r); + } + // Curl-pipe fallback + if (opts.dryRun) { + note(` would download ${INIT_SCRIPT_URL} and run it on ${process.cwd()}`); + return true; + } + try { + const tmp = path.join(os.tmpdir(), `caveman-init-${process.pid}.js`); + await downloadTo(INIT_SCRIPT_URL, tmp); + const r = child_process.spawnSync(absoluteNodePath(), [tmp, ...args], { stdio: 'inherit' }); + try { fs.unlinkSync(tmp); } catch (_) {} + return spawnOk(r); + } catch (e) { + warn(' ' + e.message); + return false; + } +} + +// ── HTTPS download via stdlib ───────────────────────────────────────────── +function downloadTo(url, dest) { + // Prefer curl/wget when available (better proxy + cert handling on legacy + // systems); fall back to Node https. + if (hasCmd('curl')) { + const r = child_process.spawnSync('curl', ['-fsSL', '-o', dest, url], { stdio: 'inherit' }); + if (r.status === 0) return; + throw new Error(`curl failed for ${url}`); + } + const https = require('https'); + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + resolve(downloadTo(res.headers.location, dest)); + return; + } + if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode} for ${url}`)); return; } + const out = fs.createWriteStream(dest); + res.pipe(out); + out.on('finish', () => out.close(resolve)); + out.on('error', reject); + }); + req.on('error', reject); + }); +} + +// ── Integrity verification for downloaded hooks (#262) ───────────────────── +function sha256File(p) { + return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +} + +// Download + parse the hook integrity manifest from the pinned release ref. +// Returns Map, or null when the manifest is unavailable +// (release tags older than this feature predate it) — the caller treats null +// as "cannot verify" and warns rather than aborting, for back-compat. Parses +// the standard `sha256sum` text format: "<64-hex> " (two spaces, or +// " *" binary marker). +async function loadRemoteHookChecksums() { + const tmp = path.join(os.tmpdir(), `caveman-checksums-${process.pid}-${Date.now()}.sha256`); + try { + await downloadTo(`${HOOKS_REMOTE}/checksums.sha256`, tmp); + const txt = fs.readFileSync(tmp, 'utf8'); + const map = new Map(); + for (const line of txt.split('\n')) { + const m = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); + if (m) map.set(path.basename(m[2].trim()), m[1].toLowerCase()); + } + return map.size ? map : null; + } catch (_) { + return null; + } finally { + try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ } + } +} + +// ── Uninstall ───────────────────────────────────────────────────────────── +function uninstall(ctx) { + const { say, note, warn, ok, opts, configDir } = ctx; + say('🪨 caveman uninstall'); + + if (opts.dryRun) note(' (dry run — nothing will be removed)'); + + // Hooks: remove from settings.json + delete hook files. + const hooksDir = path.join(configDir, 'hooks'); + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = SETTINGS.readSettings(settingsPath); + if (settings) { + const removed = SETTINGS.removeCavemanHooks(settings); + // Drop our statusline if it points at our script + if (settings.statusLine) { + const cmd = typeof settings.statusLine === 'string' ? settings.statusLine : (settings.statusLine.command || ''); + if (cmd.includes('caveman-statusline')) delete settings.statusLine; + } + SETTINGS.validateHookFields(settings); + if (!opts.dryRun) SETTINGS.writeSettings(settingsPath, settings); + ok(` removed ${removed} caveman hook entr${removed === 1 ? 'y' : 'ies'} from settings.json`); + } + } + + if (fs.existsSync(hooksDir)) { + for (const f of HOOK_FILES) { + const p = path.join(hooksDir, f); + if (!fs.existsSync(p)) continue; + if (!opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } + note(` removed ${p}`); + } + // Don't rmdir hooksDir — other plugins may use it. + } + + // Plugin uninstall on Claude. Probe `plugin list` first so a re-run on a + // machine where caveman was never installed (or was already removed) doesn't + // print "Plugin not installed" stderr noise. + if (hasCmd('claude')) { + const probe = captureSpawn('claude', ['plugin', 'list']); + if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) { + const r = runSpawn('claude', ['plugin', 'uninstall', 'caveman@caveman'], null, opts.dryRun); + if (spawnOk(r)) ok(' removed claude plugin'); + } else { + note(' claude plugin not installed — skipping'); + } + + // caveman-shrink MCP — only run if `claude mcp` subcommand exists. Tolerate + // non-zero exit (server may have never been registered). + const mcpHelp = captureSpawn('claude', ['mcp', '--help']); + if (mcpHelp.status === 0) { + runSpawn('claude', ['mcp', 'remove', 'caveman-shrink'], null, opts.dryRun); + } + } + + // Gemini extension. Same idempotency probe as claude. + if (hasCmd('gemini')) { + const probe = captureSpawn('gemini', ['extensions', 'list']); + if (probe.status === 0 && /caveman/i.test(probe.stdout || '')) { + runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun); + } else { + note(' gemini extension not installed — skipping'); + } + } + + // opencode native install — strip plugin entry, MCP entry, and our files. + // Probed by the existence of the plugin dir we own; if absent, skip silently. + const ocDir = opencodeConfigDir(); + const ocPluginDir = path.join(ocDir, 'plugins', 'caveman'); + if (fs.existsSync(ocPluginDir)) { + const ocJson = path.join(ocDir, 'opencode.json'); + if (fs.existsSync(ocJson)) { + const cfg = SETTINGS.readSettings(ocJson); + if (cfg) { + if (Array.isArray(cfg.plugin)) { + cfg.plugin = cfg.plugin.filter(p => p !== OPENCODE_PLUGIN_REL); + if (cfg.plugin.length === 0) delete cfg.plugin; + } + if (cfg.mcp && typeof cfg.mcp === 'object' && cfg.mcp['caveman-shrink']) { + delete cfg.mcp['caveman-shrink']; + if (Object.keys(cfg.mcp).length === 0) delete cfg.mcp; + } + if (!opts.dryRun) SETTINGS.writeSettings(ocJson, cfg); + ok(` pruned caveman entries from ${ocJson}`); + } + } + if (!opts.dryRun) { try { fs.rmSync(ocPluginDir, { recursive: true, force: true }); } catch (_) {} } + note(` removed ${ocPluginDir}`); + // Commands, agents, skills — only files matching our manifest (don't + // sweep the parent dirs; user may have other entries there). + for (const f of OPENCODE_COMMAND_FILES) { + const p = path.join(ocDir, 'commands', f); + if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } + } + for (const f of OPENCODE_AGENT_FILES) { + const p = path.join(ocDir, 'agents', f); + if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} } + } + for (const name of OPENCODE_SKILL_DIRS) { + const p = path.join(ocDir, 'skills', name); + if (fs.existsSync(p) && !opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} } + } + // AGENTS.md — strip the fenced caveman block (preserves user content + // above and below). If the file is empty after the strip, remove it. + // Falls back to legacy unfenced-sentinel handling for installs that + // pre-date the marker fence. + const ocAgentsMd = path.join(ocDir, 'AGENTS.md'); + if (fs.existsSync(ocAgentsMd)) { + const body = fs.readFileSync(ocAgentsMd, 'utf8'); + const begin = body.indexOf(OPENCODE_AGENTS_MD_BEGIN); + const end = body.indexOf(OPENCODE_AGENTS_MD_END); + if (begin !== -1 && end !== -1 && end > begin) { + const before = body.slice(0, begin).replace(/\n+$/, '\n'); + const after = body.slice(end + OPENCODE_AGENTS_MD_END.length).replace(/^\n+/, '\n'); + let next = (before + after).trimEnd(); + next = next ? next + '\n' : ''; + if (!opts.dryRun) { + if (next === '') { + try { fs.unlinkSync(ocAgentsMd); } catch (_) {} + } else { + fs.writeFileSync(ocAgentsMd, next, { mode: 0o644 }); + } + } + note(next === '' ? ` removed ${ocAgentsMd}` : ` stripped caveman block from ${ocAgentsMd}`); + } else if (body.includes(OPENCODE_AGENTS_MD_SENTINEL)) { + // Legacy install (no marker fence). Remove only if the file is ours. + if (body.trim() === '' || body.trim().startsWith(OPENCODE_AGENTS_MD_SENTINEL)) { + if (!opts.dryRun) { try { fs.unlinkSync(ocAgentsMd); } catch (_) {} } + note(` removed ${ocAgentsMd}`); + } else { + note(` left ${ocAgentsMd} in place (legacy mixed content — strip caveman block manually)`); + } + } + } + // opencode flag file + const ocFlag = path.join(ocDir, '.caveman-active'); + if (fs.existsSync(ocFlag) && !opts.dryRun) { try { fs.unlinkSync(ocFlag); } catch (_) {} } + } + + // OpenClaw native install — strip skill folder + SOUL.md marker block. + // Probed by the skill folder we own; if absent, skip silently. + const ocwWs = process.env.OPENCLAW_WORKSPACE || path.join(os.homedir(), '.openclaw', 'workspace'); + if (fs.existsSync(path.join(ocwWs, 'skills', 'caveman')) || fs.existsSync(path.join(ocwWs, 'SOUL.md'))) { + const log = { + write: (s) => process.stdout.write(s), + note: (s) => note(s), + warn: (s) => warn(s), + }; + const r = OPENCLAW.uninstallOpenclaw({ workspace: ocwWs, dryRun: opts.dryRun, log }); + if (r.touched) ok(' pruned caveman entries from OpenClaw workspace'); + } + + // Hermes native install — remove the skill folders installHermes copied. + // Honors HERMES_HOME via hermesConfigDir(); probed by the dirs we own. + const hermesRoot = path.join(hermesConfigDir(), 'productivity'); + if (fs.existsSync(hermesRoot)) { + let prunedHermes = false; + for (const name of HERMES_SKILL_DIRS) { + const p = path.join(hermesRoot, name); + if (fs.existsSync(p)) { + if (!opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} } + note(` removed ${p}`); + prunedHermes = true; + } + } + if (prunedHermes) ok(' pruned caveman skills from Hermes'); + } + + // Flag file + const flag = path.join(configDir, '.caveman-active'); + if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} } + + process.stdout.write('\n'); + ok('uninstall done.'); + ok('npx-skills installs (Cursor/Windsurf/etc.) — remove via your IDE\'s skill manager'); + ok('per-repo init files (.cursor/, .windsurf/, AGENTS.md) — remove with your editor'); +} + +// ── Interactive prompt (TTY-only) ───────────────────────────────────────── +async function promptForOnly(detected) { + if (!process.stdin.isTTY || !process.stdout.isTTY) return null; + if (detected.length === 0) return null; + process.stdout.write('\nDetected agents:\n'); + detected.forEach((p, i) => process.stdout.write(` [${i + 1}] ${p.label}\n`)); + process.stdout.write(' [a] all [q] quit\n'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ans = await new Promise(res => rl.question('Install which? (default: all) ', res)); + rl.close(); + const t = (ans || '').trim().toLowerCase(); + if (t === 'q') process.exit(0); + if (t === '' || t === 'a' || t === 'all') return null; + const picks = t.split(/[\s,]+/).map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= detected.length); + if (picks.length === 0) return null; + return picks.map(n => detected[n - 1].id); +} + +// ── --list ───────────────────────────────────────────────────────────────── +function printList(noColor) { + const c = makeChalk(noColor); + process.stdout.write(c.orange('🪨 caveman provider matrix') + '\n\n'); + process.stdout.write(` ${pad('ID', 13)} ${pad('AGENT', 22)} INSTALL MECHANISM\n`); + process.stdout.write(` ${pad('--', 13)} ${pad('-----', 22)} -----------------\n`); + for (const p of PROVIDERS) { + const tag = p.soft ? ' (soft)' : ''; + process.stdout.write(` ${pad(p.id, 13)} ${pad(p.label, 22)} ${p.mech}${tag}\n`); + } + process.stdout.write('\n'); + process.stdout.write(c.dim(' Defaults: --with-hooks ON, --with-init OFF, --with-mcp-shrink OFF.\n')); + process.stdout.write(c.dim(' --all = hooks + init (mcp-shrink needs an upstream — opt in explicitly).\n')); + process.stdout.write(c.dim(' --minimal turns hooks + init + mcp-shrink off.\n')); +} + +function pad(s, n) { s = String(s); return s + ' '.repeat(Math.max(0, n - s.length)); } + +// ── Help ─────────────────────────────────────────────────────────────────── +function printHelp() { + process.stdout.write(`caveman installer — detects your agents and installs caveman for each one. + +USAGE + npx -y github:JuliusBrussee/caveman -- [flags] + node bin/install.js [flags] + bash install.sh [flags] # shim → npx + pwsh install.ps1 [flags] # shim → npx + +FLAGS + --dry-run Print what would run, do nothing. + --force Re-run even if a target reports already installed. + --only Install only for the named agent. Repeatable. + See --list for valid ids. + --skip-skills Don't run the npx-skills auto-detect fallback. + --all Turn on hooks + init. (mcp-shrink needs an upstream; + pass --with-mcp-shrink="" to add it.) + --minimal Just the plugin/extension install. + --with-hooks Claude Code: install SessionStart/UserPromptSubmit hooks + + statusline badge. (Default ON.) + --no-hooks Skip the hooks installer. + --with-init Write per-repo IDE rule files into \$PWD. + --with-mcp-shrink="" + Claude Code (and opencode): register caveman-shrink MCP + proxy wrapping the given upstream. Default OFF. + caveman-shrink crashes without an upstream, so a value + is required. The value is whitespace-tokenized. + Example: --with-mcp-shrink="npx @modelcontextprotocol/server-filesystem /tmp" + --no-mcp-shrink Skip MCP shrink. (Default.) + --uninstall, -u Remove caveman from this machine. + --config-dir Claude Code config dir for hook files + settings.json. + Default: \$CLAUDE_CONFIG_DIR or ~/.claude. Does NOT + scope \`claude plugin install\`, \`gemini extensions + install\`, opencode (XDG_CONFIG_HOME), or openclaw + (OPENCLAW_WORKSPACE) — those use their own paths. + --non-interactive Never prompt; use defaults. (Auto when stdin is not a TTY.) + --list Print provider matrix and exit. + --no-color Disable ANSI colors. + -h, --help Show this help. + +EXAMPLES + npx -y github:JuliusBrussee/caveman # default install + npx -y github:JuliusBrussee/caveman -- --all # all the trimmings + npx -y github:JuliusBrussee/caveman -- --only claude --no-mcp-shrink + npx -y github:JuliusBrussee/caveman -- --uninstall + + Issues: https://github.com/${REPO}/issues +`); +} + +// ── Main ─────────────────────────────────────────────────────────────────── +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const c = makeChalk(opts.noColor); + if (opts.help) { printHelp(); return 0; } + if (opts.listOnly) { printList(opts.noColor); return 0; } + + checkWslWindowsNode(); + checkNodeVersion(); + + const configDir = opts.configDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const repoRoot = detectRepoRoot(); + + const ctx = { + opts, configDir, repoRoot, + say: (s) => process.stdout.write(c.orange(s) + '\n'), + note: (s) => process.stdout.write(c.dim(s) + '\n'), + warn: (s) => process.stderr.write(c.red(s) + '\n'), + ok: (s) => process.stdout.write(c.green(s) + '\n'), + results: { installed: [], skipped: [], failed: [], detected: 0 }, + }; + + if (opts.uninstall) { uninstall(ctx); return 0; } + + ctx.say('🪨 caveman installer'); + ctx.note(` ${REPO}`); + if (opts.dryRun) ctx.note(' (dry run — nothing will be written)'); + process.stdout.write('\n'); + + // Detect everything once + const detected = PROVIDERS.filter(p => detectMatch(p.detect)); + + // TTY-only multi-select prompt when no --only and no --non-interactive. + if (opts.only.length === 0 && !opts.nonInteractive) { + const picks = await promptForOnly(detected); + if (picks) opts.only = picks; + } + + const want = (id) => opts.only.length === 0 || opts.only.includes(id); + const explicit = (id) => opts.only.includes(id); + + // Run installs in declared order. Soft providers (no reliable detect probe) + // are auto-skipped — user must opt in via `--only `. Stops the installer + // from firing `npx skills add ...` against agents the user never installed + // just because some other tool created `~/.foo` along the way. + for (const prov of PROVIDERS) { + if (!want(prov.id)) continue; + if (prov.soft && !explicit(prov.id)) continue; + // Auto-detect mode: skip providers we can't see. With --only the user + // is explicitly opting in, so trust them and let the per-provider installer + // bail itself if its preconditions aren't met (e.g. opencode bails when + // no repo clone is available; openclaw bails when the workspace dir is + // missing without --force). + if (!explicit(prov.id) && !detectMatch(prov.detect)) continue; + if (prov.id === 'claude') { await installClaude(ctx); continue; } + if (prov.id === 'gemini') { installGemini(ctx); continue; } + if (prov.id === 'opencode') { installOpencode(ctx); continue; } + if (prov.id === 'openclaw') { installOpenclaw(ctx); continue; } + if (prov.id === 'hermes') { installHermes(ctx); continue; } + if (prov.profile) { installViaSkills(ctx, prov); continue; } + } + + // Auto-detect fallback if nothing matched + if (!opts.skipSkills && opts.only.length === 0 && ctx.results.detected === 0) { + ctx.say('→ no known agents detected — running npx-skills auto-detect fallback'); + // --yes --all for the same reason as installViaSkills above (issue #370): + // skip the interactive skill picker so curl|bash actually installs. + const r = runSpawn('npx', ['-y', 'skills', 'add', REPO, '--yes', '--all'], null, opts.dryRun); + if (spawnOk(r)) ctx.results.installed.push('skills-auto'); + else ctx.results.failed.push(['skills-auto', 'npx skills add (auto) failed']); + process.stdout.write('\n'); + } + + // Per-repo init + if (opts.withInit) { + ctx.say(`→ writing per-repo IDE rule files into ${process.cwd()} (--with-init)`); + if (await runInit(ctx)) ctx.results.installed.push(`caveman-init (${process.cwd()})`); + else ctx.results.failed.push(['caveman-init', 'src/tools/caveman-init.js failed']); + process.stdout.write('\n'); + } else if (ctx.results.installed.length || ctx.results.skipped.length) { + ctx.note(' tip: re-run inside a repo with --all (or --with-init) to also write per-repo'); + ctx.note(' Cursor/Windsurf/Cline/Copilot/AGENTS.md rule files.'); + } + + // Summary + process.stdout.write('\n'); + ctx.say('🪨 done'); + if (ctx.results.installed.length) { + ctx.ok(' installed:'); + for (const a of ctx.results.installed) process.stdout.write(` • ${a}\n`); + } + if (ctx.results.skipped.length) { + process.stdout.write(' skipped:\n'); + for (const [id, why] of ctx.results.skipped) process.stdout.write(` • ${id} — ${why}\n`); + } + if (ctx.results.failed.length) { + ctx.warn(' failed:'); + for (const [id, why] of ctx.results.failed) process.stderr.write(` • ${id} — ${why}\n`); + } + if (!ctx.results.installed.length && !ctx.results.skipped.length && !ctx.results.failed.length) { + process.stdout.write(' nothing detected. run with --list to see all 30+ supported agents,\n'); + process.stdout.write(' or pass --only to force a specific target.\n'); + } + process.stdout.write('\n'); + ctx.note(" start any session and say 'caveman mode', or run /caveman in Claude Code"); + ctx.note(' measure what caveman save you: run /caveman-stats (numbers are estimates)'); + ctx.note(' verified team savings coming soon — join waitlist: https://caveman.so'); + ctx.note(` uninstall: npx -y github:${REPO} -- --uninstall`); + + // Exit code: nonzero only if every detected agent failed + if (ctx.results.detected > 0 && !ctx.results.installed.length && !ctx.results.skipped.length) return 1; + return 0; +} + +main().then(code => process.exit(code || 0)) + .catch(err => { process.stderr.write((err && err.stack || String(err)) + '\n'); process.exit(1); }); diff --git a/bin/lib/openclaw.js b/bin/lib/openclaw.js new file mode 100644 index 0000000..72f6cab --- /dev/null +++ b/bin/lib/openclaw.js @@ -0,0 +1,297 @@ +// caveman → OpenClaw install / uninstall helper. +// +// OpenClaw is a self-hosted gateway that orchestrates Claude Code, Codex, +// Pi, OpenCode, and others. It has its own workspace + skills system at +// ~/.openclaw/workspace/. Skills there appear in a compact list and are +// loaded on-demand by the model — they are NOT injected as system prompt +// each turn. The bootstrap files (AGENTS.md, SOUL.md, TOOLS.md, MEMORY.md) +// ARE injected each turn under "Project Context", subject to a 12K-per-file +// and 60K-total cap. +// +// To make caveman always-on through OpenClaw, we do two writes: +// 1. Drop a copy of skills/caveman/SKILL.md into /skills/caveman/ +// with OpenClaw-required frontmatter (`version`, `always: true`) merged +// in. Makes the skill discoverable via `openclaw skills list` and lets +// the orchestrated agent `read` it on demand. +// 2. Append a tiny marker-fenced bootstrap snippet to /SOUL.md +// pointing the agent at the skill. SOUL.md is auto-injected each turn, +// so this is what actually drives always-on behavior. +// +// Idempotent on both writes. Uninstall removes the skill folder and strips +// the marker block from SOUL.md while preserving any user-authored content. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SKILL_NAME = 'caveman'; +const SKILL_VERSION = '1.0.0'; +const MARK_BEGIN = ''; +const MARK_END = ''; +const SOUL_FILE = 'SOUL.md'; + +function resolveWorkspace(env = process.env) { + if (env.OPENCLAW_WORKSPACE) return path.resolve(env.OPENCLAW_WORKSPACE); + return path.join(os.homedir(), '.openclaw', 'workspace'); +} + +function readIfExists(p) { + try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; } +} + +// ── Frontmatter helpers ─────────────────────────────────────────────────── +// Lightweight YAML merge — we only need to insert `version` and `always` if +// they're absent. Avoids pulling in a YAML dep for a job this small. The +// caveman SKILL.md uses block-scalar `description: >`, which a naive split +// would mangle — but since we're only ever appending top-level keys (never +// editing existing ones), a string-prepend after the leading `---\n` is safe. + +function splitFrontmatter(src) { + if (!src.startsWith('---\n') && !src.startsWith('---\r\n')) { + return { frontmatter: '', body: src }; + } + const after = src.slice(src.indexOf('\n') + 1); + const endRe = /(^|\n)---\s*(\r?\n|$)/; + const m = endRe.exec(after); + if (!m) return { frontmatter: '', body: src }; + const fmEnd = m.index + (m[1] ? 1 : 0); + const fm = after.slice(0, fmEnd); + const rest = after.slice(m.index + m[0].length); + return { frontmatter: fm, body: rest }; +} + +function frontmatterHasKey(fm, key) { + const re = new RegExp('(^|\\n)' + key + '\\s*:', 'i'); + return re.test(fm); +} + +function mergeOpenclawFrontmatter(src) { + const { frontmatter, body } = splitFrontmatter(src); + const additions = []; + if (!frontmatterHasKey(frontmatter, 'name')) additions.push(`name: ${SKILL_NAME}`); + if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${SKILL_VERSION}`); + if (!frontmatterHasKey(frontmatter, 'always')) additions.push('always: true'); + if (additions.length === 0 && frontmatter) return src; + const fmBody = (frontmatter ? frontmatter.trimEnd() + '\n' : '') + additions.join('\n') + (additions.length ? '\n' : ''); + return '---\n' + fmBody + '---\n' + body; +} + +// ── Bootstrap snippet load ──────────────────────────────────────────────── +function loadBootstrapSnippet(repoRoot) { + if (repoRoot) { + const p = path.join(repoRoot, 'src', 'rules', 'caveman-openclaw-bootstrap.md'); + const body = readIfExists(p); + if (body) return body.endsWith('\n') ? body : body + '\n'; + } + // Standalone fallback (curl|node case where there's no repo on disk). + // Keep this in sync with src/rules/caveman-openclaw-bootstrap.md. + return [ + MARK_BEGIN, + '## Caveman mode (always on)', + '', + 'Respond terse like smart caveman. All technical substance stay. Only fluff die.', + '', + "The full ruleset and intensity levels live in this workspace's caveman skill:", + '', + ' skills/caveman/SKILL.md', + '', + 'Default intensity: `full`. Switch with `/caveman lite|full|ultra|wenyan`.', + 'Stop with: "stop caveman" / "normal mode" / "deactivate caveman".', + '', + 'Auto-Clarity: drop caveman for security warnings, irreversible action', + 'confirmations, multi-step sequences where fragments risk misread, or when', + 'user is confused or repeating. Resume after.', + '', + 'Boundaries: code, commit messages, and PR descriptions stay normal prose.', + MARK_END, + '', + ].join('\n'); +} + +function loadSkillBody(repoRoot) { + if (!repoRoot) return null; + return readIfExists(path.join(repoRoot, 'skills', 'caveman', 'SKILL.md')); +} + +// ── SOUL.md marker-block append/strip ───────────────────────────────────── +// +// Damage tolerance (#596): a stray or truncated marker (interrupted write, +// partial user edit) used to chain into data loss — append saw "no complete +// block" and added a SECOND block; strip then cut from the FIRST begin to the +// FIRST end, which spanned all user content between the stray marker and the +// appended block. The scan below pairs each begin with the nearest end BEFORE +// the next begin; an unpaired marker is removed as just the marker itself, +// never as a span over user content. + +function stripAllBootstrapBlocks(text) { + let result = ''; + let found = false; + let i = 0; + while (i < text.length) { + const b = text.indexOf(MARK_BEGIN, i); + if (b === -1) { result += text.slice(i); break; } + result += text.slice(i, b); + found = true; + const nextB = text.indexOf(MARK_BEGIN, b + MARK_BEGIN.length); + const e = text.indexOf(MARK_END, b + MARK_BEGIN.length); + if (e !== -1 && (nextB === -1 || e < nextB)) { + i = e + MARK_END.length; // well-formed block — drop begin..end inclusive + } else { + i = b + MARK_BEGIN.length; // orphan begin — drop only the marker itself + } + // Collapse the blank-line scar around the cut (same cosmetic rule the + // old single-cut code applied): keep at most one newline on each side. + result = result.replace(/\n+$/, '\n'); + const lead = /^\n+/.exec(text.slice(i)); + if (lead) i += lead[0].length - (result ? 1 : 0); + } + // Orphan end markers (begin already gone or never written) — drop marker only. + while (result.includes(MARK_END)) { found = true; result = result.replace(MARK_END, ''); } + return { next: result, found }; +} + +function appendBootstrapToSoul(soulPath, snippet) { + const existing = readIfExists(soulPath); + const count = (s, sub) => s.split(sub).length - 1; + let base = existing; + let repaired = false; + if (existing) { + const nb = count(existing, MARK_BEGIN); + const ne = count(existing, MARK_END); + if (nb === 1 && ne === 1 && existing.indexOf(MARK_END) > existing.indexOf(MARK_BEGIN)) { + return { changed: false, reason: 'already present' }; + } + if (nb > 0 || ne > 0) { + // Damaged markers — strip them safely first, then append one clean block. + base = stripAllBootstrapBlocks(existing).next; + repaired = true; + } + } + let next; + if (base && base.length) { + const sep = base.endsWith('\n\n') ? '' : (base.endsWith('\n') ? '\n' : '\n\n'); + next = base + sep + snippet; + } else { + next = snippet; + } + fs.writeFileSync(soulPath, next, { mode: 0o644 }); + return repaired ? { changed: true, repaired: true } : { changed: true }; +} + +function stripBootstrapFromSoul(soulPath) { + const existing = readIfExists(soulPath); + if (!existing) return { changed: false, reason: 'no SOUL.md' }; + const { next: stripped, found } = stripAllBootstrapBlocks(existing); + if (!found) return { changed: false, reason: 'no marker block' }; + let next = stripped.trimEnd(); + next = next ? next + '\n' : ''; + if (next === '') { + // SOUL.md only contained our block — remove the file so OpenClaw doesn't + // bootstrap an empty section every turn. + try { fs.unlinkSync(soulPath); } catch (_) {} + return { changed: true, removed: true }; + } + fs.writeFileSync(soulPath, next, { mode: 0o644 }); + return { changed: true }; +} + +// ── Public API ──────────────────────────────────────────────────────────── +function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog() } = {}) { + const ws = workspace || resolveWorkspace(); + const skillBody = loadSkillBody(repoRoot); + if (!skillBody) { + log.warn(' openclaw install requires the caveman repo on disk (skills/caveman/SKILL.md missing).'); + log.note(' Re-run from a clone or via `npx -y github:JuliusBrussee/caveman -- --only openclaw`.'); + return { ok: false, reason: 'repo not available' }; + } + const snippet = loadBootstrapSnippet(repoRoot); + + if (!fs.existsSync(ws)) { + if (!force) { + log.warn(` openclaw workspace not found at ${ws}.`); + log.note(' Either install OpenClaw (https://openclaw.ai) and re-run, or pass --force to mkdir.'); + return { ok: false, reason: 'workspace missing' }; + } + if (!dryRun) fs.mkdirSync(ws, { recursive: true }); + } + + const skillDir = path.join(ws, 'skills', SKILL_NAME); + const skillFile = path.join(skillDir, 'SKILL.md'); + const soulFile = path.join(ws, SOUL_FILE); + + if (dryRun) { + log.note(` would write ${skillFile} (with version/always frontmatter)`); + log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`); + return { ok: true, dryRun: true }; + } + + fs.mkdirSync(skillDir, { recursive: true }); + const merged = mergeOpenclawFrontmatter(skillBody); + fs.writeFileSync(skillFile, merged, { mode: 0o644 }); + log.write(` installed: ${skillFile}\n`); + + const soul = appendBootstrapToSoul(soulFile, snippet); + if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`); + else log.note(` ${soulFile} already contains caveman bootstrap`); + + return { ok: true }; +} + +function uninstallOpenclaw({ workspace, dryRun = false, log = noopLog() } = {}) { + const ws = workspace || resolveWorkspace(); + const skillDir = path.join(ws, 'skills', SKILL_NAME); + const soulFile = path.join(ws, SOUL_FILE); + + let touched = false; + + if (fs.existsSync(skillDir)) { + if (dryRun) { + log.note(` would remove ${skillDir}/`); + } else { + try { fs.rmSync(skillDir, { recursive: true, force: true }); } catch (_) {} + log.note(` removed ${skillDir}`); + } + touched = true; + } + + if (fs.existsSync(soulFile)) { + if (dryRun) { + log.note(` would strip caveman block from ${soulFile}`); + touched = true; + } else { + const r = stripBootstrapFromSoul(soulFile); + if (r.changed) { + log.note(r.removed ? ` removed ${soulFile}` : ` stripped caveman block from ${soulFile}`); + touched = true; + } + } + } + + return { ok: true, touched }; +} + +function noopLog() { + return { + write: (_) => {}, + note: (_) => {}, + warn: (_) => {}, + }; +} + +module.exports = { + installOpenclaw, + uninstallOpenclaw, + resolveWorkspace, + // exported for tests + mergeOpenclawFrontmatter, + splitFrontmatter, + appendBootstrapToSoul, + stripBootstrapFromSoul, + loadBootstrapSnippet, + MARK_BEGIN, + MARK_END, + SKILL_NAME, + SKILL_VERSION, +}; diff --git a/bin/lib/opencode-agent.js b/bin/lib/opencode-agent.js new file mode 100644 index 0000000..1e052dc --- /dev/null +++ b/bin/lib/opencode-agent.js @@ -0,0 +1,42 @@ +'use strict'; + +// Strip the `tools:` field from a Claude-Code-style subagent frontmatter so +// the file is valid for opencode, whose schema rejects the YAML array form +// (`tools: [Read, Grep, Bash]`) with: +// +// Configuration is invalid at .../agents/cavecrew-reviewer.md +// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools +// +// opencode allows `tools` to be a map (`{read: true, grep: true}`) or +// omitted entirely. Omitting falls back to opencode's default tool set, +// which is what the cavecrew subagent prompts already self-restrict against +// in their body ("Read-only locator", "No `Bash` available", etc.), so +// dropping the array form is safe. + +const TOOLS_FIELD_RE = /^tools[ \t]*:/; +const CONTINUATION_RE = /^[ \t]/; +const FRONTMATTER_FENCE = '---\n'; + +function stripOpencodeAgentTools(content) { + if (typeof content !== 'string' || !content.startsWith(FRONTMATTER_FENCE)) return content; + const fmEnd = content.indexOf('\n---', FRONTMATTER_FENCE.length); + if (fmEnd < 0) return content; + + const fm = content.slice(FRONTMATTER_FENCE.length, fmEnd); + const rest = content.slice(fmEnd); + + const out = []; + let dropping = false; + for (const line of fm.split('\n')) { + if (dropping) { + if (CONTINUATION_RE.test(line)) continue; + dropping = false; + } + if (TOOLS_FIELD_RE.test(line)) { dropping = true; continue; } + out.push(line); + } + + return FRONTMATTER_FENCE + out.join('\n') + rest; +} + +module.exports = { stripOpencodeAgentTools }; diff --git a/bin/lib/settings.js b/bin/lib/settings.js new file mode 100644 index 0000000..6e2c293 --- /dev/null +++ b/bin/lib/settings.js @@ -0,0 +1,361 @@ +// caveman — JSONC-tolerant settings.json read/write + defensive hook validation. +// +// Lifted in spirit from gsd-build/get-shit-done's stripJsonComments + readSettings. +// Reused by bin/install.js and (optionally) by hooks/caveman-activate.js so a +// commented settings.json no longer crashes the installer or the runtime hooks. +// +// Public API: +// readSettings(path) → object, {}, or null on hard parse failure +// writeSettings(path, obj) → atomic write with newline +// stripJsonComments(src) → string with // and /* */ stripped (string-aware) +// validateHookFields(settings) → mutates: drops malformed hook entries +// hasCavemanHook(settings, ev) → idempotency probe +// addCommandHook(settings, ev, opts) → no-op if substring marker already present +// removeCavemanHooks(settings) → uninstall helper +// +// Pure stdlib, CommonJS, Node ≥14. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +// ── stripJsonComments ────────────────────────────────────────────────────── +// Hand-rolled state machine. Tracks string state + backslash escape so a +// comment-looking sequence inside a quoted string is left alone. Removes +// trailing commas in a final pass — JSONC tolerates those, JSON.parse does not. +function stripJsonComments(src) { + if (typeof src !== 'string') return src; + let out = ''; + let i = 0; + const n = src.length; + let inString = false; + let stringChar = ''; + let inLine = false; + let inBlock = false; + while (i < n) { + const c = src[i]; + const next = i + 1 < n ? src[i + 1] : ''; + if (inLine) { + if (c === '\n') { inLine = false; out += c; } + i++; continue; + } + if (inBlock) { + if (c === '*' && next === '/') { inBlock = false; i += 2; continue; } + i++; continue; + } + if (inString) { + out += c; + if (c === '\\') { if (i + 1 < n) { out += src[i + 1]; i += 2; continue; } } + if (c === stringChar) { inString = false; } + i++; continue; + } + if (c === '"' || c === "'") { inString = true; stringChar = c; out += c; i++; continue; } + if (c === '/' && next === '/') { inLine = true; i += 2; continue; } + if (c === '/' && next === '*') { inBlock = true; i += 2; continue; } + out += c; i++; + } + return stripTrailingCommas(out); +} + +// ── stripTrailingCommas ──────────────────────────────────────────────────── +// Remove `,` when the next non-whitespace char is `}` or `]` — but only +// OUTSIDE strings. The old global regex ran over string contents too and +// silently corrupted values like `"echo ,}"` → `"echo }"` (issue #595); +// comment-stripping does not sanitize string bodies, so a string-aware scan +// is required here as well. +function stripTrailingCommas(src) { + let out = ''; + let i = 0; + const n = src.length; + let inString = false; + let stringChar = ''; + while (i < n) { + const c = src[i]; + if (inString) { + out += c; + if (c === '\\') { if (i + 1 < n) { out += src[i + 1]; i += 2; continue; } } + if (c === stringChar) inString = false; + i++; continue; + } + if (c === '"' || c === "'") { inString = true; stringChar = c; out += c; i++; continue; } + if (c === ',') { + let j = i + 1; + while (j < n && /\s/.test(src[j])) j++; + if (j < n && (src[j] === '}' || src[j] === ']')) { i++; continue; } // drop the comma + } + out += c; i++; + } + return out; +} + +// ── readSettings ─────────────────────────────────────────────────────────── +// Try strict JSON first (fast path). On failure, strip comments and retry. +// On total failure return `null` and warn — never silently overwrite a +// malformed-but-recoverable file with `{}`. +function readSettings(p) { + if (!fs.existsSync(p)) return {}; + let raw; + try { raw = fs.readFileSync(p, 'utf8'); } + catch (e) { + process.stderr.write(`caveman: cannot read ${p}: ${e.message}\n`); + return null; + } + if (!raw.trim()) return {}; + try { return JSON.parse(raw); } catch (_) { /* fall through to JSONC */ } + try { return JSON.parse(stripJsonComments(raw)); } + catch (e) { + process.stderr.write(`caveman: warning — ${p} is not valid JSON or JSONC: ${e.message}\n`); + return null; + } +} + +// ── writeSettings ────────────────────────────────────────────────────────── +// Atomic write: temp file + rename. mode 0600 (settings often contains tokens). +function writeSettings(p, obj) { + const dir = path.dirname(p); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.${path.basename(p)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`); + fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, p); +} + +// ── validateHookFields ──────────────────────────────────────────────────── +// Claude Code uses strict Zod on settings.json — a single malformed hook +// silently discards the entire file. Mutate-to-valid before write. +// +// Required shape (per Claude Code docs): +// settings.hooks[event] = [{ hooks: [{ type:'command', command:'…', timeout?:n }, ...] }, ...] +// settings.hooks[event] = [{ matcher?:'…', hooks: [...] }, ...] // also valid +function validateHookFields(settings) { + if (!settings || typeof settings !== 'object') return settings; + if (!settings.hooks || typeof settings.hooks !== 'object') return settings; + for (const ev of Object.keys(settings.hooks)) { + const arr = settings.hooks[ev]; + if (!Array.isArray(arr)) { delete settings.hooks[ev]; continue; } + settings.hooks[ev] = arr.filter(entry => { + if (!entry || typeof entry !== 'object') return false; + if (!Array.isArray(entry.hooks)) return false; + entry.hooks = entry.hooks.filter(h => { + if (!h || typeof h !== 'object') return false; + if (h.type === 'command') return typeof h.command === 'string' && h.command.length > 0; + if (h.type === 'agent') return typeof h.prompt === 'string' && h.prompt.length > 0; + return false; + }); + return entry.hooks.length > 0; + }); + if (settings.hooks[ev].length === 0) delete settings.hooks[ev]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + return settings; +} + +// ── Idempotency probe ────────────────────────────────────────────────────── +function hasCavemanHook(settings, event, marker = 'caveman') { + const arr = settings && settings.hooks && settings.hooks[event]; + if (!Array.isArray(arr)) return false; + return arr.some(e => + e && Array.isArray(e.hooks) && + e.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(marker)) + ); +} + +// ── addCommandHook ──────────────────────────────────────────────────────── +// Idempotent push. `marker` defaults to opts.command — pass an explicit +// shorter substring (e.g. the script basename) when the full command path +// might rotate across reinstalls. +function addCommandHook(settings, event, opts) { + if (!settings.hooks) settings.hooks = {}; + if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = []; + const marker = opts.marker || opts.command; + if (hasCavemanHook(settings, event, marker)) return false; + const hook = { type: 'command', command: opts.command }; + if (typeof opts.timeout === 'number') hook.timeout = opts.timeout; + if (typeof opts.statusMessage === 'string') hook.statusMessage = opts.statusMessage; + settings.hooks[event].push({ hooks: [hook] }); + return true; +} + +// ── Managed hook scripts ────────────────────────────────────────────────── +// The exact script basenames this installer wires into settings.json. Every +// helper that decides "is this hook ours?" must match against these — never +// against a bare "caveman" substring, which also matches user-authored hooks +// that merely mention the word in a path (issue #593). +const MANAGED_HOOK_BASENAMES = new Set([ + 'caveman-activate.js', + 'caveman-mode-tracker.js', + 'caveman-stats.js', + 'caveman-statusline.sh', + 'caveman-statusline.ps1', +]); + +// Split a command into shell-ish tokens, honoring single/double quotes so a +// path containing spaces survives intact. Good enough for hook commands we +// generate (`node "/a/x.js"`, `"/abs/node" "/a/x.js"`, `bash /a/x.sh`); not +// a full shell parser. +function tokenizeCommand(command) { + const out = []; + const re = /"([^"]*)"|'([^']*)'|(\S+)/g; + let m; + while ((m = re.exec(command)) !== null) out.push(m[1] ?? m[2] ?? m[3]); + return out; +} + +// True iff some token's BASENAME exactly equals a managed script name. Exact +// match — not substring — so `mycaveman-activate.js` or a user hook living +// under a `caveman-notes/` directory is never treated as ours. win32.basename +// splits on both / and \ so a settings.json written on Windows still matches +// when processed elsewhere. +function referencesManagedScript(command) { + try { + for (const tok of tokenizeCommand(command)) { + if (tok && typeof tok === 'string' && MANAGED_HOOK_BASENAMES.has(path.win32.basename(tok))) return true; + } + } catch (_) { /* malformed command — treat as not ours */ } + return false; +} + +// ── removeCavemanHooks ──────────────────────────────────────────────────── +// Strip every entry whose any hook command targets one of our managed hook +// scripts (exact basename match, see above). 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) { + 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; + return !entry.hooks.some(h => h && typeof h.command === 'string' && referencesManagedScript(h.command)); + }); + removed += before - settings.hooks[ev].length; + if (settings.hooks[ev].length === 0) delete settings.hooks[ev]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + return removed; +} + +// ── rewriteLegacyManagedHookCommands ────────────────────────────────────── +// Walk every hook command. If it's a bare `node /path/to/.js` (no +// absolute node path) and the basename is one of ours, rewrite to use +// `absoluteNode` so GUI launchers with minimal PATH still find Node. Only +// touches commands matching the exact bare-node shape — won't false-positive +// on user-authored hooks that just happen to mention "caveman". +function rewriteLegacyManagedHookCommands(settings, absoluteNode) { + if (!settings || !settings.hooks || !absoluteNode) return 0; + let rewritten = 0; + const reBare = /^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/; + for (const ev of Object.keys(settings.hooks)) { + for (const entry of settings.hooks[ev]) { + if (!entry || !Array.isArray(entry.hooks)) continue; + for (const h of entry.hooks) { + if (!h || typeof h.command !== 'string') continue; + const m = reBare.exec(h.command); + if (!m) continue; + const scriptPath = m[2] || m[3] || m[4]; + const basename = path.basename(scriptPath); + if (!MANAGED_HOOK_BASENAMES.has(basename)) continue; + h.command = `"${absoluteNode}" "${scriptPath}"`; + rewritten++; + } + } + } + return rewritten; +} + +// ── pruneOrphanedManagedHooks ───────────────────────────────────────────── +// Remove managed hook entries whose target script no longer exists on disk. +// +// Migrating an old manual install (settings.json hooks → ~/.claude/hooks/ +// caveman-*.js) to the Claude Code plugin disables/renames those local +// scripts but leaves the settings.json entries pointing at the now-missing +// file. Claude Code then runs `node ` every SessionStart / +// UserPromptSubmit and crashes with `node:…/loader:1478 — Cannot find module +// …caveman-activate.js` (issue #471). rewriteLegacyManagedHookCommands can't +// help — it only matches the bare-node shape and these orphans are usually +// absolute-node — and removeCavemanHooks runs only on uninstall. +// +// We extract the script path from any managed-looking command (bare- or +// absolute-node, quoted or not), resolve it relative to dir if not absolute, +// and drop the hook only when its target is genuinely absent. A managed hook +// whose script still exists is left untouched, so this is safe to run on +// every install. +function pruneOrphanedManagedHooks(settings, configDir) { + if (!settings || typeof settings !== 'object') return 0; + const baseDir = configDir || claudeConfigDir(); + let removed = 0; + + // A command is a missing managed target iff some token's BASENAME exactly + // equals a managed script (exact match — not substring — so a user hook like + // `mycaveman-activate.js` is never touched) and that resolved path is absent. + // Relative paths resolve against configDir; honors CLAUDE_CONFIG_DIR. Wrapped + // so a malformed command or fs error never throws out of the prune pass. + const targetMissing = (command) => { + try { + for (const tok of tokenizeCommand(command)) { + if (!tok || typeof tok !== 'string') continue; + if (!MANAGED_HOOK_BASENAMES.has(path.basename(tok))) continue; + const scriptPath = path.isAbsolute(tok) ? tok : path.join(baseDir, tok); + return !fs.existsSync(scriptPath); + } + } catch (_) { /* silent-fail: never block install on a parse/fs hiccup */ } + return false; + }; + + if (settings.hooks && typeof settings.hooks === 'object') { + // Normalize malformed shapes first so the filter below only sees valid + // entries (and a poisoned settings.json can't survive the rewrite). + validateHookFields(settings); + } + if (settings.hooks && typeof settings.hooks === 'object') { + 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 || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return true; + return !entry.hooks.some(h => h && typeof h.command === 'string' && targetMissing(h.command)); + }); + removed += before - settings.hooks[ev].length; + if (settings.hooks[ev].length === 0) delete settings.hooks[ev]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + } + + // statusLine lives outside settings.hooks. A managed statusline command + // pointing at a missing script leaves a blank statusline (cosmetic, exits + // clean) but is still stale — drop it so Claude Code falls back to default. + if (settings.statusLine && typeof settings.statusLine.command === 'string' + && targetMissing(settings.statusLine.command)) { + delete settings.statusLine; + removed++; + } + + return removed; +} + +// ── claudeConfigDir ─────────────────────────────────────────────────────── +function claudeConfigDir() { + if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR; + return path.join(os.homedir(), '.claude'); +} + +module.exports = { + stripJsonComments, + readSettings, + writeSettings, + validateHookFields, + hasCavemanHook, + addCommandHook, + removeCavemanHooks, + rewriteLegacyManagedHookCommands, + pruneOrphanedManagedHooks, + claudeConfigDir, + MANAGED_HOOK_BASENAMES, +}; diff --git a/commands/caveman-commit.md b/commands/caveman-commit.md new file mode 100644 index 0000000..d85fca3 --- /dev/null +++ b/commands/caveman-commit.md @@ -0,0 +1,5 @@ +--- +description: Generate terse caveman-style commit message +--- + +Generate a terse commit message for the current staged changes. Conventional Commits format. Subject: ≤50 chars, imperative, lowercase after type. Body: only when 'why' isn't obvious from subject. Why over what. No period on subject. diff --git a/commands/caveman-commit.toml b/commands/caveman-commit.toml new file mode 100644 index 0000000..2487bac --- /dev/null +++ b/commands/caveman-commit.toml @@ -0,0 +1,2 @@ +description = "Generate terse caveman-style commit message" +prompt = "Generate a terse commit message for the current staged changes. Conventional Commits format. Subject: ≤50 chars, imperative, lowercase after type. Body: only when 'why' isn't obvious from subject. Why over what. No period on subject." diff --git a/commands/caveman-init.md b/commands/caveman-init.md new file mode 100644 index 0000000..fb4da84 --- /dev/null +++ b/commands/caveman-init.md @@ -0,0 +1,13 @@ +--- +description: Drop the always-on caveman activation rule into the current repo for every IDE agent +argument-hint: "[--dry-run|--force] [--only ]" +--- + +Write the per-repo caveman rule files (Cursor, Windsurf, Cline, Copilot, AGENTS.md) into the current repo, then report the result. + +How to run the init script — pick the first that applies: + +1. If `src/tools/caveman-init.js` exists in the current repo (you are inside a caveman checkout), run: `node src/tools/caveman-init.js $ARGUMENTS` +2. Otherwise download and run the standalone script (it is self-contained and supports stdin execution): `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/tools/caveman-init.js | node - $ARGUMENTS` + +Use `--dry-run` first if the user did not pass `--force`, so we never silently overwrite an existing rule file. diff --git a/commands/caveman-init.toml b/commands/caveman-init.toml new file mode 100644 index 0000000..6e57247 --- /dev/null +++ b/commands/caveman-init.toml @@ -0,0 +1,2 @@ +description = "Drop the always-on caveman activation rule into the current repo for every IDE agent" +prompt = "Write the per-repo caveman rule files into the current repo and report the result. If `src/tools/caveman-init.js` exists in the current repo (a caveman checkout), run `node src/tools/caveman-init.js {{args}}`. Otherwise run the standalone script (self-contained, supports stdin execution): `curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/tools/caveman-init.js | node - {{args}}`. Use --dry-run first if the user did not pass --force, so we never silently overwrite an existing rule file." diff --git a/commands/caveman-review.md b/commands/caveman-review.md new file mode 100644 index 0000000..9fa94dc --- /dev/null +++ b/commands/caveman-review.md @@ -0,0 +1,5 @@ +--- +description: One-line code review comments +--- + +Review the current code changes. One-line per finding. Format: L: . . Severity: bug, risk, nit, q. Skip praise. Skip obvious. If code look good, say 'LGTM' and stop. diff --git a/commands/caveman-review.toml b/commands/caveman-review.toml new file mode 100644 index 0000000..6dccf3f --- /dev/null +++ b/commands/caveman-review.toml @@ -0,0 +1,2 @@ +description = "One-line code review comments" +prompt = "Review the current code changes. One-line per finding. Format: L: . . Severity: bug, risk, nit, q. Skip praise. Skip obvious. If code look good, say 'LGTM' and stop." diff --git a/commands/caveman-stats.md b/commands/caveman-stats.md new file mode 100644 index 0000000..af3675e --- /dev/null +++ b/commands/caveman-stats.md @@ -0,0 +1,6 @@ +--- +description: Real session token usage + lifetime savings + USD. Tweetable line via --share. +argument-hint: "[--share|--all|--since 7d]" +--- + +/caveman-stats $ARGUMENTS diff --git a/commands/caveman-stats.toml b/commands/caveman-stats.toml new file mode 100644 index 0000000..52f52fb --- /dev/null +++ b/commands/caveman-stats.toml @@ -0,0 +1,2 @@ +description = "Real session token usage + lifetime savings + USD. Tweetable line via --share." +prompt = "/caveman-stats {{args}}" diff --git a/commands/caveman.md b/commands/caveman.md new file mode 100644 index 0000000..b8975f1 --- /dev/null +++ b/commands/caveman.md @@ -0,0 +1,6 @@ +--- +description: Switch caveman intensity level (lite/full/ultra/wenyan) +argument-hint: "[lite|full|ultra|wenyan]" +--- + +Switch to caveman $ARGUMENTS mode. If no level specified, use full. Respond terse like smart caveman — drop articles, filler, pleasantries. Fragments OK. Technical terms exact. Code unchanged. Pattern: [thing] [action] [reason]. [next step]. diff --git a/commands/caveman.toml b/commands/caveman.toml new file mode 100644 index 0000000..4707259 --- /dev/null +++ b/commands/caveman.toml @@ -0,0 +1,2 @@ +description = "Switch caveman intensity level (lite/full/ultra/wenyan)" +prompt = "Switch to caveman {{args}} mode. If no level specified, use full. Respond terse like smart caveman — drop articles, filler, pleasantries. Fragments OK. Technical terms exact. Code unchanged. Pattern: [thing] [action] [reason]. [next step]." diff --git a/dist/caveman.skill b/dist/caveman.skill new file mode 100644 index 0000000..2662f04 Binary files /dev/null and b/dist/caveman.skill differ diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/.nojekyll @@ -0,0 +1 @@ + diff --git a/docs/HONEST-NUMBERS.md b/docs/HONEST-NUMBERS.md new file mode 100644 index 0000000..f58126e --- /dev/null +++ b/docs/HONEST-NUMBERS.md @@ -0,0 +1,47 @@ +# Honest Numbers + +Caveman save tokens sometimes. Caveman cost tokens sometimes. This page say which is which, with the real numbers. No marketing. If caveman lose for your workload, this page tell you to turn it off. + +## What caveman actually does + +Caveman is a system-prompt skill. It makes the model **write shorter output**. That is the whole mechanism. It does not compress your input, your context, your files, or the model's thinking tokens. + +## The measured numbers + +| What | Number | How measured | Source | +|---|---|---|---| +| Output reduction vs default verbose replies | **65% average** (range 22–87%) | Real Claude API token counts, 10 prompts | [`benchmarks/`](../benchmarks/) | +| Input reduction from the skill | **0%** | It's an output-style instruction | — | +| Input cost the skill *adds* | **~1–1.5k tokens per turn** | SKILL.md rules (~5 KB) injected into context, plus skill-list entries | [`skills/caveman/SKILL.md`](../skills/caveman/SKILL.md) | +| `/caveman-compress` on memory files | ~46% average input reduction, per session, for those files only | Real files, token counts in README table | [README](../README.md#benchmarks) | + +These figures are output tokens only — the skill does not compress your input, your context, your files, or the model's thinking tokens. The full eval harness and its correction history are documented in [`evals/README.md`](../evals/README.md). + +## When caveman wins + +- **Long chatty outputs.** Explanations, architecture discussions, code review, docs, debugging walkthroughs — anywhere the model would write 1k+ output tokens per reply. This is where the 50–87% cuts happen. +- **Long sessions with verbose agents.** The per-reply savings compound; the fixed ~1–1.5k/turn rule cost stays flat. +- **Reading speed.** Shorter replies finish sooner and you read them faster. For many users this, not cost, is the real win. + +## When caveman loses (net-negative) + +Plainly: **the skill costs ~1–1.5k input tokens every turn. If it saves less output than that, you are paying to use it.** + +- **Terse coding Q&A** ([#145](https://github.com/JuliusBrussee/caveman/issues/145)). If your normal replies are ~150 output tokens, caveman saves maybe 70–100 of them and costs ~1k+ of input overhead per turn. Net loss. The user in #145 measured exactly this. They were right. +- **Agents that bill by request or credit, not tokens** ([#506](https://github.com/JuliusBrussee/caveman/issues/506)). GitHub Copilot charges premium *requests*. A shorter answer is the same request. Caveman cannot lower your Copilot credit use. Same logic for any per-message pricing. +- **Session-level totals** are always smaller than the output-reduction headline, because input tokens (your prompts, your context, your files, the injected rules) dwarf output tokens in agentic coding. Independent session-level measurements land around **14–21% total savings** on output-heavy workloads — and below zero on terse ones. +- **Some tool-side counters go the wrong way** ([#550](https://github.com/JuliusBrussee/caveman/issues/550)). One Cursor A/B showed 4.3M tokens with caveman vs 1M without, and double the wall-clock time. We could not reproduce the exact run, but the honest reading is: rule re-injection, retries, and cache/context accounting can swamp output savings in some agents. If your A/B looks like that, caveman is net-negative for you. Turn it off. Wanting the rock to work does not make the rock work. + +## Measure it yourself + +1. **`/caveman-stats`** (Claude Code) reads your real session log and prints actual input/output token counts. The "saved" line is an **estimate**: it extrapolates what the output would have been without caveman using the benchmark ratio. Real usage, estimated baseline — the output labels it `est.` for exactly that reason. +2. **The only fully honest test is an A/B**: run the same task with and without caveman and compare your provider's own usage/billing page. That number outranks anything this repo prints. +3. **Reproduce our numbers**: `benchmarks/run.py` (needs an Anthropic key) and `evals/measure.py` (offline, reads the committed snapshot). + +## Rule of thumb + +> Normal reply longer than ~1.5–2k output tokens → caveman probably saves you money. +> Normal reply shorter than that, or you pay per request → caveman probably costs you money. +> Either way, caveman replies faster to read. That part is free. + +Found a workload where our numbers are wrong? [Open an issue](https://github.com/JuliusBrussee/caveman/issues) with the A/B. We will put it on this page. diff --git a/docs/assets/atlas-cloud-dark.svg b/docs/assets/atlas-cloud-dark.svg new file mode 100644 index 0000000..56be9cb --- /dev/null +++ b/docs/assets/atlas-cloud-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/assets/atlas-cloud.svg b/docs/assets/atlas-cloud.svg new file mode 100644 index 0000000..a16d202 --- /dev/null +++ b/docs/assets/atlas-cloud.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/assets/caveman-logo-banner.png b/docs/assets/caveman-logo-banner.png new file mode 100644 index 0000000..704b6b8 Binary files /dev/null and b/docs/assets/caveman-logo-banner.png differ diff --git a/docs/assets/dancing-rock-32.png b/docs/assets/dancing-rock-32.png new file mode 100644 index 0000000..602dc09 Binary files /dev/null and b/docs/assets/dancing-rock-32.png differ diff --git a/docs/assets/dancing-rock.svg b/docs/assets/dancing-rock.svg new file mode 100644 index 0000000..e5b1f13 --- /dev/null +++ b/docs/assets/dancing-rock.svg @@ -0,0 +1,7 @@ + + + Caveman Rock + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..2a542e1 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,452 @@ + + + + + +Caveman | Lithic Token Compression + + + + + + + +
+
+
+ +
+
STATUS: OPTIMIZED
+
PHYSICAL_IMPACTS: 0
+
TOKENS_PURGED: 65%
+
+ +
+
+ + + JuliusBrussee / caveman + +

Few tokens.
Do trick.

+

A semantic constraint engine for Claude Code & Codex. Forces agentic communication into minimal-token lithic structures. Retain 100% technical accuracy while destroying up to 87% of output latency.

+ +
+ + npx skills add JuliusBrussee/caveman + +
+
+
+ +
+
+
+
+ +
+ 01 // Compression Matrix +

The Lithic Payload Diff

+

Visualize the exact tokens purged by the Caveman protocol. Technical imperatives are retained; conversational padding is annihilated.

+ +
+
+ Payload: React Re-render Diagnosis + 69 Tokens → 19 Tokens +
+
+ "The reason your React component is re-rendering is likely because you're creating a New object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a = new ref = re-render. I'd recommend using Wrap in useMemo to memoize the object." +
+
+ + +
+
+
+ +
+ 02 // Empirical Data +

Token Latency Reduction

+

Average reduction of 65% across standard software engineering tasks. Reasoning tokens remain unaffected; only the output payload is compressed.

+ +
+
+
Explain React re-render bug
+
+
+
159/1180 87%
+
+
+
+
Fix auth token expiry
+
+
+
121/704 83%
+
+
+
+
Debug PostgreSQL race condition
+
+
+
232/1200 81%
+
+
+
+
Explain git rebase vs merge
+
+
+
292/702 58%
+
+
+
+
Review PR for security
+
+
+
398/678 41%
+
+
+
+
+ +
+ 03 // Lexical Ruleset +

Execution Architecture

+ +
+
+
💀
+
Pleasantry Annihilation
+
Standard LLMs waste compute on sycophantic hedging: "I'd be happy to help," or "It might be worth considering." Caveman protocol identifies and destroys these patterns at the generation layer.
+
+
+
✍️
+
Code Preservation
+
Code blocks, Git commits, and PR descriptions bypass the lithic filter. Caveman writes normal code.
+
+
+
🧠
+
Nomenclature Integrity
+
Technical jargon is retained precisely. "Polymorphism" remains "polymorphism." Caveman not stupid. Caveman efficient.
+
+
+
🔬
+
Scientific Backing
+
Based on the March 2026 paper "Brevity Constraints Reverse Performance Hierarchies in Language Models". Forcing brevity improves accuracy by 26 percentage points on key benchmarks. Less word = more correct.
+
+
+
+ +
+ 04 // Interface +

Initialize Protocol

+ +
+
+
+
+
+
System ready. Establishing connection to Claude Code Env...
+
Installing plugins via marketplace...
+
claude plugin install caveman@caveman
+
Trigger the protocol:
+
/caveman
+
Me ready.
+ +
+ + +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/docs/install-windows.md b/docs/install-windows.md new file mode 100644 index 0000000..8ab5402 --- /dev/null +++ b/docs/install-windows.md @@ -0,0 +1,59 @@ +# 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 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" } +$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 (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 + +# Create or update marketplace.json with the caveman entry +New-Item -ItemType Directory -Path $MarketplaceDir -Force | Out-Null +if (Test-Path $MarketplaceFile) { + $marketplace = Get-Content $MarketplaceFile -Raw | ConvertFrom-Json +} else { + $marketplace = [pscustomobject]@{} +} +if (-not ($marketplace.PSObject.Properties.Name -contains "plugins")) { + $marketplace | Add-Member -NotePropertyName plugins -NotePropertyValue ([pscustomobject]@{}) +} +$plugins = [ordered]@{} +foreach ($p in $marketplace.plugins.PSObject.Properties) { $plugins[$p.Name] = $p.Value } +$plugins["caveman"] = [ordered]@{ name = "caveman"; source = "JuliusBrussee/caveman"; version = "main" } +$marketplace.plugins = [pscustomobject]$plugins +$marketplace | ConvertTo-Json -Depth 10 | Set-Content -Path $MarketplaceFile -Encoding UTF8 +``` + +Verify: `Test-Path "$PluginSkillDir\SKILL.md"` should print `True`. Restart Claude Code, then run `/caveman` to confirm the skill loads. + +## Codex on Windows + +1. Enable symlinks first: `git config --global core.symlinks true` (requires Developer Mode or admin). +2. Clone repo → Open VS Code → Codex Settings → Plugins → find "Caveman" under the local marketplace → Install → Reload Window. +3. Codex hooks are currently disabled on Windows, so use `$caveman` to start the mode manually each session. + +## `npx skills` symlink fallback + +`npx skills` uses symlinks by default. If symlinks fail, add `--copy`: + +```powershell +npx skills add JuliusBrussee/caveman --copy +``` + +## Want it always on (any agent)? + +Paste this into the agent's system prompt or rules file: + +``` +Terse like caveman. Technical substance exact. Only fluff die. +Drop: articles, filler (just/really/basically), pleasantries, hedging. +Fragments OK. Short synonyms. Code unchanged. +Pattern: [thing] [action] [reason]. [next step]. +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. +Code/commits/PRs: normal. Off: "stop caveman" / "normal mode". +``` diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..5752014 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,84 @@ +# Evals + +Measures real token compression of caveman skills by running the same +prompts through Claude Code under three conditions and comparing the +generated output token counts. + +## The three arms + +| Arm | System prompt | +|-----|--------------| +| `__baseline__` | none | +| `__terse__` | `Answer concisely.` | +| `` | `Answer concisely.\n\n{SKILL.md}` | + +The honest delta for any skill is **`` vs `__terse__`** — i.e. +how much the skill itself adds on top of a plain "be terse" instruction. +Comparing a skill to the no-system-prompt baseline conflates the skill +with the generic terseness ask, which is what an earlier version of +this harness did and is why its numbers were inflated. + +## Why this design + +- **Real LLM output**, not hand-written examples (no circularity). +- **Same Claude Code** the skills target — no separate API key. +- **Snapshot committed to git** so CI runs are deterministic and free, + and so any change to the numbers is reviewable as a diff. +- **Control arm** isolates the skill's contribution from the generic + "be terse" effect. + +## Files + +- `prompts/en.txt` — fixed list of dev questions, one per line. +- `llm_run.py` — runs `claude -p --system-prompt …` per (prompt, arm), + captures real LLM output, writes `snapshots/results.json` along with + metadata (model, CLI version, generation timestamp). +- `measure.py` — reads the snapshot, counts tokens with tiktoken + `o200k_base`, prints a markdown table with median / mean / min / max / + stdev across prompts. +- `snapshots/results.json` — committed source of truth, regenerated only + when SKILL.md files or prompts change. + +## Refresh the snapshot (requires `claude` CLI logged in) + +```bash +uv run python evals/llm_run.py +``` + +This calls Claude once per prompt × (N skills + 2 control arms). Use +a small model to keep it cheap: + +```bash +CAVEMAN_EVAL_MODEL=claude-haiku-4-5 uv run python evals/llm_run.py +``` + +## Read the snapshot (no LLM, no API key, runs in CI) + +```bash +uv run --with tiktoken python evals/measure.py +``` + +## Adding a prompt + +Append a line to `prompts/en.txt`, then refresh the snapshot. + +## Adding a skill + +Drop a `skills//SKILL.md`, then refresh the snapshot. `llm_run.py` +picks up every skill directory automatically. + +## What this does NOT measure + +- **Fidelity** — does the compressed answer preserve the technical + claims? A skill that replies `k` to everything would score −99% and + "win". A future v2 could add a judge-model rubric. +- **Latency or cost** — out of scope. Note that skills add input tokens + on every call, so output savings are not the full economic picture. +- **Cross-model behavior** — only the model used to generate the + snapshot is measured. +- **Exact Claude tokens** — `tiktoken o200k_base` is OpenAI's BPE and is + only an approximation of Claude's tokenizer. Ratios between arms are + meaningful; absolute numbers are approximate. +- **Statistical significance** — single run per (prompt, arm) at default + temperature. The min/max/stdev columns let you eyeball whether a + number is solid or noisy, but this is not a powered experiment. diff --git a/evals/llm_run.py b/evals/llm_run.py new file mode 100644 index 0000000..795ef07 --- /dev/null +++ b/evals/llm_run.py @@ -0,0 +1,105 @@ +""" +Run each prompt through Claude Code in three conditions and snapshot the +real LLM outputs: + + 1. baseline — no extra system prompt at all + 2. terse — system prompt: "Answer concisely." + 3. terse+skill — system prompt: "Answer concisely.\n\n{SKILL.md}" + +The honest delta is (3) vs (2): how much does the SKILL itself add on top +of a plain "be terse" instruction? Comparing (3) vs (1) conflates the +skill with the generic terseness ask, which is what the previous version +of this harness did. + +This is the source-of-truth generator. It calls a real LLM and produces +evals/snapshots/results.json. Run it locally when SKILL.md files change. +The CI-side `measure.py` only reads the snapshot and counts tokens. + +Requires: + - `claude` CLI on PATH (Claude Code), authenticated + +Run: uv run python evals/llm_run.py + +Environment: + CAVEMAN_EVAL_MODEL optional --model flag value passed through to claude +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +import subprocess +from pathlib import Path + +EVALS = Path(__file__).parent +SKILLS = EVALS.parent / "skills" +PROMPTS = EVALS / "prompts" / "en.txt" +SNAPSHOT = EVALS / "snapshots" / "results.json" + +TERSE_PREFIX = "Answer concisely." + + +def run_claude(prompt: str, system: str | None = None) -> str: + cmd = ["claude", "-p"] + if system: + cmd += ["--system-prompt", system] + if model := os.environ.get("CAVEMAN_EVAL_MODEL"): + cmd += ["--model", model] + cmd.append(prompt) + out = subprocess.run(cmd, capture_output=True, text=True, check=True) + return out.stdout.strip() + + +def claude_version() -> str: + try: + out = subprocess.run( + ["claude", "--version"], capture_output=True, text=True, check=True + ) + return out.stdout.strip() + except Exception: + return "unknown" + + +def main() -> None: + prompts = [p.strip() for p in PROMPTS.read_text().splitlines() if p.strip()] + skills = sorted(p.name for p in SKILLS.iterdir() if (p / "SKILL.md").exists()) + + print( + f"=== {len(prompts)} prompts × ({len(skills)} skills + 2 control arms) ===", + flush=True, + ) + + snapshot: dict = { + "metadata": { + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "claude_cli_version": claude_version(), + "model": os.environ.get("CAVEMAN_EVAL_MODEL", "default"), + "n_prompts": len(prompts), + "terse_prefix": TERSE_PREFIX, + }, + "prompts": prompts, + "arms": {}, + } + + print("baseline (no system prompt)", flush=True) + snapshot["arms"]["__baseline__"] = [run_claude(p) for p in prompts] + + print("terse (control: terse instruction only, no skill)", flush=True) + snapshot["arms"]["__terse__"] = [ + run_claude(p, system=TERSE_PREFIX) for p in prompts + ] + + for skill in skills: + skill_md = (SKILLS / skill / "SKILL.md").read_text() + system = f"{TERSE_PREFIX}\n\n{skill_md}" + print(f" {skill}", flush=True) + snapshot["arms"][skill] = [run_claude(p, system=system) for p in prompts] + + SNAPSHOT.parent.mkdir(parents=True, exist_ok=True) + SNAPSHOT.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2)) + print(f"\nWrote {SNAPSHOT}") + + +if __name__ == "__main__": + main() diff --git a/evals/measure.py b/evals/measure.py new file mode 100644 index 0000000..f1b1092 --- /dev/null +++ b/evals/measure.py @@ -0,0 +1,107 @@ +""" +Read evals/snapshots/results.json (produced by llm_run.py) and report +real token compression per skill against the *terse control arm* — i.e. +how much the skill adds on top of a plain "Answer concisely." instruction. + +Reports median, min, max and stdev across prompts, not just the mean, +so the reader can see whether a number is solid or noisy. + +Tokenizer note: tiktoken o200k_base is OpenAI's tokenizer and is only an +approximation of Claude's BPE. The ratios are still meaningful for +comparing skills against each other, but the absolute numbers should be +read as "approximate output-length reduction", not "exact Claude tokens". + +Run: uv run --with tiktoken python evals/measure.py +""" + +from __future__ import annotations + +import json +import statistics +from pathlib import Path + +import tiktoken + +ENCODING = tiktoken.get_encoding("o200k_base") +SNAPSHOT = Path(__file__).parent / "snapshots" / "results.json" + + +def count(text: str) -> int: + return len(ENCODING.encode(text)) + + +def stats(savings: list[float]) -> tuple[float, float, float, float, float]: + return ( + statistics.median(savings), + statistics.mean(savings), + min(savings), + max(savings), + statistics.stdev(savings) if len(savings) > 1 else 0.0, + ) + + +def fmt_pct(x: float) -> str: + sign = "−" if x < 0 else "+" + return f"{sign}{abs(x) * 100:.0f}%" + + +def main() -> None: + if not SNAPSHOT.exists(): + print(f"No snapshot at {SNAPSHOT}. Run `python evals/llm_run.py` first.") + return + + data = json.loads(SNAPSHOT.read_text()) + arms = data["arms"] + meta = data.get("metadata", {}) + + baseline_tokens = [count(o) for o in arms["__baseline__"]] + terse_tokens = [count(o) for o in arms["__terse__"]] + + print(f"_Generated: {meta.get('generated_at', '?')}_") + print( + f"_Model: {meta.get('model', '?')} · CLI: {meta.get('claude_cli_version', '?')}_" + ) + print(f"_Tokenizer: tiktoken o200k_base (approximation of Claude's BPE)_") + print( + f"_n = {meta.get('n_prompts', len(baseline_tokens))} prompts, single run per arm_" + ) + print() + print(f"**Reference arms (no skill):**") + print(f"- baseline (no system prompt): {sum(baseline_tokens)} tokens total") + print( + f"- terse control (`Answer concisely.`): {sum(terse_tokens)} tokens total " + f"({fmt_pct(1 - sum(terse_tokens) / sum(baseline_tokens))} vs baseline)" + ) + print() + print("**Skills, measured as additional reduction on top of the terse control:**") + print() + print("| Skill | Median | Mean | Min | Max | Stdev | Tokens (skill / terse) |") + print("|-------|--------|------|-----|-----|-------|-------------------------|") + + rows = [] + for skill, outputs in arms.items(): + if skill in ("__baseline__", "__terse__"): + continue + skill_tokens = [count(o) for o in outputs] + savings = [ + 1 - (s / t) if t else 0.0 for s, t in zip(skill_tokens, terse_tokens) + ] + med, mean, lo, hi, sd = stats(savings) + rows.append( + (skill, med, mean, lo, hi, sd, sum(skill_tokens), sum(terse_tokens)) + ) + + for row in sorted(rows, key=lambda r: -r[1]): + skill, med, mean, lo, hi, sd, st, tt = row + print( + f"| **{skill}** | {fmt_pct(med)} | {fmt_pct(mean)} | " + f"{fmt_pct(lo)} | {fmt_pct(hi)} | {sd * 100:.0f}% | {st} / {tt} |" + ) + + print() + print("_Savings = `1 - skill_tokens / terse_tokens` per prompt._") + print(f"_Source: {SNAPSHOT.name}. Refresh with `python evals/llm_run.py`._") + + +if __name__ == "__main__": + main() diff --git a/evals/plot.py b/evals/plot.py new file mode 100644 index 0000000..42aab23 --- /dev/null +++ b/evals/plot.py @@ -0,0 +1,150 @@ +""" +Generate a boxplot showing the distribution of token compression per +skill, compared against a plain "Answer concisely." control. + +Reads evals/snapshots/results.json and writes: + - evals/snapshots/results.html (interactive plotly) + - evals/snapshots/results.png (static export for README/PR embed) + +Run: uv run --with tiktoken --with plotly --with kaleido python evals/plot.py +""" + +from __future__ import annotations + +import json +import statistics +from pathlib import Path + +import plotly.graph_objects as go +import tiktoken + +ENCODING = tiktoken.get_encoding("o200k_base") +SNAPSHOT = Path(__file__).parent / "snapshots" / "results.json" +HTML_OUT = Path(__file__).parent / "snapshots" / "results.html" +PNG_OUT = Path(__file__).parent / "snapshots" / "results.png" + + +def count(text: str) -> int: + return len(ENCODING.encode(text)) + + +def main() -> None: + data = json.loads(SNAPSHOT.read_text()) + arms = data["arms"] + meta = data.get("metadata", {}) + + terse_tokens = [count(o) for o in arms["__terse__"]] + + rows = [] + for skill, outputs in arms.items(): + if skill in ("__baseline__", "__terse__"): + continue + skill_tokens = [count(o) for o in outputs] + savings = [ + (1 - (s / t)) * 100 if t else 0.0 + for s, t in zip(skill_tokens, terse_tokens) + ] + rows.append( + {"skill": skill, "savings": savings, "median": statistics.median(savings)} + ) + + rows.sort(key=lambda r: -r["median"]) # best first + + fig = go.Figure() + + for row in rows: + fig.add_trace( + go.Box( + y=row["savings"], + name=row["skill"], + boxpoints="all", + jitter=0.4, + pointpos=0, + marker=dict(color="#2ca02c", size=7, opacity=0.7), + line=dict(color="#2c3e50", width=2), + fillcolor="rgba(76, 120, 168, 0.25)", + boxmean=True, + hovertemplate="%{x}
%{y:.1f}%", + ) + ) + + # zero line — "no effect" + fig.add_hline( + y=0, + line=dict(color="black", width=1.5, dash="dash"), + annotation_text="no effect (= same length as control)", + annotation_position="top right", + annotation_font=dict(size=11, color="black"), + ) + + # median labels above each box + for row in rows: + fig.add_annotation( + x=row["skill"], + y=max(row["savings"]), + text=f"{row['median']:+.0f}%", + showarrow=False, + yshift=22, + font=dict(size=16, color="#2c3e50"), + ) + + fig.update_layout( + title=dict( + text=f"How much shorter does each skill make Claude's answers?
" + f"Distribution of per-prompt savings vs system prompt = " + f"'Answer concisely.'
" + f"{meta.get('model', '?')} · n={meta.get('n_prompts', '?')} prompts · " + f"single run per arm
", + x=0.5, + xanchor="center", + ), + xaxis=dict(title="", automargin=True), + yaxis=dict( + title="↑ shorter · vs control · longer ↓", + ticksuffix="%", + zeroline=False, + gridcolor="rgba(0,0,0,0.08)", + range=[-30, 115], + ), + plot_bgcolor="white", + height=560, + width=980, + margin=dict(l=140, r=80, t=120, b=120), + showlegend=False, + annotations=[ + dict( + x=0.5, + y=-0.22, + xref="paper", + yref="paper", + showarrow=False, + font=dict(size=11, color="#555"), + text=( + "box = IQR (middle 50%) · " + "line in box = median · " + "dashed line = mean · " + "green dots = individual prompts" + ), + ) + ], + ) + + # re-add labels after update_layout (which would otherwise wipe them) + for row in rows: + fig.add_annotation( + x=row["skill"], + y=max(row["savings"]), + text=f"{row['median']:+.0f}%", + showarrow=False, + yshift=22, + font=dict(size=16, color="#2c3e50"), + ) + + fig.write_html(HTML_OUT) + print(f"Wrote {HTML_OUT}") + fig.write_image(PNG_OUT, scale=2) + print(f"Wrote {PNG_OUT}") + + +if __name__ == "__main__": + main() diff --git a/evals/prompts/en.txt b/evals/prompts/en.txt new file mode 100644 index 0000000..e05a4df --- /dev/null +++ b/evals/prompts/en.txt @@ -0,0 +1,10 @@ +Why does my React component re-render every time the parent updates? +Explain database connection pooling. +What's the difference between TCP and UDP? +How do I fix a memory leak in a long-running Node.js process? +What does the SQL EXPLAIN command tell me? +How does a hash table handle collisions? +Why am I getting CORS errors in my browser console? +What's the point of using a debouncer on a search input? +How does git rebase differ from git merge? +When should I use a queue vs a topic in messaging systems? diff --git a/evals/snapshots/results.json b/evals/snapshots/results.json new file mode 100644 index 0000000..c76d9b5 --- /dev/null +++ b/evals/snapshots/results.json @@ -0,0 +1,95 @@ +{ + "metadata": { + "generated_at": "2026-04-08T22:01:24.027359+00:00", + "claude_cli_version": "2.1.97 (Claude Code)", + "model": "claude-opus-4-6", + "n_prompts": 10, + "terse_prefix": "Answer concisely." + }, + "prompts": [ + "Why does my React component re-render every time the parent updates?", + "Explain database connection pooling.", + "What's the difference between TCP and UDP?", + "How do I fix a memory leak in a long-running Node.js process?", + "What does the SQL EXPLAIN command tell me?", + "How does a hash table handle collisions?", + "Why am I getting CORS errors in my browser console?", + "What's the point of using a debouncer on a search input?", + "How does git rebase differ from git merge?", + "When should I use a queue vs a topic in messaging systems?" + ], + "arms": { + "__baseline__": [ + "Because by default, React re-renders all children when a parent re-renders, regardless of whether their props changed.\n\nTo prevent it, wrap the component in `React.memo`:\n\n```jsx\nconst Child = React.memo(function Child(props) { ... });\n```\n\nCaveats:\n- `memo` does a shallow prop comparison. New object/array/function props created inline in the parent (` ...} data={{...}} />`) break it — wrap those in `useCallback`/`useMemo`.\n- Children passed via `children` or other JSX props are new elements each render, so `memo` won't help there.\n- Context updates still re-render consumers regardless of `memo`.\n\nIf you share the component + parent code, I can point to the exact cause.", + "Database connection pooling maintains a cache of reusable DB connections instead of opening/closing one per query.\n\n**Why:** Opening a TCP + auth handshake per query is expensive (often 10–100ms). Pools amortize that cost.\n\n**How it works:**\n1. Pool opens N connections at startup (or lazily).\n2. App \"checks out\" a connection, runs queries, \"returns\" it.\n3. Idle connections stay open, ready for reuse.\n4. If all are busy, requests wait or the pool grows up to `max_size`.\n\n**Key knobs:**\n- `min_size` / `max_size` — floor and ceiling of connections.\n- `max_idle_time` — close connections idle too long.\n- `max_lifetime` — recycle old connections (avoids stale TCP state).\n- `timeout` — how long a checkout waits before erroring.\n\n**Gotchas:**\n- **Sizing**: `max_size` × app instances must stay below DB's `max_connections`. Postgres typically handles ~100–200 well; beyond that use PgBouncer.\n- **Transaction leaks**: forgetting to release a connection starves the pool.\n- **Serverless**: short-lived functions defeat pooling — use an external pooler (PgBouncer, RDS Proxy, Neon pooler).\n- **Async frameworks** (FastAPI + asyncpg/SQLAlchemy async): pool is per-process; workers multiply it.\n\n**Common libraries:** HikariCP (Java), pgbouncer (external, Postgres), SQLAlchemy `QueuePool`, asyncpg `create_pool`, node-postgres `Pool`.", + "TCP is connection-oriented, reliable, and ordered: it establishes a handshake, retransmits lost packets, guarantees delivery and sequence, and handles flow/congestion control. Used for HTTP, SSH, email.\n\nUDP is connectionless and best-effort: no handshake, no retransmission, no ordering, minimal overhead. Faster and lighter, but packets can be lost or arrive out of order. Used for DNS, video/voice streaming, games, QUIC.\n\nKey tradeoff: TCP = reliability, UDP = low latency.", + "Common approaches:\n\n1. **Find the leak** — run with `--inspect`, take heap snapshots in Chrome DevTools at intervals, compare retained objects. Or use `clinic.js doctor` / `heapdump`.\n\n2. **Usual suspects**:\n - Unbounded caches/Maps — use `LRUCache` with a max size.\n - Event listeners not removed — check `emitter.listenerCount()`, use `once()` or `off()`.\n - Closures holding large scopes — especially in callbacks/timers.\n - Global arrays that only grow (logs, metrics buffers).\n - Timers (`setInterval`) keeping refs alive — `clearInterval` on shutdown, or `.unref()`.\n - Promises that never settle, holding their chain in memory.\n\n3. **Measure** — log `process.memoryUsage().heapUsed` over time; a steadily rising baseline across GCs confirms a leak (vs. normal sawtooth).\n\n4. **Bound it** — run with `--max-old-space-size=N` so it crashes loudly instead of thrashing, and use a supervisor (pm2, systemd) to restart.\n\nIf you share the code or a heap snapshot I can point at the specific culprit.", + "`EXPLAIN` shows the query planner's execution plan for a SQL statement — how the database intends to run it, without actually executing it (use `EXPLAIN ANALYZE` to run it and get real timings).\n\nKey things it reveals:\n- **Scan type**: Seq Scan (full table) vs Index Scan / Index Only Scan / Bitmap Scan — tells you if indexes are being used.\n- **Join strategy**: Nested Loop, Hash Join, Merge Join.\n- **Estimated cost**: `cost=startup..total`, `rows=`, `width=` (bytes/row). Costs are in arbitrary planner units, not ms.\n- **Order of operations**: read bottom-up, inside-out.\n- **Filters vs Index Conds**: a `Filter:` after a Seq Scan means rows were read then discarded — often a missing index.\n\nWith `EXPLAIN ANALYZE` you also get `actual time`, `rows`, and `loops`. Big gaps between estimated and actual rows usually mean stale stats (`ANALYZE` the table). Add `BUFFERS` to see cache vs disk reads.", + "Two main strategies:\n\n**1. Separate chaining** — each bucket stores a linked list (or tree) of entries. Colliding keys get appended. Lookup scans the chain. Used by Java's `HashMap`, Python's `dict` historically is different.\n\n**2. Open addressing** — on collision, probe for another empty slot in the array itself:\n- *Linear probing*: try `i+1, i+2, ...` (cache-friendly but clusters)\n- *Quadratic probing*: try `i+1, i+4, i+9, ...` (reduces clustering)\n- *Double hashing*: step size from a second hash function\n\nPython's `dict` and Ruby's `Hash` use open addressing; Go's `map` uses chaining with bucket arrays.\n\nBoth degrade as load factor rises, so tables resize (typically at 0.7–0.75) to keep average lookup O(1).", + "CORS errors happen when your frontend (origin A) makes a request to a backend (origin B) and the server doesn't return the `Access-Control-Allow-Origin` header permitting origin A. The browser enforces this — the request often reaches the server, but the response is blocked.\n\nCommon causes:\n- **Missing CORS middleware** on the backend (e.g., FastAPI needs `CORSMiddleware`, Express needs `cors`).\n- **Origin mismatch**: `localhost:3000` ≠ `127.0.0.1:3000`, `http` ≠ `https`, wrong port.\n- **Preflight (OPTIONS) failing**: non-simple requests (custom headers, `PUT`/`DELETE`, `application/json` with auth) trigger an OPTIONS preflight the server must handle.\n- **Credentials**: if sending cookies/auth, you need `Access-Control-Allow-Credentials: true` AND an explicit origin (not `*`).\n- **Wildcard + credentials**: `Allow-Origin: *` is rejected when credentials are included.\n\nTo help more specifically: what's the exact error message, and what stack is your backend?", + "A debouncer delays firing the search until the user pauses typing (e.g., 300ms of no keystrokes). Without it, every keystroke triggers a search — typing \"react\" fires 5 requests.\n\nBenefits:\n- **Fewer API calls** — cuts network load and backend cost\n- **Avoids race conditions** — stale responses arriving out of order\n- **Better UX** — no flickering results as the user types\n- **Less CPU work** — for expensive client-side filtering\n\nThrottle vs debounce: throttle fires at a fixed rate during activity; debounce waits for silence. Search inputs want debounce because you only care about the final query.", + "**Merge**: Combines branches by creating a new \"merge commit\" that ties their histories together. Preserves full history, including the branch topology. Non-destructive.\n\n**Rebase**: Replays your branch's commits on top of another branch, rewriting them as new commits. Produces a linear history with no merge commit.\n\nKey differences:\n- **History shape**: merge = branching graph; rebase = straight line\n- **Commit hashes**: merge preserves them; rebase rewrites them\n- **Conflicts**: merge resolves once; rebase may resolve per-commit\n- **Safety**: merge is safe on shared branches; rebase should be avoided on already-pushed/shared branches (rewrites history)\n\nRule of thumb: rebase local work to clean it up before sharing; merge when integrating shared branches.", + "**Queue**: point-to-point. One message → one consumer. Use for work distribution where each job must be processed exactly once (task queues, order processing, background jobs). Multiple workers compete; load balances across them.\n\n**Topic**: pub/sub. One message → many subscribers, each gets a copy. Use for broadcasting events to independent consumers (event notifications, cache invalidation, fan-out to analytics + audit + email).\n\nRule of thumb: \"who needs to react?\"\n- One of N workers → queue\n- All interested parties → topic\n\nModern brokers blur the line (Kafka topics with consumer groups act queue-like; SNS+SQS combines both)." + ], + "__terse__": [ + "React components re-render by default whenever their parent re-renders, regardless of whether their props changed. Common causes and fixes:\n\n1. **Default behavior** — Wrap the child in `React.memo(MyComponent)` to skip re-renders when props are shallow-equal.\n\n2. **New object/array/function props each render** — Even with `memo`, passing `{}`, `[]`, or inline `() => {}` creates new references every render, breaking memoization. Fix with `useMemo` / `useCallback` in the parent.\n\n3. **Children prop / context** — `memo` doesn't help if you pass `children` that change, or if the component consumes a context whose value changes.\n\n4. **New reference from hooks** — e.g. `useState` setters are stable, but values derived inline aren't.\n\nQuick check: add `console.log` with the props and see which one changes identity. If you share the component + parent code I can point to the exact culprit.", + "Database connection pooling maintains a cache of reusable database connections instead of opening/closing one per request.\n\n**Why it matters:** Opening a DB connection is expensive — TCP handshake, TLS, auth, session setup (often 50–200ms). Doing this per query kills throughput.\n\n**How it works:**\n1. Pool pre-opens N connections at startup.\n2. App \"checks out\" a connection, runs queries, \"returns\" it.\n3. Idle connections stay warm, ready for reuse.\n4. If all are busy, new requests wait (or the pool grows up to a max).\n\n**Key parameters:**\n- `min_size` / `max_size` — floor and ceiling of connections\n- `timeout` — max wait for a free connection\n- `max_lifetime` / `idle_timeout` — recycle stale connections\n- `max_overflow` — temporary burst capacity above max_size\n\n**Common tools:** SQLAlchemy's `QueuePool`, `asyncpg.create_pool`, HikariCP (Java), PgBouncer (external pooler sitting in front of Postgres).\n\n**Gotchas:**\n- Pool size > DB's `max_connections` → errors. Size per-process, multiply by workers.\n- Long transactions starve the pool.\n- Serverless/short-lived processes benefit from an external pooler (PgBouncer) in transaction mode.\n- Connections can go stale (network drops, DB restarts) — use health checks / `pre_ping`.\n\n**Rule of thumb:** pool size ≈ `(cores * 2) + effective_spindles`, tuned by load testing. Bigger isn't better — contention at the DB beats connection reuse gains.", + "TCP is connection-oriented, reliable, and ordered: it establishes a handshake, guarantees delivery, retransmits lost packets, and preserves sequence — used for HTTP, SSH, email.\n\nUDP is connectionless and best-effort: no handshake, no delivery guarantee, no ordering, lower latency and overhead — used for DNS, video/voice streaming, games.\n\nKey tradeoff: TCP = reliability, UDP = speed.", + "Common approaches:\n\n1. **Profile heap**: Run with `node --inspect` and use Chrome DevTools → Memory → take heap snapshots at intervals, compare retained size to find growing objects.\n2. **Usual culprits**:\n - Unbounded caches/Maps → use `lru-cache` or `WeakMap`.\n - Event listeners not removed → check `emitter.listenerCount()`, call `off()`/`removeListener()`.\n - Closures holding large scopes (e.g. in timers, promises).\n - Global arrays that only grow.\n - Unclosed DB/HTTP connections or streams.\n3. **Monitor**: log `process.memoryUsage().heapUsed` over time; use `--max-old-space-size` only as a bandaid.\n4. **Tools**: `clinic.js doctor`, `heapdump`, `0x`, or `--heap-prof` flag for sampling.\n5. **Reproduce in isolation**: load-test one endpoint/job at a time to localize the leak.\n\nStart with a heap snapshot diff — it usually points straight at the retainer.", + "SQL `EXPLAIN` shows the query execution plan — how the database will run your query. Key info:\n\n- **Access method**: sequential scan vs index scan vs index-only scan\n- **Join strategy**: nested loop, hash join, merge join\n- **Row estimates**: how many rows the planner expects at each step\n- **Cost estimates**: relative startup/total cost units\n- **Order of operations**: which tables/filters/sorts happen first\n\nUse `EXPLAIN ANALYZE` to actually execute the query and get real timings + actual row counts (vs estimates), which is what you need to spot bad plans (e.g., estimate says 10 rows, reality is 10M → stale stats or bad selectivity).\n\nCommon things to look for:\n- Seq Scan on large tables → missing index\n- Big gap between estimated and actual rows → run `ANALYZE`\n- Nested loop over huge row counts → join/index problem\n- Sort spilling to disk → need `work_mem` or an index providing order", + "Hash tables handle collisions with two main strategies:\n\n1. **Separate chaining**: Each bucket holds a linked list (or tree) of entries. Collisions append to the list. Lookup scans the chain. Java's `HashMap` uses this (converting to a tree after 8 entries).\n\n2. **Open addressing**: Store entries directly in the array; on collision, probe for another slot:\n - **Linear probing**: try `i+1, i+2, ...`\n - **Quadratic probing**: try `i+1², i+2², ...`\n - **Double hashing**: step size from a second hash function\n\nChaining is simpler and degrades gracefully; open addressing is cache-friendlier but needs resizing before load factor gets high (~0.7).", + "CORS errors happen when your frontend (origin A) requests a resource from a server (origin B) that doesn't return the right `Access-Control-Allow-*` headers. The browser blocks the response — the request often reaches the server fine.\n\nCommon causes:\n1. **Missing header** — server doesn't send `Access-Control-Allow-Origin` matching your frontend origin.\n2. **Wildcard + credentials** — using `Access-Control-Allow-Origin: *` with `credentials: 'include'`. You must echo the exact origin and set `Access-Control-Allow-Credentials: true`.\n3. **Preflight failing** — non-simple requests (custom headers, `PUT`/`DELETE`, `application/json`) trigger an `OPTIONS` preflight. Server must handle `OPTIONS` and return `Access-Control-Allow-Methods` / `Access-Control-Allow-Headers`.\n4. **Protocol/port mismatch** — `http://localhost:3000` ≠ `http://localhost:8000` ≠ `https://...`. All count as different origins.\n5. **Redirects** — CORS headers must be on the final response; redirects can strip them.\n\nQuick fixes by stack:\n- **FastAPI**: `app.add_middleware(CORSMiddleware, allow_origins=[...], allow_credentials=True, allow_methods=[\"*\"], allow_headers=[\"*\"])`\n- **Express**: `app.use(cors({ origin: '...', credentials: true }))`\n- **Dev only**: use a Vite/Next proxy so requests are same-origin.\n\nShare the exact error message and your frontend/backend origins and I can pinpoint it.", + "A debouncer delays running the search until the user stops typing for a short interval (e.g., 300ms). Benefits:\n\n- **Fewer API calls / DB queries**: avoids firing a request on every keystroke (\"a\", \"ap\", \"app\"...) — only one fires after typing pauses.\n- **Lower cost & server load**: especially important for expensive backends or rate-limited APIs.\n- **Better UX**: prevents flickering results and out-of-order responses racing each other.\n- **Saves client work**: less re-rendering and state churn.\n\nRelated: *throttle* caps frequency (e.g., 1/sec), while *debounce* waits for silence — debounce is usually what you want for search inputs.", + "**Merge**: Combines branches by creating a new merge commit that ties both histories together. Preserves the full branch history and is non-destructive.\n\n**Rebase**: Replays your branch's commits on top of another branch, creating new commits with new hashes. Produces a linear history but rewrites commits.\n\nKey differences:\n- **History**: merge = branching graph; rebase = straight line\n- **Safety**: merge is safe on shared branches; rebase should be avoided on pushed/shared branches (rewrites history)\n- **Conflicts**: merge resolves once; rebase may require resolving per replayed commit\n- **Traceability**: merge keeps context of when/where branches diverged; rebase loses it\n\nRule of thumb: rebase local/private work to clean up, merge for integrating shared branches.", + "**Queue** (point-to-point): one message → one consumer. Use for work distribution, task processing, load balancing across workers. Each job should be handled exactly once (e.g., order processing, email sending, image resizing).\n\n**Topic** (pub/sub): one message → many subscribers. Use for event broadcasting where multiple independent systems react to the same event (e.g., \"OrderPlaced\" → inventory, billing, analytics, notifications all consume it).\n\n**Rule of thumb:**\n- \"Who does this work?\" → queue\n- \"Who needs to know?\" → topic\n\nModern brokers blur the line (Kafka topics with consumer groups act like queues per group; SNS+SQS fan-out combines both), but the semantic distinction still drives the design choice." + ], + "caveman": [ + "Parent re-render → child re-render by default. Props \"change\" each render if inline obj/array/fn → new ref → child sees new props.\n\nFix:\n- Wrap child: `React.memo(Child)`\n- Stabilize props: `useMemo` for obj/array, `useCallback` for fn\n- Lift state so parent re-render not needed\n\nCheck: if child memo'd but still re-renders, inline prop is culprit.", + "Pool = reuse open DB conns. No new conn per req → skip TCP+auth handshake. Fixed max size cap load on DB. Idle conns recycled. Win: latency ↓, throughput ↑.", + "TCP: connection-based. Handshake, ordered, reliable, retransmit lost packets, flow/congestion control. Slower. Use: web, SSH, files.\n\nUDP: connectionless. No handshake, no order, no retransmit. Fast, low overhead. Use: video, games, DNS, VoIP.", + "Use caveman skill (requested mode by system).\n\n**Find leak:**\n- Run with `node --inspect`. Open `chrome://inspect` → Memory tab.\n- Take 3 heap snapshots over time. Compare. Look \"Retained Size\" growing.\n- Or `clinic doctor -- node app.js` for quick diagnosis.\n\n**Common causes:**\n- Global vars / caches no eviction → use `lru-cache` with max.\n- Event listeners not removed → `emitter.off()` on cleanup. Watch `MaxListenersExceededWarning`.\n- Closures holding big objects → null refs when done.\n- Timers (`setInterval`) never cleared → `clearInterval`.\n- Promises never resolve → pile up.\n\n**Fix pattern:**\n```js\nconst cache = new LRU({ max: 500 });\nemitter.on('x', handler);\n// cleanup\nemitter.off('x', handler);\nclearInterval(timer);\n```\n\n**Monitor prod:** `process.memoryUsage().heapUsed`. Alert on growth trend. Restart worker if threshold hit (pm2/k8s).\n\nRoot cause fix > restart band-aid.", + "EXPLAIN show query plan. DB tell you *how* it run query, not result.\n\nReveal:\n- **Scan type**: seq scan (full table) vs index scan vs bitmap scan\n- **Join strategy**: nested loop, hash join, merge join\n- **Row estimates**: planner guess of rows per step\n- **Cost**: startup..total (arbitrary units)\n- **Order**: which step first, which last\n\n`EXPLAIN ANALYZE` = actually run query + show real time + real rows. Compare est vs actual → spot bad stats.\n\nUse for: find missing index (seq scan on big table), bad join order, wrong row estimate → `ANALYZE table`.", + "Caveman mode on.\n\nTwo main way:\n\n1. **Chaining**: each bucket hold linked list (or tree). Collide → append to list. Lookup walk list.\n2. **Open addressing**: collide → probe next slot. Variants: linear probe (+1), quadratic (+i²), double hash (step = hash2(key)).\n\nLoad factor high → resize + rehash. Java `HashMap` chain→tree at 8. Python dict + Go map use open addressing.", + "CORS = server no send `Access-Control-Allow-Origin` header match your origin. Browser block response.\n\nCommon cause:\n- Server miss CORS middleware\n- Origin not in allowlist\n- Preflight (OPTIONS) fail — need `Allow-Methods`, `Allow-Headers`\n- Credentials mode: need `Allow-Credentials: true` + explicit origin (no `*`)\n\nFix FastAPI:\n```python\nfrom fastapi.middleware.cors import CORSMiddleware\napp.add_middleware(CORSMiddleware, allow_origins=[\"http://localhost:3000\"], allow_credentials=True, allow_methods=[\"*\"], allow_headers=[\"*\"])\n```\n\nPaste exact error → me pinpoint.", + "Debouncer delay fn call until user stop typing. Without: every keystroke → API hit → spam backend, race conditions, laggy UI. With: wait ~300ms idle → one request. Saves cost, cuts load, better UX.", + "Merge = combine branches, make new merge commit. Keep history as-is, show branching.\n\nRebase = replay your commits on top of target branch. Rewrite history, linear shape.\n\nKey diff:\n- **Merge**: non-destructive, preserve context, extra merge commit, messy graph\n- **Rebase**: clean linear log, rewrite SHAs, dangerous on shared branches\n\nRule: merge for shared/public branches, rebase for local cleanup before push.", + "Queue = point-to-point. One msg → one consumer. Work distribution, load balance. Ex: job processing.\n\nTopic = pub/sub. One msg → many subscribers. Event broadcast, fan-out. Ex: order placed → notify billing + inventory + email.\n\nRule: task need doing once? Queue. Event many care about? Topic." + ], + "caveman-cn": [ + "父 render -> 子收到新 props 引用 -> 子重渲染。\n\n修:\n- `React.memo(Child)` 包子组件。\n- 父传的对象/数组/函数用 `useMemo` / `useCallback` 稳引用。\n- 否则 memo 浅比较次次 fail。", + "池 = 复用 DB 连接。省握手 -> 更快。限并发 -> 保护 DB。", + "TCP:有连接。三次握手。保序。重传。流控/拥塞控。慢但可靠。→ HTTP、SSH、DB。\n\nUDP:无连接。发了就忘。无序。可丢。无重传。快、开销小。→ DNS、视频、游戏、QUIC 底层。", + "用 `--inspect` 启动。Chrome DevTools -> Memory -> 拍两张 heap snapshot(间隔负载后)。对比 \"Comparison\" 视图,看 Delta 正的对象。\n\n常见源头:\n- 闭包抓大对象\n- 全局 `Map`/`Set` 只加不删 -> 换 `WeakMap`/`WeakRef` 或加 TTL\n- 监听器泄漏 -> `emitter.setMaxListeners`,记得 `off()`\n- 定时器未 `clearInterval`\n- 缓存无上限 -> 用 `lru-cache`\n\n监控:`process.memoryUsage().heapUsed` 打点。或 `clinic heapprofiler` / `0x`。\n\n复现:`node --expose-gc`,循环后 `global.gc()`,看 RSS 是否回落。不回落 = 泄漏。", + "查询执行计划。DB 怎么跑你的 SQL:用哪个 index、join 顺序、扫全表还是走索引、行数估算、成本。\n\nPostgres 用 `EXPLAIN ANALYZE` -> 真实执行时间 + 实际行数。对比 estimated vs actual 找烂计划。\n\n关键字段:\n- `Seq Scan` -> 全表扫,通常坏\n- `Index Scan` -> 走索引,好\n- `rows=` -> 优化器估算\n- `cost=` -> 相对开销\n- `Nested Loop` / `Hash Join` / `Merge Join` -> join 策略\n\n用途:debug 慢查询,决定加哪个 index。", + "链地址法:桶存链表/树,冲突元素串一起。开放寻址:另找空槽——线性探测、二次探测、双重哈希。负载因子过高 -> 扩容 rehash。Java 8 `HashMap`:链表长度 >8 转红黑树。", + "CORS = 浏览器拦。服务器没回 `Access-Control-Allow-Origin` 头,或回的 origin 不匹配。\n\n修:\n- 后端加头:`Access-Control-Allow-Origin: <你的前端域>`(或 `*`,但带 cookie 不行)\n- 预检 `OPTIONS` 要回 200 + `Allow-Methods` / `Allow-Headers`\n- 带 cookie:`Allow-Credentials: true` + 明确 origin(不能 `*`)\n- FastAPI:`CORSMiddleware`。Express:`cors` 包。\n\n贴报错原文 + 请求/响应头,能更准。", + "防止每字触发请求。用户打字 -> 等停顿(如300ms) -> 才发请求。省 API 调用、减抖动、少无用渲染。", + "merge: 保留两条历史,生成 merge commit。非破坏性。历史有分叉。\n\nrebase: 把你的 commit 摘下来,重放到目标分支顶端。线性历史。commit 变新 hash (重写)。\n\n选择:\n- 公共分支 -> merge (别重写共享历史)\n- 本地/私有分支整理 -> rebase\n- 想保留合并上下文 -> merge\n- 想干净线性 log -> rebase\n\n冲突:merge 解一次。rebase 可能每个 commit 解一次。", + "队列 = 点对点。一消息一消费者。工作分发、任务处理。\n\n主题 = 发布/订阅。一消息多订阅者。事件广播、扇出。\n\n选队列:负载均衡 workers、订单处理、job queue。\n选主题:多系统响应同事件(如 `user.created` -> email + analytics + CRM)。" + ], + "caveman-es": [ + "Padre re-render → hijos re-render por default. Props nuevas cada vez (obj/array/fn inline = ref nueva) rompen bail-out.\n\nFix:\n- `React.memo(Child)` para comparar props shallow\n- `useMemo`/`useCallback` en padre para props estables\n- Evitar ` ...} />` inline\n\nSin memo, React re-renderiza hijos siempre aunque props iguales.", + "Pool = reusar conexiones BD abiertas. Evita handshake TCP + auth por cada request → lento bajo carga.\n\n**Cómo funciona:**\n- App pide conn → pool da una libre (o crea si < max)\n- App termina → conn vuelve al pool, no se cierra\n- Pool lleno → request espera o falla\n\n**Params clave:**\n- `min`: conns calientes siempre\n- `max`: techo (proteger BD de saturación)\n- `idle_timeout`: matar conns ociosas\n- `acquire_timeout`: cuánto esperar conn libre\n\n**Gotchas:**\n- Max muy alto → BD muere (Postgres ~100 conns default)\n- Serverless → usar pooler externo (PgBouncer, Supavisor). Lambdas efímeras rompen pools in-process\n- Transacciones largas bloquean pool → leaks\n\nLibs: HikariCP (Java), pgbouncer, SQLAlchemy pool, `pg` node.", + "TCP: conexión, confiable, ordenado, handshake, retransmite pérdidas, control flujo/congestión. Lento pero seguro. Web/SSH/SQL.\n\nUDP: sin conexión, sin garantía, sin orden, cero handshake. Rápido, ligero. Pierde paquetes sin avisar. DNS/video/juegos/VoIP.\n\nClave: TCP = llega todo bien. UDP = llega rápido o no llega.", + "Pasos:\n\n1. **Reproducir + medir**: `node --inspect` + Chrome DevTools → Memory tab. O `process.memoryUsage()` en loop.\n2. **Heap snapshots**: tomar 3 (baseline, medio, tarde). DevTools \"Comparison\" → ver qué objetos crecen.\n3. **Sospechosos comunes**:\n - Listeners no removidos (`emitter.on` sin `off`) → `EventEmitter` warning a 10+\n - Closures reteniendo refs grandes\n - Caches sin límite (usar `lru-cache`)\n - Globals/singletons acumulando\n - Timers (`setInterval`) sin `clearInterval`\n - Promesas colgadas reteniendo scope\n4. **Fix**: limpiar listeners en cleanup, TTL en caches, `WeakMap`/`WeakRef` para refs opcionales.\n5. **Verificar**: snapshot post-fix, memoria estable bajo carga (`autocannon`/`k6`).\n\nHerramientas: `clinic.js doctor`, `heapdump`, `--heap-prof`.", + "EXPLAIN muestra plan ejecución query. Revela: orden joins, índices usados (o no), método scan (seq/index), filas estimadas, costo. `EXPLAIN ANALYZE` corre query real → tiempos + filas reales vs estimadas. Usar para diagnosticar queries lentas.", + "Colisiones hash — dos claves → mismo bucket. Soluciones principales:\n\n**1. Chaining (encadenamiento):** Bucket guarda lista enlazada. Colisión → append a lista. Lookup = hash + scan lista. Java `HashMap` usa esto (lista → árbol si >8).\n\n**2. Open addressing (direccionamiento abierto):** Una entrada por bucket. Colisión → probar otro slot:\n- **Linear probing:** `i+1, i+2...` (rápido, pero clustering)\n- **Quadratic:** `i+1², i+2²...` (menos clusters)\n- **Double hashing:** segundo hash como paso\n\nPython `dict`, Go `map` usan open addressing.\n\n**Trade-offs:** Chaining tolera load factor >1, simple borrado. Open addressing mejor cache locality, pero requiere resize antes (~0.7 load factor) y borrado usa tombstones.", + "Browser bloquea request cross-origin. Server no manda header `Access-Control-Allow-Origin` para tu origen.\n\nFix rápido:\n- **Backend**: agregar CORS middleware permitiendo tu origen frontend (ej FastAPI: `CORSMiddleware` con `allow_origins=[\"http://localhost:3000\"]`).\n- **Preflight `OPTIONS` falla** → permitir métodos/headers usados (`allow_methods`, `allow_headers`).\n- **Credentials (cookies)** → `allow_credentials=True` + origen explícito (no `*`).\n- **Dev hack**: proxy en dev server (Vite/Next) para evitar cross-origin.\n\nPegá el error exacto + stack (frontend/backend) para fix preciso.", + "Debouncer espera usuario parar tecleo antes disparar búsqueda. Sin él: request por cada tecla → spam API, renders extra, resultados desordenados (race). Con él (~300ms): una sola query tras pausa → menos carga servidor, menos costo, UX suave.", + "Rebase: reescribe commits encima de base nueva. Historia lineal, sin merge commit. Cambia SHAs → no usar en ramas compartidas.\n\nMerge: une ramas con commit nuevo. Preserva historia real, no destructivo. Seguro en ramas públicas.\n\nRegla: rebase local, merge público.", + "Cola: 1 productor → 1 consumidor (de grupo). Mensaje entregado 1 vez. Work distribution, tareas, jobs.\n\nTópico: 1 productor → N suscriptores. Cada uno recibe copia. Pub/sub, eventos, broadcast.\n\nRegla: ¿trabajo a repartir? cola. ¿evento a anunciar? tópico.\n\nHíbrido (Kafka/SNS+SQS): tópico con consumer groups = ambos. Cada grupo = cola lógica." + ], + "compress": [ + "React re-renders children by default when the parent renders. To prevent it:\n\n1. **Wrap in `React.memo`** — skips re-render if props are shallow-equal:\n ```jsx\n export default React.memo(MyComponent);\n ```\n\n2. **Stabilize prop references** — objects/arrays/functions created inline break memo. Use `useMemo`/`useCallback` in the parent:\n ```jsx\n const data = useMemo(() => ({...}), [deps]);\n const onClick = useCallback(() => {...}, [deps]);\n ```\n\n3. **Check context** — any component consuming a context re-renders when that context value changes.\n\nMost common cause: passing `{}`, `[]`, or `() => {}` inline as props defeats `memo`.", + "Database connection pooling reuses a set of open DB connections instead of opening/closing one per query.\n\n**Why:** Opening a TCP + auth handshake is expensive (often 10–100ms). Pooling amortizes that cost.\n\n**How it works:**\n1. Pool opens N connections at startup.\n2. App \"checks out\" a connection, runs queries, \"returns\" it.\n3. Idle connections stay warm; pool enforces min/max size, timeouts, and health checks.\n\n**Key settings:**\n- `min_size` / `max_size` — floor/ceiling of connections\n- `max_idle_time` — recycle stale conns\n- `acquire_timeout` — fail fast when pool exhausted\n\n**Gotchas:**\n- Pool size > DB `max_connections` → errors. Size per-process; multiply by workers.\n- Long transactions starve the pool.\n- Serverless/short-lived functions → use an external pooler (PgBouncer, RDS Proxy).\n\n**Examples:** SQLAlchemy `QueuePool`, asyncpg `create_pool`, HikariCP (Java), PgBouncer (external).", + "TCP is connection-oriented, reliable, ordered, with handshakes and retransmission (web, email, SSH). UDP is connectionless, unreliable, no ordering, low overhead (DNS, video, games).", + "Common causes and fixes:\n\n1. **Profile first** — `node --inspect` + Chrome DevTools Memory tab, or `clinic doctor`. Take heap snapshots at intervals and diff them.\n\n2. **Frequent culprits**:\n - **Unbounded caches/Maps** → use `lru-cache` with max size\n - **Event listener leaks** → check `emitter.listenerCount()`; always `removeListener` on cleanup; raise/audit `setMaxListeners`\n - **Closures holding large objects** → avoid capturing big scopes in long-lived callbacks\n - **Global arrays that only grow** (logs, metrics buffers) → cap or flush\n - **Timers/intervals not cleared** → `clearInterval` on shutdown\n - **Promises never settling** → retained forever\n\n3. **Tools**: `heapdump`, `v8.writeHeapSnapshot()`, `process.memoryUsage()`, `--max-old-space-size` to delay OOM while debugging (not a fix).\n\n4. **Pattern**: snapshot → run workload → snapshot → compare retained size by constructor. Look for objects growing linearly with request count.\n\n5. **WeakMap/WeakRef** for caches keyed by objects you don't want to retain.\n\nStart with a heap diff — guessing wastes time.", + "SQL `EXPLAIN` shows the query execution plan — how the database will run your query. Key info:\n\n- **Access method**: sequential scan vs index scan vs index-only scan\n- **Join strategy**: nested loop, hash join, merge join\n- **Row estimates**: how many rows the planner expects at each step\n- **Cost estimates**: relative cost units (startup..total)\n- **Order of operations**: which tables/filters/joins run first\n\nUse `EXPLAIN ANALYZE` to actually execute the query and get real timings + actual row counts, which reveals bad estimates and slow steps. Add `BUFFERS` (Postgres) to see cache hits/disk reads.\n\nMain use: finding missing indexes, bad join orders, and costly seq scans.", + "Hash tables handle collisions mainly two ways:\n\n1. **Separate chaining**: each bucket stores a linked list (or tree) of entries that hash to the same index. On collision, append to the list. Lookup scans the list.\n\n2. **Open addressing**: store all entries in the array itself; on collision, probe for another slot:\n - *Linear probing*: try i+1, i+2, …\n - *Quadratic probing*: try i+1², i+2², …\n - *Double hashing*: step size from a second hash function.\n\nBoth degrade as load factor rises, so tables resize/rehash past a threshold (e.g., 0.75).", + "CORS errors happen when your frontend (e.g. `http://localhost:3000`) makes a request to a backend on a different origin (different scheme/host/port) and the server doesn't return the expected `Access-Control-Allow-*` headers.\n\nCommon causes:\n- **Missing headers**: server doesn't send `Access-Control-Allow-Origin` matching your frontend origin.\n- **Preflight fails**: browser sends `OPTIONS` before `POST`/`PUT`/custom headers; server must respond 2xx with `Allow-Methods` / `Allow-Headers`.\n- **Credentials mismatch**: using `credentials: 'include'` requires `Access-Control-Allow-Credentials: true` AND a specific origin (not `*`).\n- **Wrong URL**: hitting `http://` vs `https://`, or `localhost` vs `127.0.0.1`.\n\nFix (FastAPI example):\n```python\nfrom fastapi.middleware.cors import CORSMiddleware\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"http://localhost:3000\"],\n allow_credentials=True,\n allow_methods=[\"*\"],\n allow_headers=[\"*\"],\n)\n```\n\nShare the exact error message + your stack and I'll pinpoint it.", + "A debouncer delays running the search until the user stops typing for a short interval (e.g., 300ms). Benefits:\n\n- **Fewer API calls**: avoids firing a request on every keystroke (\"r\", \"re\", \"rea\", \"reac\", \"react\" → 1 call instead of 5).\n- **Lower backend load & cost**: less traffic, fewer DB/LLM hits.\n- **Better UX**: results don't flicker/reorder mid-typing; avoids race conditions where a stale response overwrites a newer one.\n- **Saves client resources**: fewer re-renders and network round-trips.\n\nUse ~200–400ms typically. For instant local filtering, debouncing is less critical; for remote search, it's essentially mandatory.", + "**Merge**: combines branches by creating a new merge commit that ties both histories together. Preserves full history, non-destructive.\n\n**Rebase**: replays your commits on top of another branch, creating new commits. Produces linear history, rewrites commits.\n\nRule of thumb: merge for shared/public branches, rebase for local cleanup before pushing.", + "Queue = point-to-point, one consumer processes each message (work distribution, task processing). Topic = pub/sub, every subscriber gets a copy (event broadcasting, fan-out).\n\nUse a **queue** when: load-balancing work across workers, ensuring exactly one handler, order/retry matters per job.\n\nUse a **topic** when: multiple independent systems react to the same event, decoupling producers from N consumers, event sourcing/notifications.\n\nHybrid (e.g. Kafka consumer groups, SNS→SQS): topic for fan-out + per-subscriber queue for durability and load-balancing within each group." + ] + } +} \ No newline at end of file diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..4b0ce68 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,6 @@ +{ + "name": "caveman", + "description": "Ultra-compressed communication mode. Cuts 65% of output tokens (measured) while keeping full technical accuracy by speaking like a caveman.", + "version": "1.0.1", + "contextFileName": "GEMINI.md" +} diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e581806 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,78 @@ +# caveman — installer shim (Windows / PowerShell). +# +# Thin wrapper around bin/install.js (the unified Node installer). Every flag +# you'd pass to bin/install.js can be passed here; we just forward them. +# +# One-line install: +# irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex +# +# Local clone: +# pwsh install.ps1 [flags] +# +# Why a Node installer? install.sh + install.ps1 used to be parallel sources of +# truth and constantly drifted (issue #249 was a `node -e "..."` quoting bug +# that silently dropped the JSON merge step on every Windows install). One +# Node script works everywhere without quoting bugs. +# +# Why no top-level param() and everything inside a function? `irm | iex` +# executes this file as a string: script-path variables ($PSCommandPath, +# $MyInvocation.MyCommand.Path) are $null and a top-level param block cannot +# receive arguments through a pipe anyway (issue #565). Wrapping the logic in +# a function and forwarding $args keeps one script working for both the pipe +# path (no args, no script path) and the local-clone path. + +function Install-Caveman { + param( + [string[]]$InstallerArgs = @() + ) + + $ErrorActionPreference = "Stop" + $Repo = "JuliusBrussee/caveman" + + # Require Node ≥18. + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { + Write-Error @" +caveman: Node.js (>=18) required. Install: + - winget install OpenJS.NodeJS.LTS + - or download from https://nodejs.org +"@ + exit 1 + } + + $nodeMajor = [int](& node -p "process.versions.node.split('.')[0]") + if ($nodeMajor -lt 18) { + Write-Error "caveman: Node $nodeMajor too old. Need Node >=18. Upgrade: https://nodejs.org" + exit 1 + } + + # If we're inside the repo clone, run the local installer directly. + # $PSCommandPath is $null when piped to iex (#565) — the old unguarded + # Split-Path on it was the "Cannot bind argument to parameter 'Path' + # because it is null" crash. + if ($PSCommandPath) { + $here = Split-Path -Parent $PSCommandPath + $local = Join-Path $here "bin/install.js" + if (Test-Path $local) { + & node $local @InstallerArgs + exit $LASTEXITCODE + } + } + + # Curl-pipe path: delegate to npx. + $npx = Get-Command npx -ErrorAction SilentlyContinue + if (-not $npx) { + Write-Error "caveman: npx required (ships with Node >=18). Reinstall Node.js." + exit 1 + } + + # Do NOT pass `--` here — npm 7+ npx already forwards trailing args to the + # package, and a literal `--` was tripping bin/install.js's parseArgs as an + # unknown flag. + & npx -y "github:$Repo" @InstallerArgs + exit $LASTEXITCODE +} + +# $args is the automatic variable: populated when run as a file +# (`pwsh install.ps1 --force`), empty under `irm | iex`. +Install-Caveman -InstallerArgs $args diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..87dbe4a --- /dev/null +++ b/install.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# caveman — installer shim. +# +# Thin wrapper around bin/install.js (the unified Node installer). Every flag +# you'd pass to bin/install.js can be passed here; we just forward them. +# +# One-line install: +# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash -s -- --all +# +# Local clone: +# bash install.sh [flags] +# +# Why a Node installer? install.sh + install.ps1 used to be parallel sources +# of truth and constantly drifted (issue #249, etc.). One Node script works +# everywhere without bash/PowerShell quoting bugs. + +set -euo pipefail + +REPO="JuliusBrussee/caveman" + +# Require Node ≥18. nvm is a common path; print a hint if missing. +if ! command -v node >/dev/null 2>&1; then + echo "caveman: Node.js (≥18) required. Install:" >&2 + echo " macOS: brew install node" >&2 + echo " Linux: see https://nodejs.org or use nvm (https://github.com/nvm-sh/nvm)" >&2 + exit 1 +fi + +NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]") +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "caveman: Node $NODE_MAJOR too old. Need Node ≥18." >&2 + echo " Upgrade: https://nodejs.org" >&2 + exit 1 +fi + +# If we're inside the repo clone, run the local installer directly — saves +# the npx round-trip and keeps offline installs working. BASH_SOURCE is unset +# when bash is invoked from stdin (curl | bash), and `set -u` would trip on a +# bare reference — default to empty so the curl-pipe path falls through cleanly. +here="$(cd "$(dirname "${BASH_SOURCE[0]:-}")" 2>/dev/null && pwd)" || here="" +if [ -n "$here" ] && [ -f "$here/bin/install.js" ]; then + exec node "$here/bin/install.js" "$@" +fi + +# Curl-pipe path: delegate to npx. We do NOT pass `--` here — npm 7+ npx +# already forwards trailing args to the package, and a literal `--` tripped +# bin/install.js's parseArgs as an unknown flag. +if ! command -v npx >/dev/null 2>&1; then + echo "caveman: npx required (ships with Node ≥18). Reinstall Node.js." >&2 + exit 1 +fi + +exec npx -y "github:$REPO" "$@" diff --git a/package.json b/package.json new file mode 100644 index 0000000..27f1a30 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "caveman-installer", + "version": "0.1.0", + "description": "Caveman installer — detects your AI coding agents and installs caveman for each one.", + "license": "MIT", + "author": "Julius Brussee", + "homepage": "https://github.com/JuliusBrussee/caveman", + "repository": { + "type": "git", + "url": "git+https://github.com/JuliusBrussee/caveman.git" + }, + "bugs": { + "url": "https://github.com/JuliusBrussee/caveman/issues" + }, + "bin": { + "caveman": "./bin/install.js" + }, + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test tests/installer/*.test.mjs" + }, + "files": [ + "bin/", + "src/", + "agents/", + "skills/", + "plugins/", + "commands/", + "dist/caveman.skill", + "README.md", + "LICENSE" + ] +} diff --git a/plugins/caveman/.codex-plugin/plugin.json b/plugins/caveman/.codex-plugin/plugin.json new file mode 100644 index 0000000..7822753 --- /dev/null +++ b/plugins/caveman/.codex-plugin/plugin.json @@ -0,0 +1,39 @@ +{ + "name": "caveman", + "version": "0.1.0", + "description": "Ultra-compressed communication mode. Cut filler. Keep technical accuracy.", + "author": { + "name": "Julius Brussee", + "url": "https://github.com/JuliusBrussee" + }, + "homepage": "https://github.com/JuliusBrussee/caveman", + "repository": "https://github.com/JuliusBrussee/caveman", + "license": "MIT", + "keywords": [ + "productivity", + "communication", + "brevity", + "writing" + ], + "skills": "./skills/", + "interface": { + "displayName": "Caveman", + "shortDescription": "Talk like caveman. Cut filler. Keep technical accuracy.", + "longDescription": "Ultra-compressed communication mode for Codex. Use fewer words. Keep exact technical substance.", + "developerName": "Julius Brussee", + "category": "Productivity", + "capabilities": [ + "Write" + ], + "websiteURL": "https://github.com/JuliusBrussee/caveman", + "privacyPolicyURL": "https://github.com/JuliusBrussee/caveman/blob/main/README.md", + "termsOfServiceURL": "https://github.com/JuliusBrussee/caveman/blob/main/LICENSE", + "defaultPrompt": [ + "Use caveman mode. Cut filler. Keep technical accuracy." + ], + "composerIcon": "./assets/caveman-small.svg", + "logo": "./assets/caveman.svg", + "screenshots": [], + "brandColor": "#6B7280" + } +} diff --git a/plugins/caveman/agents/cavecrew-builder.md b/plugins/caveman/agents/cavecrew-builder.md new file mode 100644 index 0000000..65750df --- /dev/null +++ b/plugins/caveman/agents/cavecrew-builder.md @@ -0,0 +1,47 @@ +--- +name: cavecrew-builder +description: > + Surgical 1-2 file edit. Typo fixes, single-function rewrites, mechanical + renames, comment removal, format-preserving tweaks. Hard refuses 3+ file + scope. Returns caveman diff receipt. Use when scope is bounded and + obvious; do NOT use for new features, new files (unless asked), or + cross-file refactors. +tools: [Read, Edit, Write, Grep, Glob] +--- + +Caveman-ultra. Drop articles/filler. Code/paths exact, backticked. No narration. + +## Scope + +1 file ideal. 2 OK. 3+ → refuse. +Edit existing only (new file iff user asked). +No new abstractions. No drive-by refactors. No comment additions. +No `Bash` available — cannot shell out, cannot push, cannot delete. + +## Workflow + +1. `Read` target(s). Never edit blind. +2. `Edit` smallest diff that work. +3. Re-`Read` to verify. +4. Return receipt. + +## Output (receipt) + +``` +. +. +verified: . +``` + +Diff is the artifact. Receipt is the proof. No exploration story. + +## Refusals (terminal lines) + +3+ files → `too-big. split: .` +Destructive needed → `needs-confirm. op: .` +Spec ambiguous → `ambiguous. ask: .` +Tests fail post-edit, can't fix in scope → `regressed. revert path:line. cause: .` + +## Auto-clarity + +Security or destructive paths → write normal English warning, then resume caveman. diff --git a/plugins/caveman/agents/cavecrew-investigator.md b/plugins/caveman/agents/cavecrew-investigator.md new file mode 100644 index 0000000..be27498 --- /dev/null +++ b/plugins/caveman/agents/cavecrew-investigator.md @@ -0,0 +1,57 @@ +--- +name: cavecrew-investigator +description: > + Read-only code locator. Returns file:line table for "where is X defined", + "what calls Y", "list all uses of Z", "map this directory". Output is + caveman-compressed so the main thread eats ~60% fewer tokens than + vanilla Explore. Refuses to suggest fixes. +tools: [Read, Grep, Glob, Bash] +model: haiku +--- + +Caveman-ultra. Drop articles/filler/hedging. Code/symbols/paths exact, backticked. Lead with answer. + +## Job + +Locate. Report. Stop. Never edit, never propose fix. + +## Output + +``` + — `` — <≤6 word note> + — `` — <≤6 word note> +``` + +Group with one-word header when 3+ rows: `Defs:` / `Refs:` / `Callers:` / `Tests:` / `Imports:` / `Sites:`. +Single hit → one line, no header. +Zero hits → `No match.` +Last line → totals: `2 defs, 5 refs.` (omit if 0 or 1). + +## Tools + +`Grep` for symbols/strings. `Glob` for paths. `Read` only specific ranges. `Bash` for `git log -S`/`git grep`/`find` when faster. + +## Refusals + +Asked to fix → `Read-only. Spawn cavecrew-builder.` +Asked to design → `Read-only. Spawn cavecrew-builder or use main thread.` + +## Auto-clarity + +Security warnings, destructive ops → write normal English. Resume after. + +## Example + +Q: "where symlink-safe flag write?" + +``` +Defs: +- hooks/caveman-config.js:81 — `safeWriteFlag` — atomic write w/ O_NOFOLLOW +- hooks/caveman-config.js:160 — `readFlag` — paired reader +Callers: +- hooks/caveman-mode-tracker.js:33,87 +- hooks/caveman-activate.js:40 +Tests: +- tests/test_symlink_flag.js — 12 cases +2 defs, 3 callers, 1 test file. +``` diff --git a/plugins/caveman/agents/cavecrew-reviewer.md b/plugins/caveman/agents/cavecrew-reviewer.md new file mode 100644 index 0000000..97049ce --- /dev/null +++ b/plugins/caveman/agents/cavecrew-reviewer.md @@ -0,0 +1,48 @@ +--- +name: cavecrew-reviewer +description: > + Diff/branch/file reviewer. One line per finding, severity-tagged, no praise, + no scope creep. Output format `path:line: : . .` + Use for "review this PR", "review my diff", "audit this file". Skips + formatting nits unless they change meaning. +tools: [Read, Grep, Bash] +model: haiku +--- + +Caveman-ultra. Findings only. No "looks good", no "I'd suggest", no preamble. + +## Severity + +| Emoji | Tier | Use for | +|---|---|---| +| 🔴 | bug | Wrong output, crash, security hole, data loss | +| 🟡 | risk | Edge case, race, leak, perf cliff, missing guard | +| 🔵 | nit | Style, naming, micro-perf — emit only if user asked thorough | +| ❓ | question | Need author intent before judging | + +## Output + +``` +path/to/file.ts:42: 🔴 bug: token expiry uses `<` not `<=`. Off-by-one allows expired tokens 1 tick. +path/to/file.ts:118: 🟡 risk: pool not closed on error path. Add `try/finally`. +src/utils.ts:7: ❓ question: why duplicate `.trim()` here? +totals: 1🔴 1🟡 1❓ +``` + +Zero findings → `No issues.` +File order, ascending line numbers within file. + +## Boundaries + +- Review only what's in front of you. No "while we're here". +- No big-refactor proposals. +- Need more context → append `(see L in )`. Don't guess. +- Formatting nits skipped unless they change meaning. + +## Tools + +`Bash` only for `git diff`/`git log -p`/`git show`. No mutating commands. + +## Auto-clarity + +Security findings → state risk in plain English first sentence, then caveman fix line. diff --git a/plugins/caveman/assets/caveman-small.svg b/plugins/caveman/assets/caveman-small.svg new file mode 100644 index 0000000..d85603e --- /dev/null +++ b/plugins/caveman/assets/caveman-small.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/caveman/assets/caveman.svg b/plugins/caveman/assets/caveman.svg new file mode 100644 index 0000000..c19d09b --- /dev/null +++ b/plugins/caveman/assets/caveman.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/caveman/skills/cavecrew/SKILL.md b/plugins/caveman/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/plugins/caveman/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/plugins/caveman/skills/caveman-compress/SKILL.md b/plugins/caveman/skills/caveman-compress/SKILL.md new file mode 100644 index 0000000..00ce454 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/SKILL.md @@ -0,0 +1,111 @@ +--- +name: caveman-compress +description: > + Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format + to save input tokens. Preserves all technical substance, code, URLs, and structure. + Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md. + Trigger: /caveman-compress FILEPATH or "compress memory file" +--- + +# Caveman Compress + +## Purpose + +Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`. + +## Trigger + +`/caveman-compress ` or when user asks to compress a memory file. + +## Process + +1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. + +2. From the directory containing this SKILL.md, run: + +python3 -m scripts + +3. The CLI will: +- detect file type (no tokens) +- call Claude to compress +- validate output (no tokens) +- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression) +- retry up to 2 times +- if still failing after 2 retries: report error to user, leave original file untouched + +4. Return result to user + +## Compression Rules + +### Remove +- Articles: a, an, the +- Filler: just, really, basically, actually, simply, essentially, generally +- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend" +- Hedging: "it might be worth", "you could consider", "it would be good to" +- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because" +- Connective fluff: "however", "furthermore", "additionally", "in addition" + +### Preserve EXACTLY (never modify) +- Code blocks (fenced ``` and indented) +- Inline code (`backtick content`) +- URLs and links (full URLs, markdown links) +- File paths (`/src/components/...`, `./config.yaml`) +- Commands (`npm install`, `git commit`, `docker build`) +- Technical terms (library names, API names, protocols, algorithms) +- Proper nouns (project names, people, companies) +- Dates, version numbers, numeric values +- Environment variables (`$HOME`, `NODE_ENV`) + +### Preserve Structure +- All markdown headings (keep exact heading text, compress body below) +- Bullet point hierarchy (keep nesting level) +- Numbered lists (keep numbering) +- Tables (compress cell text, keep structure) +- Frontmatter/YAML headers in markdown files + +### Compress +- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize" +- Fragments OK: "Run tests before commit" not "You should always run tests before committing" +- Drop "you should", "make sure to", "remember to" — just state the action +- Merge redundant bullets that say the same thing differently +- Keep one example where multiple examples show the same pattern + +CRITICAL RULE: +Anything inside ``` ... ``` must be copied EXACTLY. +Do not: +- remove comments +- remove spacing +- reorder lines +- shorten commands +- simplify anything + +Inline code (`...`) must be preserved EXACTLY. +Do not modify anything inside backticks. + +If file contains code blocks: +- Treat code blocks as read-only regions +- Only compress text outside them +- Do not merge sections around code + +## Pattern + +Original: +> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production. + +Compressed: +> Run tests before push to main. Catch bugs early, prevent broken prod deploys. + +Original: +> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens. + +Compressed: +> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens. + +## Boundaries + +- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless) +- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh +- If file has mixed content (prose + code), compress ONLY the prose sections +- If unsure whether something is code or prose, leave it unchanged +- Original file is backed up as FILE.original.md before overwriting +- Never compress FILE.original.md (skip it) diff --git a/plugins/caveman/skills/caveman-compress/scripts/__init__.py b/plugins/caveman/skills/caveman-compress/scripts/__init__.py new file mode 100644 index 0000000..16b8c53 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/__init__.py @@ -0,0 +1,9 @@ +"""Caveman compress scripts. + +This package provides tools to compress natural language markdown files +into caveman format to save input tokens. +""" + +__all__ = ["cli", "compress", "detect", "validate"] + +__version__ = "1.0.0" diff --git a/plugins/caveman/skills/caveman-compress/scripts/__main__.py b/plugins/caveman/skills/caveman-compress/scripts/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/plugins/caveman/skills/caveman-compress/scripts/benchmark.py b/plugins/caveman/skills/caveman-compress/scripts/benchmark.py new file mode 100644 index 0000000..f9e2ee0 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/benchmark.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +# Support both direct execution and module import +try: + from .validate import validate +except ImportError: + sys.path.insert(0, str(Path(__file__).parent)) + from validate import validate + +try: + import tiktoken + _enc = tiktoken.get_encoding("o200k_base") +except ImportError: + _enc = None + + +def count_tokens(text): + if _enc is None: + return len(text.split()) # fallback: word count + return len(_enc.encode(text)) + + +def benchmark_pair(orig_path: Path, comp_path: Path): + orig_text = orig_path.read_text() + comp_text = comp_path.read_text() + + orig_tokens = count_tokens(orig_text) + comp_tokens = count_tokens(comp_text) + saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0 + result = validate(orig_path, comp_path) + + return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid) + + +def print_table(rows): + print("\n| File | Original | Compressed | Saved % | Valid |") + print("|------|----------|------------|---------|-------|") + for r in rows: + print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |") + + +def main(): + # Direct file pair: python3 benchmark.py original.md compressed.md + if len(sys.argv) == 3: + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + if not orig.exists(): + print(f"❌ Not found: {orig}") + sys.exit(1) + if not comp.exists(): + print(f"❌ Not found: {comp}") + sys.exit(1) + print_table([benchmark_pair(orig, comp)]) + return + + # Glob mode: repo_root/tests/caveman-compress/ + # __file__ lives at /skills/caveman-compress/scripts/benchmark.py + # Walk up four dirs: scripts → caveman-compress → skills → repo_root. + tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress" + if not tests_dir.exists(): + print(f"❌ Tests dir not found: {tests_dir}") + sys.exit(1) + + rows = [] + for orig in sorted(tests_dir.glob("*.original.md")): + comp = orig.with_name(orig.stem.removesuffix(".original") + ".md") + if comp.exists(): + rows.append(benchmark_pair(orig, comp)) + + if not rows: + print("No compressed file pairs found.") + return + + print_table(rows) + + +if __name__ == "__main__": + main() diff --git a/plugins/caveman/skills/caveman-compress/scripts/cli.py b/plugins/caveman/skills/caveman-compress/scripts/cli.py new file mode 100644 index 0000000..75ea8a6 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/cli.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Caveman Compress CLI + +Usage: + caveman +""" + +import sys + +# Force UTF-8 on stdout/stderr before any code can print. Windows consoles +# default to cp1252 and crash on the ❌ glyphs in error/validation branches, +# masking the real error and leaving the user with a half-compressed file. +for _stream in (sys.stdout, sys.stderr): + reconfigure = getattr(_stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from pathlib import Path + +from .compress import backup_dir_for, compress_file +from .detect import detect_file_type, should_compress + + +def print_usage(): + print("Usage: caveman ") + + +def main(): + if len(sys.argv) != 2: + print_usage() + sys.exit(1) + + filepath = Path(sys.argv[1]) + + # Check file exists + if not filepath.exists(): + print(f"❌ File not found: {filepath}") + sys.exit(1) + + if not filepath.is_file(): + print(f"❌ Not a file: {filepath}") + sys.exit(1) + + filepath = filepath.resolve() + + # Detect file type + file_type = detect_file_type(filepath) + + print(f"Detected: {file_type}") + + # Check if compressible + if not should_compress(filepath): + print("Skipping: file is not natural language (code/config)") + sys.exit(0) + + print("Starting caveman compression...\n") + + try: + success = compress_file(filepath) + + if success: + print("\nCompression completed successfully") + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + print(f"Compressed: {filepath}") + print(f"Original: {backup_path}") + sys.exit(0) + else: + print("\n❌ Compression failed after retries") + sys.exit(2) + + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(130) + + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/caveman/skills/caveman-compress/scripts/compress.py b/plugins/caveman/skills/caveman-compress/scripts/compress.py new file mode 100644 index 0000000..e93d934 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/compress.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Caveman Memory Compression Orchestrator + +Usage: + python scripts/compress.py +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List + +OUTER_FENCE_REGEX = re.compile( + r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL +) + +# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line. +# Captures the entire block (including delimiters and trailing newline) and the body after. +FRONTMATTER_REGEX = re.compile( + r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL +) + + +def split_frontmatter(text: str): + """Split YAML frontmatter from body. Returns (frontmatter, body). + + Memory files (and many other markdown docs) start with a YAML frontmatter + block delimited by `---` lines. The compression LLM has a habit of stripping + or rewriting these despite preserve-structure rules in the prompt — so we + surgically remove the frontmatter before compression and prepend it back + verbatim to the output. Files without frontmatter pass through unchanged. + """ + m = FRONTMATTER_REGEX.match(text) + if m: + return m.group(1), m.group(2) + return "", text + +# Filenames and paths that almost certainly hold secrets or PII. Compressing +# them ships raw bytes to the Anthropic API — a third-party data boundary that +# developers on sensitive codebases cannot cross. detect.py already skips .env +# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would +# slip through the natural-language filter. This is a hard refuse before read. +SENSITIVE_BASENAME_REGEX = re.compile( + r"(?ix)^(" + r"\.env(\..+)?" + r"|\.netrc" + r"|credentials(\..+)?" + r"|secrets?(\..+)?" + r"|passwords?(\..+)?" + r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?" + r"|authorized_keys" + r"|known_hosts" + r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)" + r")$" +) + +SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"}) + +SENSITIVE_NAME_TOKENS = ( + "secret", "credential", "password", "passwd", + "apikey", "accesskey", "token", "privatekey", +) + + +def backup_dir_for(filepath: Path) -> Path: + """Resolve the out-of-tree backup directory for a given source file. + + Backups must live OUTSIDE the source directory so skill auto-loaders + (Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the + `.original.md` copies as live files. Base dir is platform-aware: + - Windows: %LOCALAPPDATA%\\caveman-compress\\backups + - else: $XDG_DATA_HOME/caveman-compress/backups if set, + else ~/.local/share/caveman-compress/backups + + The source file's parent-dir name is mirrored under the base to reduce + cross-project collisions (e.g. two `task.md` files in different repos). + """ + if os.name == "nt" or sys.platform == "win32": + local_appdata = os.environ.get("LOCALAPPDATA") + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + base = base / "caveman-compress" / "backups" + else: + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + base = base / "caveman-compress" / "backups" + return base / filepath.parent.name + + +def is_sensitive_path(filepath: Path) -> bool: + """Heuristic denylist for files that must never be shipped to a third-party API.""" + name = filepath.name + if SENSITIVE_BASENAME_REGEX.match(name): + return True + lowered_parts = {p.lower() for p in filepath.parts} + if lowered_parts & SENSITIVE_PATH_COMPONENTS: + return True + # Normalize separators so "api-key" and "api_key" both match "apikey". + lower = re.sub(r"[_\-\s.]", "", name.lower()) + return any(tok in lower for tok in SENSITIVE_NAME_TOKENS) + + +def strip_llm_wrapper(text: str) -> str: + """Strip outer ```markdown ... ``` fence when it wraps the entire output.""" + m = OUTER_FENCE_REGEX.match(text) + if m: + return m.group(2) + return text + +from .detect import should_compress +from .validate import validate + +MAX_RETRIES = 2 + + +# ---------- Claude Calls ---------- + + +def call_claude(prompt: str) -> str: + """Send a prompt to Claude. + + Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls + back to the ``claude --print`` CLI (which handles desktop auth). + + On Windows the CLI subprocess decoding defaults to the system codepage + (cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning + ``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual + native I/O and prevents the UnicodeDecodeError before validation can + report. Windows users with non-ASCII content can also set + ``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess. + """ + api_key = os.environ.get("ANTHROPIC_API_KEY") + if api_key: + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + msg = client.messages.create( + model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"), + max_tokens=8192, + messages=[{"role": "user", "content": prompt}], + ) + return strip_llm_wrapper(msg.content[0].text.strip()) + except ImportError: + pass # anthropic not installed, fall back to CLI + # Fallback: use claude CLI (handles desktop auth). + # Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g. + # %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX, + # shutil.which returns the same absolute path as the implicit lookup, + # so this is a no-op there. Falls back to bare "claude" if not found + # on PATH so subprocess raises a clear FileNotFoundError. + claude_bin = shutil.which("claude") or "claude" + try: + result = subprocess.run( + [claude_bin, "--print"], + input=prompt, + text=True, + capture_output=True, + check=True, + encoding="utf-8", + errors="replace", + ) + return strip_llm_wrapper(result.stdout.strip()) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Claude call failed:\n{e.stderr}") + + +def build_compress_prompt(original: str) -> str: + return f""" +Compress this markdown into caveman format. + +STRICT RULES: +- Do NOT modify anything inside ``` code blocks +- Do NOT modify anything inside inline backticks +- Preserve ALL URLs exactly +- Preserve ALL headings exactly +- Preserve file paths and commands +- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. + +Only compress natural language. + +TEXT: +{original} +""" + + +def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: + errors_str = "\n".join(f"- {e}" for e in errors) + return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found. + +CRITICAL RULES: +- DO NOT recompress or rephrase the file +- ONLY fix the listed errors — leave everything else exactly as-is +- The ORIGINAL is provided as reference only (to restore missing content) +- Preserve caveman style in all untouched sections + +ERRORS TO FIX: +{errors_str} + +HOW TO FIX: +- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED +- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED +- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED +- Do not touch any section not mentioned in the errors + +ORIGINAL (reference only): +{original} + +COMPRESSED (fix this): +{compressed} + +Return ONLY the fixed compressed file. No explanation. +""" + + +# ---------- Core Logic ---------- + + +def compress_file(filepath: Path) -> bool: + # Resolve and validate path + filepath = filepath.resolve() + MAX_FILE_SIZE = 500_000 # 500KB + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + if filepath.stat().st_size > MAX_FILE_SIZE: + raise ValueError(f"File too large to compress safely (max 500KB): {filepath}") + + # Refuse files that look like they contain secrets or PII. Compressing ships + # the raw bytes to the Anthropic API — a third-party boundary — so we fail + # loudly rather than silently exfiltrate credentials or keys. Override is + # intentional: the user must rename the file if the heuristic is wrong. + if is_sensitive_path(filepath): + raise ValueError( + f"Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Compression sends file contents to the Anthropic API. " + "Rename the file if this is a false positive." + ) + + print(f"Processing: {filepath}") + + if not should_compress(filepath): + print("Skipping (not natural language)") + return False + + original_text = filepath.read_text(errors="ignore") + # Store backup outside the source directory so skill auto-loaders don't + # re-ingest the `.original.md` copy as a live file. Mirror the source's + # parent-dir name + stem under a platform-aware base to reduce collisions. + backup_dir = backup_dir_for(filepath) + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / (filepath.stem + ".original.md") + + if not original_text.strip(): + print("❌ Refusing to compress: file is empty or whitespace-only.") + return False + + # Check if backup already exists to prevent accidental overwriting + if backup_path.exists(): + print(f"⚠️ Backup file already exists: {backup_path}") + print("The original backup may contain important content.") + print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + return False + + # Split YAML frontmatter off before compression. Claude tends to strip or + # rewrite frontmatter despite preserve-structure rules; we keep it verbatim + # by removing it from the input and re-prepending it to the output. + frontmatter, body = split_frontmatter(original_text) + if frontmatter: + print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim") + + if not body.strip(): + print("❌ Refusing to compress: body is empty after frontmatter removal.") + return False + + # Step 1: Compress (body only, frontmatter excluded) + print("Compressing with Claude...") + compressed_body = call_claude(build_compress_prompt(body)) + + if compressed_body is None or not compressed_body.strip(): + print("❌ Compression aborted: Claude returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + + # Compare the BODY (not the whole file) — frontmatter is preserved verbatim + # and would never change, so identity must be judged on the compressible part. + if compressed_body.strip() == body.strip(): + print("❌ Compression aborted: output is identical to input.") + print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is") + print(" already in caveman form. Original file is untouched (no backup created).") + return False + + # Reassemble: frontmatter (verbatim) + compressed body + compressed = frontmatter + compressed_body + + # Save original as backup, then verify the backup readback before + # touching the input file. If the filesystem dropped bytes (encoding, + # antivirus, disk full), unlink the bad backup and abort instead of + # leaving the user with a corrupt backup + compressed primary. + backup_path.write_text(original_text) + backup_readback = backup_path.read_text(errors="ignore") + if backup_readback != original_text: + print(f"❌ Backup write verification failed: {backup_path}") + print(" In-memory original differs from on-disk backup. Aborting before touching the input file.") + try: + backup_path.unlink() + except OSError: + pass + return False + filepath.write_text(compressed) + + # Step 2: Validate + Retry + for attempt in range(MAX_RETRIES): + print(f"\nValidation attempt {attempt + 1}") + + result = validate(backup_path, filepath) + + if result.is_valid: + print("Validation passed") + break + + print("❌ Validation failed:") + for err in result.errors: + print(f" - {err}") + + if attempt == MAX_RETRIES - 1: + # Restore original on failure + filepath.write_text(original_text) + backup_path.unlink(missing_ok=True) + print("❌ Failed after retries — original restored") + return False + + print("Fixing with Claude...") + compressed = call_claude( + build_fix_prompt(original_text, compressed, result.errors) + ) + filepath.write_text(compressed) + + return True diff --git a/plugins/caveman/skills/caveman-compress/scripts/detect.py b/plugins/caveman/skills/caveman-compress/scripts/detect.py new file mode 100644 index 0000000..5114d04 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/detect.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Detect whether a file is natural language (compressible) or code/config (skip).""" + +import json +import re +from pathlib import Path + +# Extensions that are natural language and compressible +COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} + +# Extensions that are code/config and should be skipped +SKIP_EXTENSIONS = { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", + ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml", + ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c", + ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua", + ".dockerfile", ".makefile", ".csv", ".ini", ".cfg", +} + +# Well-known build/config files that carry no (or a misleading) extension — +# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and +# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by +# basename before any extension rule. +KNOWN_CODE_FILENAMES = { + "dockerfile", "makefile", "gnumakefile", "jenkinsfile", "vagrantfile", + "rakefile", "gemfile", "justfile", "procfile", "brewfile", + "cmakelists.txt", +} + +# Patterns that indicate a line is code +CODE_PATTERNS = [ + re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"), + re.compile(r"^\s*(def |class |function |async function |export )"), + re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"), + re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets + re.compile(r"^\s*@\w+"), # decorators/annotations + re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value + re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal +] + + +def _is_code_line(line: str) -> bool: + """Check if a line looks like code.""" + return any(p.match(line) for p in CODE_PATTERNS) + + +def _is_json_content(text: str) -> bool: + """Check if content is valid JSON.""" + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + return False + + +def _is_yaml_content(lines: list[str]) -> bool: + """Heuristic: check if content looks like YAML.""" + yaml_indicators = 0 + for line in lines[:30]: + stripped = line.strip() + if stripped.startswith("---"): + yaml_indicators += 1 + elif re.match(r"^\w[\w\s]*:\s", stripped): + yaml_indicators += 1 + elif stripped.startswith("- ") and ":" in stripped: + yaml_indicators += 1 + # If most non-empty lines look like YAML + non_empty = sum(1 for l in lines[:30] if l.strip()) + return non_empty > 0 and yaml_indicators / non_empty > 0.6 + + +def detect_file_type(filepath: Path) -> str: + """Classify a file as 'natural_language', 'code', 'config', or 'unknown'. + + Returns: + One of: 'natural_language', 'code', 'config', 'unknown' + """ + ext = filepath.suffix.lower() + + # Known code filenames win over any extension rule + if filepath.name.lower() in KNOWN_CODE_FILENAMES: + return "code" + + # Extension-based classification + if ext in COMPRESSIBLE_EXTENSIONS: + return "natural_language" + if ext in SKIP_EXTENSIONS: + return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config" + + # Extensionless files (like CLAUDE.md, TODO) — check content + if not ext: + try: + text = filepath.read_text(errors="ignore") + except (OSError, PermissionError): + return "unknown" + + lines = text.splitlines()[:50] + + # Shebang means executable script, never prose + if text.startswith("#!"): + return "code" + + if _is_json_content(text[:10000]): + return "config" + if _is_yaml_content(lines): + return "config" + + code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l)) + non_empty = sum(1 for l in lines if l.strip()) + if non_empty > 0 and code_lines / non_empty > 0.4: + return "code" + + return "natural_language" + + return "unknown" + + +def should_compress(filepath: Path) -> bool: + """Return True if the file is natural language and should be compressed.""" + if not filepath.is_file(): + return False + # Skip backup files + if filepath.name.endswith(".original.md"): + return False + return detect_file_type(filepath) == "natural_language" + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python detect.py [file2] ...") + sys.exit(1) + + for path_str in sys.argv[1:]: + p = Path(path_str).resolve() + file_type = detect_file_type(p) + compress = should_compress(p) + print(f" {p.name:30s} type={file_type:20s} compress={compress}") diff --git a/plugins/caveman/skills/caveman-compress/scripts/validate.py b/plugins/caveman/skills/caveman-compress/scripts/validate.py new file mode 100644 index 0000000..dc07307 --- /dev/null +++ b/plugins/caveman/skills/caveman-compress/scripts/validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +import re +from collections import Counter +from pathlib import Path + +URL_REGEX = re.compile(r"https?://[^\s)]+") +FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$") +HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE) +BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE) + +# crude but effective path detection +# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match +PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+") + + +class ValidationResult: + def __init__(self): + self.is_valid = True + self.errors = [] + self.warnings = [] + + def add_error(self, msg): + self.is_valid = False + self.errors.append(msg) + + def add_warning(self, msg): + self.warnings.append(msg) + + +def read_file(path: Path) -> str: + return path.read_text(errors="ignore") + + +# ---------- Extractors ---------- + + +def extract_headings(text): + return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)] + + +def extract_code_blocks(text): + """Line-based fenced code block extractor. + + Handles ``` and ~~~ fences with variable length (CommonMark: closing + fence must use same char and be at least as long as opening). Supports + nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick + content). + """ + blocks = [] + lines = text.split("\n") + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + open_line = lines[i] + block_lines = [open_line] + i += 1 + closed = False + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + block_lines.append(lines[i]) + closed = True + i += 1 + break + block_lines.append(lines[i]) + i += 1 + if closed: + blocks.append("\n".join(block_lines)) + # Unclosed fences are silently skipped — they indicate malformed markdown + # and including them would cause false-positive validation failures. + return blocks + + +def extract_urls(text): + return set(URL_REGEX.findall(text)) + + +def extract_paths(text): + return set(PATH_REGEX.findall(text)) + + +def count_bullets(text): + return len(BULLET_REGEX.findall(text)) + + +def extract_inline_codes(text): + text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE) + text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE) + return re.findall(r"`([^`]+)`", text_without_fences) + + +# ---------- Validators ---------- + + +def validate_headings(orig, comp, result): + h1 = extract_headings(orig) + h2 = extract_headings(comp) + + if len(h1) != len(h2): + result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}") + + if h1 != h2: + result.add_warning("Heading text/order changed") + + +def validate_code_blocks(orig, comp, result): + c1 = extract_code_blocks(orig) + c2 = extract_code_blocks(comp) + + if c1 != c2: + result.add_error("Code blocks not preserved exactly") + + +def validate_urls(orig, comp, result): + u1 = extract_urls(orig) + u2 = extract_urls(comp) + + if u1 != u2: + result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}") + + +def validate_paths(orig, comp, result): + p1 = extract_paths(orig) + p2 = extract_paths(comp) + + if p1 != p2: + result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}") + + +def validate_bullets(orig, comp, result): + b1 = count_bullets(orig) + b2 = count_bullets(comp) + + if b1 == 0: + return + + diff = abs(b1 - b2) / b1 + + if diff > 0.15: + result.add_warning(f"Bullet count changed too much: {b1} -> {b2}") + + +def validate_inline_codes(orig, comp, result): + c1 = Counter(extract_inline_codes(orig)) + c2 = Counter(extract_inline_codes(comp)) + + if c1 != c2: + lost = set(c1.keys()) - set(c2.keys()) + added = set(c2.keys()) - set(c1.keys()) + for code, count in c1.items(): + if code in c2 and c2[code] < count: + lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)") + if lost: + result.add_error(f"Inline code lost: {lost}") + if added: + result.add_warning(f"Inline code added: {added}") + + +# ---------- Main ---------- + + +def validate(original_path: Path, compressed_path: Path) -> ValidationResult: + result = ValidationResult() + + orig = read_file(original_path) + comp = read_file(compressed_path) + + validate_headings(orig, comp, result) + validate_code_blocks(orig, comp, result) + validate_urls(orig, comp, result) + validate_paths(orig, comp, result) + validate_bullets(orig, comp, result) + validate_inline_codes(orig, comp, result) + + return result + + +# ---------- CLI ---------- + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 3: + print("Usage: python validate.py ") + sys.exit(1) + + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + + res = validate(orig, comp) + + print(f"\nValid: {res.is_valid}") + + if res.errors: + print("\nErrors:") + for e in res.errors: + print(f" - {e}") + + if res.warnings: + print("\nWarnings:") + for w in res.warnings: + print(f" - {w}") diff --git a/plugins/caveman/skills/caveman-stats/SKILL.md b/plugins/caveman/skills/caveman-stats/SKILL.md new file mode 100644 index 0000000..a7348ac --- /dev/null +++ b/plugins/caveman/skills/caveman-stats/SKILL.md @@ -0,0 +1,10 @@ +--- +name: caveman-stats +description: > + Show real token usage and estimated savings for the current session. + Reads directly from the Claude Code session log — no AI estimation. + Triggers on /caveman-stats. Output is injected by the mode-tracker hook; + the model itself does not compute the numbers. +--- + +This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately. diff --git a/plugins/caveman/skills/caveman/SKILL.md b/plugins/caveman/skills/caveman/SKILL.md new file mode 100644 index 0000000..adf8bc5 --- /dev/null +++ b/plugins/caveman/skills/caveman/SKILL.md @@ -0,0 +1,78 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. + +No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | +| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop, new ref, re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" +- wenyan-ultra: "新參照則重繪。useMemo 包之。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool reuse open DB connections. No per-request handshake." +- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。" +- wenyan-ultra: "池蓄連,免逐請新開,省握手。" + +## Auto-Clarity + +Drop caveman when: +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/plugins/caveman/skills/caveman/agents/openai.yaml b/plugins/caveman/skills/caveman/agents/openai.yaml new file mode 100644 index 0000000..b7cda02 --- /dev/null +++ b/plugins/caveman/skills/caveman/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Caveman" + short_description: "Talk like caveman. Cut filler. Keep technical accuracy." + icon_small: "./assets/caveman-small.svg" + icon_large: "./assets/caveman.svg" + default_prompt: "Use $caveman to answer briefly, cut filler, and preserve exact technical substance." diff --git a/plugins/caveman/skills/caveman/assets/caveman-small.svg b/plugins/caveman/skills/caveman/assets/caveman-small.svg new file mode 100644 index 0000000..d85603e --- /dev/null +++ b/plugins/caveman/skills/caveman/assets/caveman-small.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/caveman/skills/caveman/assets/caveman.svg b/plugins/caveman/skills/caveman/assets/caveman.svg new file mode 100644 index 0000000..c19d09b --- /dev/null +++ b/plugins/caveman/skills/caveman/assets/caveman.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..7cac7a5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "cavecrew": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/cavecrew/SKILL.md", + "computedHash": "06d45a7308d8603365313decf400020106b985cb5ce500ee169bfba9c71dd147" + } + } +} diff --git a/skills/cavecrew/README.md b/skills/cavecrew/README.md new file mode 100644 index 0000000..20bb07a --- /dev/null +++ b/skills/cavecrew/README.md @@ -0,0 +1,61 @@ +# cavecrew + +Decision guide. When to delegate to caveman subagents instead of doing the work inline. + +## What it does + +Tells the main thread when to spawn a caveman-style subagent versus the vanilla equivalent. The win: subagent tool-results inject back into main context verbatim, and caveman output is roughly 1/3 the size of vanilla prose. Across 20 delegations in one session, that is the difference between context exhaustion and finishing the task. + +Three subagents: + +| Subagent | Job | Use when | +|----------|-----|----------| +| `cavecrew-investigator` | Locate code (read-only) | "Where is X defined / what calls Y / list uses of Z" | +| `cavecrew-builder` | Surgical edit, 1-2 files | Scope is obvious, ≤2 files. Refuses 3+ file scope. | +| `cavecrew-reviewer` | Diff/file review | One-line findings with severity emoji | + +Use vanilla `Explore` or `Code Reviewer` when you want prose, architecture commentary, or rationale. Use main thread directly for one-line answers and 3+ file refactors. + +This skill is a decision guide, not a slash command. It activates when the conversation mentions delegation. + +## How to invoke + +Triggers on phrases like "delegate to subagent", "use cavecrew", "spawn investigator", "save context", "compressed agent output". + +## Example chaining + +Locate → fix → verify (most common): + +1. `cavecrew-investigator` returns site list (`path:line — symbol — note`) +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder` +3. `cavecrew-reviewer` audits the resulting diff + +Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main. + +## Model overrides + +By default, `cavecrew-reviewer` and `cavecrew-investigator` pin `model: haiku` in their frontmatter; `cavecrew-builder` has no `model:` line (uses the API session default). Set env vars in your shell before launching Claude Code to override per-agent: + +| Env var | Agent | +|---|---| +| `CAVECREW_REVIEWER_MODEL` | `cavecrew-reviewer` | +| `CAVECREW_BUILDER_MODEL` | `cavecrew-builder` | +| `CAVECREW_INVESTIGATOR_MODEL` | `cavecrew-investigator` | + +Example — run reviewer on sonnet, keep others on default: + +```sh +export CAVECREW_REVIEWER_MODEL=sonnet +``` + +Use the same model name strings you'd use in any Claude Code agent frontmatter (e.g. `haiku`, `sonnet`, `opus`). + +Overrides patch only the `model:` line in the installed agent's frontmatter; the prompt body is untouched and keeps receiving upstream updates. Plugin installs only — standalone hook installs have no local agent files to patch. Unset or blank = no change. The patch persists in the installed file until the plugin is updated or reinstalled. + +## See also + +- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts +- [`agents/cavecrew-investigator.md`](../../agents/cavecrew-investigator.md) +- [`agents/cavecrew-builder.md`](../../agents/cavecrew-builder.md) +- [`agents/cavecrew-reviewer.md`](../../agents/cavecrew-reviewer.md) +- [Caveman README](../../README.md) — repo overview diff --git a/skills/cavecrew/SKILL.md b/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/skills/caveman-commit/README.md b/skills/caveman-commit/README.md new file mode 100644 index 0000000..d5aee01 --- /dev/null +++ b/skills/caveman-commit/README.md @@ -0,0 +1,44 @@ +# caveman-commit + +Terse Conventional Commits. Why over what. + +## What it does + +Generates commit messages in Conventional Commits format. Subject ≤50 chars, hard cap 72. Imperative mood. Body only when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts — future debuggers need the context. + +Outputs only the message. Does not stage, commit, or amend. + +## How to invoke + +``` +/caveman-commit +``` + +Also triggers on phrases like "write a commit", "commit message", "generate commit". + +## Example output + +Diff: new endpoint for user profile. + +``` +feat(api): add GET /users/:id/profile + +Mobile client needs profile data without the full user payload +to reduce LTE bandwidth on cold-launch screens. + +Closes #128 +``` + +Diff: breaking API rename. + +``` +feat(api)!: rename /v1/orders to /v1/checkout + +BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout +before 2026-06-01. Old route returns 410 after that date. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/skills/caveman-commit/SKILL.md b/skills/caveman-commit/SKILL.md new file mode 100644 index 0000000..b9999e3 --- /dev/null +++ b/skills/caveman-commit/SKILL.md @@ -0,0 +1,65 @@ +--- +name: caveman-commit +description: > + Ultra-compressed commit message generator. Cuts noise from commit messages while preserving + intent and reasoning. Conventional Commits format. Subject ≤50 chars, body only when "why" + isn't obvious. Use when user says "write a commit", "commit message", "generate commit", + "/commit", or invokes /caveman-commit. Auto-triggers when staging changes. +--- + +Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what. + +## Rules + +**Subject line:** +- `(): ` — `` optional +- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert` +- Imperative mood: "add", "fix", "remove" — not "added", "adds", "adding" +- ≤50 chars when possible, hard cap 72 +- No trailing period +- Match project convention for capitalization after the colon + +**Body (only if needed):** +- Skip entirely when subject is self-explanatory +- Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues +- Wrap at 72 chars +- Bullets `-` not `*` +- Reference issues/PRs at end: `Closes #42`, `Refs #17` + +**What NEVER goes in:** +- "This commit does X", "I", "we", "now", "currently" — the diff says what +- "As requested by..." — use Co-authored-by trailer +- "Generated with Claude Code" or any AI attribution — unless the user's own rule requires an `Assisted-by`/AI-attribution trailer, then add it as a trailer +- Emoji (unless project convention requires) +- Restating the file name when scope already says it + +## Examples + +Diff: new endpoint for user profile with body explaining the why +- ❌ "feat: add a new endpoint to get user profile information from the database" +- ✅ + ``` + feat(api): add GET /users/:id/profile + + Mobile client needs profile data without the full user payload + to reduce LTE bandwidth on cold-launch screens. + + Closes #128 + ``` + +Diff: breaking API change +- ✅ + ``` + feat(api)!: rename /v1/orders to /v1/checkout + + BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout + before 2026-06-01. Old route returns 410 after that date. + ``` + +## Auto-Clarity + +Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never compress these into subject-only — future debuggers need the context. + +## Boundaries + +Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style. diff --git a/skills/caveman-compress/README.md b/skills/caveman-compress/README.md new file mode 100644 index 0000000..3ef6922 --- /dev/null +++ b/skills/caveman-compress/README.md @@ -0,0 +1,163 @@ +

+ +

+ +

caveman-compress

+ +

+ shrink memory file. save token every session. +

+ +--- + +A Claude Code skill that compresses your project memory files (`CLAUDE.md`, todos, preferences) into caveman format — so every session loads fewer tokens automatically. + +Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman make file small. Cost go down forever. + +## What It Do + +``` +/caveman-compress CLAUDE.md +``` + +``` +CLAUDE.md ← compressed (Claude reads this — fewer tokens every session) +CLAUDE.original.md ← human-readable backup (you edit this) +``` + +Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits. + +## Benchmarks + +Real results on real project files: + +| File | Original | Compressed | Saved | +|------|----------:|----------:|------:| +| `claude-md-preferences.md` | 706 | 285 | **59.6%** | +| `project-notes.md` | 1145 | 535 | **53.3%** | +| `claude-md-project.md` | 1122 | 636 | **43.3%** | +| `todo-list.md` | 627 | 388 | **38.1%** | +| `mixed-with-code.md` | 888 | 560 | **36.9%** | +| **Average** | **898** | **481** | **46%** | + +All validations passed ✅ — headings, code blocks, URLs, file paths preserved exactly. + +## Before / After + + + + + + +
+ +### 📄 Original (706 tokens) + +> "I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to properly type things catches a lot of bugs before they ever make it to runtime." + + + +### rock Caveman (285 tokens) + +> "Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early." + +
+ +**Same instructions. 60% fewer tokens. Every. Single. Session.** + +## Security + +`caveman-compress` is flagged as Snyk High Risk due to subprocess and file I/O patterns detected by static analysis. This is a false positive — see [SECURITY.md](./SECURITY.md) for a full explanation of what the skill does and does not do. + +## Install + +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: + +```bash +caveman-compress/ +``` + +**Requires:** Python 3.10+ + +## Usage + +``` +/caveman-compress +``` + +Examples: +``` +/caveman-compress CLAUDE.md +/caveman-compress docs/preferences.md +/caveman-compress todos.md +``` + +### What files work + +| Type | Compress? | +|------|-----------| +| `.md`, `.txt`, `.rst`, `.typ`, `.typst`, `.tex` | ✅ Yes | +| Extensionless natural language | ✅ Yes | +| `.py`, `.js`, `.ts`, `.json`, `.yaml` | ❌ Skip (code/config) | +| `*.original.md` | ❌ Skip (backup files) | + +## How It Work + +``` +/caveman-compress CLAUDE.md + ↓ +detect file type (no tokens) + ↓ +Claude compresses (tokens — one call) + ↓ +validate output (no tokens) + checks: headings, code blocks, URLs, file paths, bullets + ↓ +if errors: Claude fixes cherry-picked issues only (tokens — targeted fix) + does NOT recompress — only patches broken parts + ↓ +retry up to 2 times + ↓ +write compressed → CLAUDE.md +write original → CLAUDE.original.md +``` + +Only two things use tokens: initial compression + targeted fix if validation fails. Everything else is local Python. + +## What Is Preserved + +Caveman compress natural language. It never touch: + +- Code blocks (` ``` ` fenced or indented) +- Inline code (`` `backtick content` ``) +- URLs and links +- File paths (`/src/components/...`) +- Commands (`npm install`, `git commit`) +- Technical terms, library names, API names +- Headings (exact text preserved) +- Tables (structure preserved, cell text compressed) +- Dates, version numbers, numeric values + +## Why This Matter + +`CLAUDE.md` loads on **every session start**. A 1000-token project memory file costs tokens every single time you open a project. Over 100 sessions that's 100,000 tokens of overhead — just for context you already wrote. + +Caveman cut that by ~46% on average. Same instructions. Same accuracy. Less waste. + +``` +┌────────────────────────────────────────────┐ +│ TOKEN SAVINGS PER FILE █████ 46% │ +│ SESSIONS THAT BENEFIT ██████████ 100% │ +│ INFORMATION PRESERVED ██████████ 100% │ +│ SETUP TIME █ 1x │ +└────────────────────────────────────────────┘ +``` + +## Part of Caveman + +This skill is part of the [caveman](https://github.com/JuliusBrussee/caveman) toolkit — making Claude use fewer tokens without losing accuracy. + +- **caveman** — make Claude *speak* like caveman (cuts response tokens ~65%) +- **caveman-compress** — make Claude *read* less (cuts context tokens ~46%) diff --git a/skills/caveman-compress/SECURITY.md b/skills/caveman-compress/SECURITY.md new file mode 100644 index 0000000..693108c --- /dev/null +++ b/skills/caveman-compress/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +## Snyk High Risk Rating + +`caveman-compress` receives a Snyk High Risk rating due to static analysis heuristics. This document explains what the skill does and does not do. + +### What triggers the rating + +1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument. + +2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are read or written. + +### What the skill does NOT do + +- Does not execute user file content as code +- Does not make network requests except to Anthropic's API (via SDK or CLI) +- Does not access files outside the path the user provides +- Does not use shell=True or string interpolation in subprocess calls +- Does not collect or transmit any data beyond the file being compressed + +### Auth behavior + +If `ANTHROPIC_API_KEY` is set, the skill uses the Anthropic Python SDK directly (no subprocess). If not set, it falls back to the `claude` CLI, which uses the user's existing Claude desktop authentication. + +### File size limit + +Files larger than 500KB are rejected before any API call is made. + +### Reporting a vulnerability + +If you believe you've found a genuine security issue, please open a GitHub issue with the label `security`. diff --git a/skills/caveman-compress/SKILL.md b/skills/caveman-compress/SKILL.md new file mode 100644 index 0000000..00ce454 --- /dev/null +++ b/skills/caveman-compress/SKILL.md @@ -0,0 +1,111 @@ +--- +name: caveman-compress +description: > + Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format + to save input tokens. Preserves all technical substance, code, URLs, and structure. + Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md. + Trigger: /caveman-compress FILEPATH or "compress memory file" +--- + +# Caveman Compress + +## Purpose + +Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`. + +## Trigger + +`/caveman-compress ` or when user asks to compress a memory file. + +## Process + +1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. + +2. From the directory containing this SKILL.md, run: + +python3 -m scripts + +3. The CLI will: +- detect file type (no tokens) +- call Claude to compress +- validate output (no tokens) +- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression) +- retry up to 2 times +- if still failing after 2 retries: report error to user, leave original file untouched + +4. Return result to user + +## Compression Rules + +### Remove +- Articles: a, an, the +- Filler: just, really, basically, actually, simply, essentially, generally +- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend" +- Hedging: "it might be worth", "you could consider", "it would be good to" +- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because" +- Connective fluff: "however", "furthermore", "additionally", "in addition" + +### Preserve EXACTLY (never modify) +- Code blocks (fenced ``` and indented) +- Inline code (`backtick content`) +- URLs and links (full URLs, markdown links) +- File paths (`/src/components/...`, `./config.yaml`) +- Commands (`npm install`, `git commit`, `docker build`) +- Technical terms (library names, API names, protocols, algorithms) +- Proper nouns (project names, people, companies) +- Dates, version numbers, numeric values +- Environment variables (`$HOME`, `NODE_ENV`) + +### Preserve Structure +- All markdown headings (keep exact heading text, compress body below) +- Bullet point hierarchy (keep nesting level) +- Numbered lists (keep numbering) +- Tables (compress cell text, keep structure) +- Frontmatter/YAML headers in markdown files + +### Compress +- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize" +- Fragments OK: "Run tests before commit" not "You should always run tests before committing" +- Drop "you should", "make sure to", "remember to" — just state the action +- Merge redundant bullets that say the same thing differently +- Keep one example where multiple examples show the same pattern + +CRITICAL RULE: +Anything inside ``` ... ``` must be copied EXACTLY. +Do not: +- remove comments +- remove spacing +- reorder lines +- shorten commands +- simplify anything + +Inline code (`...`) must be preserved EXACTLY. +Do not modify anything inside backticks. + +If file contains code blocks: +- Treat code blocks as read-only regions +- Only compress text outside them +- Do not merge sections around code + +## Pattern + +Original: +> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production. + +Compressed: +> Run tests before push to main. Catch bugs early, prevent broken prod deploys. + +Original: +> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens. + +Compressed: +> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens. + +## Boundaries + +- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless) +- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh +- If file has mixed content (prose + code), compress ONLY the prose sections +- If unsure whether something is code or prose, leave it unchanged +- Original file is backed up as FILE.original.md before overwriting +- Never compress FILE.original.md (skip it) diff --git a/skills/caveman-compress/scripts/__init__.py b/skills/caveman-compress/scripts/__init__.py new file mode 100644 index 0000000..16b8c53 --- /dev/null +++ b/skills/caveman-compress/scripts/__init__.py @@ -0,0 +1,9 @@ +"""Caveman compress scripts. + +This package provides tools to compress natural language markdown files +into caveman format to save input tokens. +""" + +__all__ = ["cli", "compress", "detect", "validate"] + +__version__ = "1.0.0" diff --git a/skills/caveman-compress/scripts/__main__.py b/skills/caveman-compress/scripts/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/skills/caveman-compress/scripts/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/skills/caveman-compress/scripts/benchmark.py b/skills/caveman-compress/scripts/benchmark.py new file mode 100644 index 0000000..f9e2ee0 --- /dev/null +++ b/skills/caveman-compress/scripts/benchmark.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +# Support both direct execution and module import +try: + from .validate import validate +except ImportError: + sys.path.insert(0, str(Path(__file__).parent)) + from validate import validate + +try: + import tiktoken + _enc = tiktoken.get_encoding("o200k_base") +except ImportError: + _enc = None + + +def count_tokens(text): + if _enc is None: + return len(text.split()) # fallback: word count + return len(_enc.encode(text)) + + +def benchmark_pair(orig_path: Path, comp_path: Path): + orig_text = orig_path.read_text() + comp_text = comp_path.read_text() + + orig_tokens = count_tokens(orig_text) + comp_tokens = count_tokens(comp_text) + saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0 + result = validate(orig_path, comp_path) + + return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid) + + +def print_table(rows): + print("\n| File | Original | Compressed | Saved % | Valid |") + print("|------|----------|------------|---------|-------|") + for r in rows: + print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |") + + +def main(): + # Direct file pair: python3 benchmark.py original.md compressed.md + if len(sys.argv) == 3: + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + if not orig.exists(): + print(f"❌ Not found: {orig}") + sys.exit(1) + if not comp.exists(): + print(f"❌ Not found: {comp}") + sys.exit(1) + print_table([benchmark_pair(orig, comp)]) + return + + # Glob mode: repo_root/tests/caveman-compress/ + # __file__ lives at /skills/caveman-compress/scripts/benchmark.py + # Walk up four dirs: scripts → caveman-compress → skills → repo_root. + tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress" + if not tests_dir.exists(): + print(f"❌ Tests dir not found: {tests_dir}") + sys.exit(1) + + rows = [] + for orig in sorted(tests_dir.glob("*.original.md")): + comp = orig.with_name(orig.stem.removesuffix(".original") + ".md") + if comp.exists(): + rows.append(benchmark_pair(orig, comp)) + + if not rows: + print("No compressed file pairs found.") + return + + print_table(rows) + + +if __name__ == "__main__": + main() diff --git a/skills/caveman-compress/scripts/cli.py b/skills/caveman-compress/scripts/cli.py new file mode 100644 index 0000000..75ea8a6 --- /dev/null +++ b/skills/caveman-compress/scripts/cli.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Caveman Compress CLI + +Usage: + caveman +""" + +import sys + +# Force UTF-8 on stdout/stderr before any code can print. Windows consoles +# default to cp1252 and crash on the ❌ glyphs in error/validation branches, +# masking the real error and leaving the user with a half-compressed file. +for _stream in (sys.stdout, sys.stderr): + reconfigure = getattr(_stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from pathlib import Path + +from .compress import backup_dir_for, compress_file +from .detect import detect_file_type, should_compress + + +def print_usage(): + print("Usage: caveman ") + + +def main(): + if len(sys.argv) != 2: + print_usage() + sys.exit(1) + + filepath = Path(sys.argv[1]) + + # Check file exists + if not filepath.exists(): + print(f"❌ File not found: {filepath}") + sys.exit(1) + + if not filepath.is_file(): + print(f"❌ Not a file: {filepath}") + sys.exit(1) + + filepath = filepath.resolve() + + # Detect file type + file_type = detect_file_type(filepath) + + print(f"Detected: {file_type}") + + # Check if compressible + if not should_compress(filepath): + print("Skipping: file is not natural language (code/config)") + sys.exit(0) + + print("Starting caveman compression...\n") + + try: + success = compress_file(filepath) + + if success: + print("\nCompression completed successfully") + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + print(f"Compressed: {filepath}") + print(f"Original: {backup_path}") + sys.exit(0) + else: + print("\n❌ Compression failed after retries") + sys.exit(2) + + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(130) + + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/caveman-compress/scripts/compress.py b/skills/caveman-compress/scripts/compress.py new file mode 100644 index 0000000..e93d934 --- /dev/null +++ b/skills/caveman-compress/scripts/compress.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Caveman Memory Compression Orchestrator + +Usage: + python scripts/compress.py +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List + +OUTER_FENCE_REGEX = re.compile( + r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL +) + +# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line. +# Captures the entire block (including delimiters and trailing newline) and the body after. +FRONTMATTER_REGEX = re.compile( + r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL +) + + +def split_frontmatter(text: str): + """Split YAML frontmatter from body. Returns (frontmatter, body). + + Memory files (and many other markdown docs) start with a YAML frontmatter + block delimited by `---` lines. The compression LLM has a habit of stripping + or rewriting these despite preserve-structure rules in the prompt — so we + surgically remove the frontmatter before compression and prepend it back + verbatim to the output. Files without frontmatter pass through unchanged. + """ + m = FRONTMATTER_REGEX.match(text) + if m: + return m.group(1), m.group(2) + return "", text + +# Filenames and paths that almost certainly hold secrets or PII. Compressing +# them ships raw bytes to the Anthropic API — a third-party data boundary that +# developers on sensitive codebases cannot cross. detect.py already skips .env +# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would +# slip through the natural-language filter. This is a hard refuse before read. +SENSITIVE_BASENAME_REGEX = re.compile( + r"(?ix)^(" + r"\.env(\..+)?" + r"|\.netrc" + r"|credentials(\..+)?" + r"|secrets?(\..+)?" + r"|passwords?(\..+)?" + r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?" + r"|authorized_keys" + r"|known_hosts" + r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)" + r")$" +) + +SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"}) + +SENSITIVE_NAME_TOKENS = ( + "secret", "credential", "password", "passwd", + "apikey", "accesskey", "token", "privatekey", +) + + +def backup_dir_for(filepath: Path) -> Path: + """Resolve the out-of-tree backup directory for a given source file. + + Backups must live OUTSIDE the source directory so skill auto-loaders + (Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the + `.original.md` copies as live files. Base dir is platform-aware: + - Windows: %LOCALAPPDATA%\\caveman-compress\\backups + - else: $XDG_DATA_HOME/caveman-compress/backups if set, + else ~/.local/share/caveman-compress/backups + + The source file's parent-dir name is mirrored under the base to reduce + cross-project collisions (e.g. two `task.md` files in different repos). + """ + if os.name == "nt" or sys.platform == "win32": + local_appdata = os.environ.get("LOCALAPPDATA") + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + base = base / "caveman-compress" / "backups" + else: + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + base = base / "caveman-compress" / "backups" + return base / filepath.parent.name + + +def is_sensitive_path(filepath: Path) -> bool: + """Heuristic denylist for files that must never be shipped to a third-party API.""" + name = filepath.name + if SENSITIVE_BASENAME_REGEX.match(name): + return True + lowered_parts = {p.lower() for p in filepath.parts} + if lowered_parts & SENSITIVE_PATH_COMPONENTS: + return True + # Normalize separators so "api-key" and "api_key" both match "apikey". + lower = re.sub(r"[_\-\s.]", "", name.lower()) + return any(tok in lower for tok in SENSITIVE_NAME_TOKENS) + + +def strip_llm_wrapper(text: str) -> str: + """Strip outer ```markdown ... ``` fence when it wraps the entire output.""" + m = OUTER_FENCE_REGEX.match(text) + if m: + return m.group(2) + return text + +from .detect import should_compress +from .validate import validate + +MAX_RETRIES = 2 + + +# ---------- Claude Calls ---------- + + +def call_claude(prompt: str) -> str: + """Send a prompt to Claude. + + Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls + back to the ``claude --print`` CLI (which handles desktop auth). + + On Windows the CLI subprocess decoding defaults to the system codepage + (cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning + ``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual + native I/O and prevents the UnicodeDecodeError before validation can + report. Windows users with non-ASCII content can also set + ``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess. + """ + api_key = os.environ.get("ANTHROPIC_API_KEY") + if api_key: + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + msg = client.messages.create( + model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"), + max_tokens=8192, + messages=[{"role": "user", "content": prompt}], + ) + return strip_llm_wrapper(msg.content[0].text.strip()) + except ImportError: + pass # anthropic not installed, fall back to CLI + # Fallback: use claude CLI (handles desktop auth). + # Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g. + # %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX, + # shutil.which returns the same absolute path as the implicit lookup, + # so this is a no-op there. Falls back to bare "claude" if not found + # on PATH so subprocess raises a clear FileNotFoundError. + claude_bin = shutil.which("claude") or "claude" + try: + result = subprocess.run( + [claude_bin, "--print"], + input=prompt, + text=True, + capture_output=True, + check=True, + encoding="utf-8", + errors="replace", + ) + return strip_llm_wrapper(result.stdout.strip()) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Claude call failed:\n{e.stderr}") + + +def build_compress_prompt(original: str) -> str: + return f""" +Compress this markdown into caveman format. + +STRICT RULES: +- Do NOT modify anything inside ``` code blocks +- Do NOT modify anything inside inline backticks +- Preserve ALL URLs exactly +- Preserve ALL headings exactly +- Preserve file paths and commands +- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. + +Only compress natural language. + +TEXT: +{original} +""" + + +def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: + errors_str = "\n".join(f"- {e}" for e in errors) + return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found. + +CRITICAL RULES: +- DO NOT recompress or rephrase the file +- ONLY fix the listed errors — leave everything else exactly as-is +- The ORIGINAL is provided as reference only (to restore missing content) +- Preserve caveman style in all untouched sections + +ERRORS TO FIX: +{errors_str} + +HOW TO FIX: +- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED +- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED +- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED +- Do not touch any section not mentioned in the errors + +ORIGINAL (reference only): +{original} + +COMPRESSED (fix this): +{compressed} + +Return ONLY the fixed compressed file. No explanation. +""" + + +# ---------- Core Logic ---------- + + +def compress_file(filepath: Path) -> bool: + # Resolve and validate path + filepath = filepath.resolve() + MAX_FILE_SIZE = 500_000 # 500KB + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + if filepath.stat().st_size > MAX_FILE_SIZE: + raise ValueError(f"File too large to compress safely (max 500KB): {filepath}") + + # Refuse files that look like they contain secrets or PII. Compressing ships + # the raw bytes to the Anthropic API — a third-party boundary — so we fail + # loudly rather than silently exfiltrate credentials or keys. Override is + # intentional: the user must rename the file if the heuristic is wrong. + if is_sensitive_path(filepath): + raise ValueError( + f"Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Compression sends file contents to the Anthropic API. " + "Rename the file if this is a false positive." + ) + + print(f"Processing: {filepath}") + + if not should_compress(filepath): + print("Skipping (not natural language)") + return False + + original_text = filepath.read_text(errors="ignore") + # Store backup outside the source directory so skill auto-loaders don't + # re-ingest the `.original.md` copy as a live file. Mirror the source's + # parent-dir name + stem under a platform-aware base to reduce collisions. + backup_dir = backup_dir_for(filepath) + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / (filepath.stem + ".original.md") + + if not original_text.strip(): + print("❌ Refusing to compress: file is empty or whitespace-only.") + return False + + # Check if backup already exists to prevent accidental overwriting + if backup_path.exists(): + print(f"⚠️ Backup file already exists: {backup_path}") + print("The original backup may contain important content.") + print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + return False + + # Split YAML frontmatter off before compression. Claude tends to strip or + # rewrite frontmatter despite preserve-structure rules; we keep it verbatim + # by removing it from the input and re-prepending it to the output. + frontmatter, body = split_frontmatter(original_text) + if frontmatter: + print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim") + + if not body.strip(): + print("❌ Refusing to compress: body is empty after frontmatter removal.") + return False + + # Step 1: Compress (body only, frontmatter excluded) + print("Compressing with Claude...") + compressed_body = call_claude(build_compress_prompt(body)) + + if compressed_body is None or not compressed_body.strip(): + print("❌ Compression aborted: Claude returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + + # Compare the BODY (not the whole file) — frontmatter is preserved verbatim + # and would never change, so identity must be judged on the compressible part. + if compressed_body.strip() == body.strip(): + print("❌ Compression aborted: output is identical to input.") + print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is") + print(" already in caveman form. Original file is untouched (no backup created).") + return False + + # Reassemble: frontmatter (verbatim) + compressed body + compressed = frontmatter + compressed_body + + # Save original as backup, then verify the backup readback before + # touching the input file. If the filesystem dropped bytes (encoding, + # antivirus, disk full), unlink the bad backup and abort instead of + # leaving the user with a corrupt backup + compressed primary. + backup_path.write_text(original_text) + backup_readback = backup_path.read_text(errors="ignore") + if backup_readback != original_text: + print(f"❌ Backup write verification failed: {backup_path}") + print(" In-memory original differs from on-disk backup. Aborting before touching the input file.") + try: + backup_path.unlink() + except OSError: + pass + return False + filepath.write_text(compressed) + + # Step 2: Validate + Retry + for attempt in range(MAX_RETRIES): + print(f"\nValidation attempt {attempt + 1}") + + result = validate(backup_path, filepath) + + if result.is_valid: + print("Validation passed") + break + + print("❌ Validation failed:") + for err in result.errors: + print(f" - {err}") + + if attempt == MAX_RETRIES - 1: + # Restore original on failure + filepath.write_text(original_text) + backup_path.unlink(missing_ok=True) + print("❌ Failed after retries — original restored") + return False + + print("Fixing with Claude...") + compressed = call_claude( + build_fix_prompt(original_text, compressed, result.errors) + ) + filepath.write_text(compressed) + + return True diff --git a/skills/caveman-compress/scripts/detect.py b/skills/caveman-compress/scripts/detect.py new file mode 100644 index 0000000..5114d04 --- /dev/null +++ b/skills/caveman-compress/scripts/detect.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Detect whether a file is natural language (compressible) or code/config (skip).""" + +import json +import re +from pathlib import Path + +# Extensions that are natural language and compressible +COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} + +# Extensions that are code/config and should be skipped +SKIP_EXTENSIONS = { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", + ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml", + ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c", + ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua", + ".dockerfile", ".makefile", ".csv", ".ini", ".cfg", +} + +# Well-known build/config files that carry no (or a misleading) extension — +# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and +# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by +# basename before any extension rule. +KNOWN_CODE_FILENAMES = { + "dockerfile", "makefile", "gnumakefile", "jenkinsfile", "vagrantfile", + "rakefile", "gemfile", "justfile", "procfile", "brewfile", + "cmakelists.txt", +} + +# Patterns that indicate a line is code +CODE_PATTERNS = [ + re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"), + re.compile(r"^\s*(def |class |function |async function |export )"), + re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"), + re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets + re.compile(r"^\s*@\w+"), # decorators/annotations + re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value + re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal +] + + +def _is_code_line(line: str) -> bool: + """Check if a line looks like code.""" + return any(p.match(line) for p in CODE_PATTERNS) + + +def _is_json_content(text: str) -> bool: + """Check if content is valid JSON.""" + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + return False + + +def _is_yaml_content(lines: list[str]) -> bool: + """Heuristic: check if content looks like YAML.""" + yaml_indicators = 0 + for line in lines[:30]: + stripped = line.strip() + if stripped.startswith("---"): + yaml_indicators += 1 + elif re.match(r"^\w[\w\s]*:\s", stripped): + yaml_indicators += 1 + elif stripped.startswith("- ") and ":" in stripped: + yaml_indicators += 1 + # If most non-empty lines look like YAML + non_empty = sum(1 for l in lines[:30] if l.strip()) + return non_empty > 0 and yaml_indicators / non_empty > 0.6 + + +def detect_file_type(filepath: Path) -> str: + """Classify a file as 'natural_language', 'code', 'config', or 'unknown'. + + Returns: + One of: 'natural_language', 'code', 'config', 'unknown' + """ + ext = filepath.suffix.lower() + + # Known code filenames win over any extension rule + if filepath.name.lower() in KNOWN_CODE_FILENAMES: + return "code" + + # Extension-based classification + if ext in COMPRESSIBLE_EXTENSIONS: + return "natural_language" + if ext in SKIP_EXTENSIONS: + return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config" + + # Extensionless files (like CLAUDE.md, TODO) — check content + if not ext: + try: + text = filepath.read_text(errors="ignore") + except (OSError, PermissionError): + return "unknown" + + lines = text.splitlines()[:50] + + # Shebang means executable script, never prose + if text.startswith("#!"): + return "code" + + if _is_json_content(text[:10000]): + return "config" + if _is_yaml_content(lines): + return "config" + + code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l)) + non_empty = sum(1 for l in lines if l.strip()) + if non_empty > 0 and code_lines / non_empty > 0.4: + return "code" + + return "natural_language" + + return "unknown" + + +def should_compress(filepath: Path) -> bool: + """Return True if the file is natural language and should be compressed.""" + if not filepath.is_file(): + return False + # Skip backup files + if filepath.name.endswith(".original.md"): + return False + return detect_file_type(filepath) == "natural_language" + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python detect.py [file2] ...") + sys.exit(1) + + for path_str in sys.argv[1:]: + p = Path(path_str).resolve() + file_type = detect_file_type(p) + compress = should_compress(p) + print(f" {p.name:30s} type={file_type:20s} compress={compress}") diff --git a/skills/caveman-compress/scripts/validate.py b/skills/caveman-compress/scripts/validate.py new file mode 100644 index 0000000..dc07307 --- /dev/null +++ b/skills/caveman-compress/scripts/validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +import re +from collections import Counter +from pathlib import Path + +URL_REGEX = re.compile(r"https?://[^\s)]+") +FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$") +HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE) +BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE) + +# crude but effective path detection +# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match +PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+") + + +class ValidationResult: + def __init__(self): + self.is_valid = True + self.errors = [] + self.warnings = [] + + def add_error(self, msg): + self.is_valid = False + self.errors.append(msg) + + def add_warning(self, msg): + self.warnings.append(msg) + + +def read_file(path: Path) -> str: + return path.read_text(errors="ignore") + + +# ---------- Extractors ---------- + + +def extract_headings(text): + return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)] + + +def extract_code_blocks(text): + """Line-based fenced code block extractor. + + Handles ``` and ~~~ fences with variable length (CommonMark: closing + fence must use same char and be at least as long as opening). Supports + nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick + content). + """ + blocks = [] + lines = text.split("\n") + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + open_line = lines[i] + block_lines = [open_line] + i += 1 + closed = False + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + block_lines.append(lines[i]) + closed = True + i += 1 + break + block_lines.append(lines[i]) + i += 1 + if closed: + blocks.append("\n".join(block_lines)) + # Unclosed fences are silently skipped — they indicate malformed markdown + # and including them would cause false-positive validation failures. + return blocks + + +def extract_urls(text): + return set(URL_REGEX.findall(text)) + + +def extract_paths(text): + return set(PATH_REGEX.findall(text)) + + +def count_bullets(text): + return len(BULLET_REGEX.findall(text)) + + +def extract_inline_codes(text): + text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE) + text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE) + return re.findall(r"`([^`]+)`", text_without_fences) + + +# ---------- Validators ---------- + + +def validate_headings(orig, comp, result): + h1 = extract_headings(orig) + h2 = extract_headings(comp) + + if len(h1) != len(h2): + result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}") + + if h1 != h2: + result.add_warning("Heading text/order changed") + + +def validate_code_blocks(orig, comp, result): + c1 = extract_code_blocks(orig) + c2 = extract_code_blocks(comp) + + if c1 != c2: + result.add_error("Code blocks not preserved exactly") + + +def validate_urls(orig, comp, result): + u1 = extract_urls(orig) + u2 = extract_urls(comp) + + if u1 != u2: + result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}") + + +def validate_paths(orig, comp, result): + p1 = extract_paths(orig) + p2 = extract_paths(comp) + + if p1 != p2: + result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}") + + +def validate_bullets(orig, comp, result): + b1 = count_bullets(orig) + b2 = count_bullets(comp) + + if b1 == 0: + return + + diff = abs(b1 - b2) / b1 + + if diff > 0.15: + result.add_warning(f"Bullet count changed too much: {b1} -> {b2}") + + +def validate_inline_codes(orig, comp, result): + c1 = Counter(extract_inline_codes(orig)) + c2 = Counter(extract_inline_codes(comp)) + + if c1 != c2: + lost = set(c1.keys()) - set(c2.keys()) + added = set(c2.keys()) - set(c1.keys()) + for code, count in c1.items(): + if code in c2 and c2[code] < count: + lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)") + if lost: + result.add_error(f"Inline code lost: {lost}") + if added: + result.add_warning(f"Inline code added: {added}") + + +# ---------- Main ---------- + + +def validate(original_path: Path, compressed_path: Path) -> ValidationResult: + result = ValidationResult() + + orig = read_file(original_path) + comp = read_file(compressed_path) + + validate_headings(orig, comp, result) + validate_code_blocks(orig, comp, result) + validate_urls(orig, comp, result) + validate_paths(orig, comp, result) + validate_bullets(orig, comp, result) + validate_inline_codes(orig, comp, result) + + return result + + +# ---------- CLI ---------- + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 3: + print("Usage: python validate.py ") + sys.exit(1) + + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + + res = validate(orig, comp) + + print(f"\nValid: {res.is_valid}") + + if res.errors: + print("\nErrors:") + for e in res.errors: + print(f" - {e}") + + if res.warnings: + print("\nWarnings:") + for w in res.warnings: + print(f" - {w}") diff --git a/skills/caveman-help/README.md b/skills/caveman-help/README.md new file mode 100644 index 0000000..5841256 --- /dev/null +++ b/skills/caveman-help/README.md @@ -0,0 +1,38 @@ +# caveman-help + +Quick-reference card. One shot, no mode change. + +## What it does + +Prints a cheat sheet of all caveman modes, sibling skills, deactivation triggers, and how to set the default mode via env var or config file. One-shot display — does not flip the active mode, write flag files, or persist anything. Use when you forget the slash commands. + +## How to invoke + +``` +/caveman-help +``` + +Also triggers on "caveman help", "what caveman commands", "how do I use caveman". + +## Example output + +``` +Modes: + /caveman full (default) + /caveman lite lighter + /caveman ultra extreme + /caveman wenyan classical Chinese + +Skills: + /caveman-commit terse Conventional Commits + /caveman-review one-line PR comments + /caveman-stats session token savings + +Deactivate: + "stop caveman" or "normal mode" +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full reference card +- [Caveman README](../../README.md) — repo overview diff --git a/skills/caveman-help/SKILL.md b/skills/caveman-help/SKILL.md new file mode 100644 index 0000000..ce1156f --- /dev/null +++ b/skills/caveman-help/SKILL.md @@ -0,0 +1,63 @@ +--- +name: caveman-help +description: > + Quick-reference card for all caveman modes, skills, and commands. + One-shot display, not a persistent mode. Trigger: /caveman-help, + "caveman help", "what caveman commands", "how do I use caveman". +--- + +# Caveman Help + +Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style. + +## Modes + +| Mode | Trigger | What change | +|------|---------|-------------| +| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. | +| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. | +| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. | +| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. | +| **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. | +| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. | + +Mode stick until changed or session end. + +## Skills + +| Skill | Trigger | What it do | +|-------|---------|-----------| +| **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-help** | `/caveman-help` | This card. | + +## Deactivate + +Say "stop caveman" or "normal mode". Resume anytime with `/caveman`. + +## Language + +Keep user's language by default. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, commands, commit types, and exact error strings stay verbatim unless user ask for translation. + +## Configure Default Mode + +Default mode = `full`. Change it: + +**Environment variable** (highest priority): +```bash +export CAVEMAN_DEFAULT_MODE=ultra +``` + +**Config file** (`~/.config/caveman/config.json`): +```json +{ "defaultMode": "lite" } +``` + +Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`. + +Resolution: env var > config file > `full`. + +## More + +Full docs: https://github.com/JuliusBrussee/caveman diff --git a/skills/caveman-review/README.md b/skills/caveman-review/README.md new file mode 100644 index 0000000..acf519f --- /dev/null +++ b/skills/caveman-review/README.md @@ -0,0 +1,33 @@ +# caveman-review + +One-line PR comments. Location, problem, fix. No throat-clearing. + +## What it does + +Generates code review comments in `L: . .` format. One line per finding. Severity emoji: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps exact line numbers, backticked symbols, and concrete fixes. + +Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts where the author needs the *why*. Resumes terse for the rest. + +Output only — does not approve, request changes, or run linters. + +## How to invoke + +``` +/caveman-review +``` + +Also triggers on "review this PR", "code review", "review the diff". + +## Example output + +``` +L42: 🔴 bug: user can be null after .find(). Add guard before .email. +L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist. +L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3). +L107: ❓ q: why drop the cache here? Reads on next request will miss. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/skills/caveman-review/SKILL.md b/skills/caveman-review/SKILL.md new file mode 100644 index 0000000..48f4adb --- /dev/null +++ b/skills/caveman-review/SKILL.md @@ -0,0 +1,55 @@ +--- +name: caveman-review +description: > + Ultra-compressed code review comments. Cuts noise from PR feedback while preserving + the actionable signal. Each comment is one line: location, problem, fix. Use when user + says "review this PR", "code review", "review the diff", "/review", or invokes + /caveman-review. Auto-triggers when reviewing pull requests. +--- + +Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing. + +## Rules + +**Format:** `L: . .` — or `:L: ...` when reviewing multi-file diffs. + +**Severity prefix (optional, when mixed):** +- `🔴 bug:` — broken behavior, will cause incident +- `🟡 risk:` — works but fragile (race, missing null check, swallowed error) +- `🔵 nit:` — style, naming, micro-optim. Author can ignore +- `❓ q:` — genuine question, not a suggestion + +**Drop:** +- "I noticed that...", "It seems like...", "You might want to consider..." +- "This is just a suggestion but..." — use `nit:` instead +- "Great work!", "Looks good overall but..." — say it once at the top, not per comment +- Restating what the line does — the reviewer can read the diff +- Hedging ("perhaps", "maybe", "I think") — if unsure use `q:` + +**Keep:** +- Exact line numbers +- Exact symbol/function/variable names in backticks +- Concrete fix, not "consider refactoring this" +- The *why* if the fix isn't obvious from the problem statement + +## Examples + +❌ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This could potentially cause a crash if the user is not found in the database. You might want to add a null check here." + +✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.` + +❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability." + +✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.` + +❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case." + +✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).` + +## Auto-Clarity + +Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those cases write a normal paragraph, then resume terse for the rest. + +## Boundaries + +Reviews only — does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style. \ No newline at end of file diff --git a/skills/caveman-stats/README.md b/skills/caveman-stats/README.md new file mode 100644 index 0000000..30b28f9 --- /dev/null +++ b/skills/caveman-stats/README.md @@ -0,0 +1,30 @@ +# caveman-stats + +Real session token receipts. No AI estimation. + +## What it does + +Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings versus a non-caveman baseline. Numbers come from the JSONL session log on disk — the model itself does not compute or estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the formatted stats as a blocked-decision reason. + +Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). + +## How to invoke + +``` +/caveman-stats +``` + +## Example output + +``` +Session: 47 turns +Input: 12,304 tokens +Output: 3,891 tokens (caveman) +Baseline: 11,247 tokens (estimated without caveman) +Saved: 7,356 tokens (~65%) +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — hook contract and mechanics +- [Caveman README](../../README.md) — repo overview diff --git a/skills/caveman-stats/SKILL.md b/skills/caveman-stats/SKILL.md new file mode 100644 index 0000000..a7348ac --- /dev/null +++ b/skills/caveman-stats/SKILL.md @@ -0,0 +1,10 @@ +--- +name: caveman-stats +description: > + Show real token usage and estimated savings for the current session. + Reads directly from the Claude Code session log — no AI estimation. + Triggers on /caveman-stats. Output is injected by the mode-tracker hook; + the model itself does not compute the numbers. +--- + +This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately. diff --git a/skills/caveman/README.md b/skills/caveman/README.md new file mode 100644 index 0000000..696a4e3 --- /dev/null +++ b/skills/caveman/README.md @@ -0,0 +1,48 @@ +# caveman + +Talk like smart caveman. Same brain, fewer tokens. + +## What it does + +Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped. + +Six intensity levels: + +| Level | What change | +|-------|-------------| +| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | +| `full` | Default. Drop articles, fragments OK, short synonyms. | +| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | +| `wenyan-lite` | Classical Chinese register, light compression. | +| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | +| `wenyan-ultra` | Extreme classical compression. | + +Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. + +## How to invoke + +``` +/caveman # full mode (default) +/caveman lite # lighter compression +/caveman ultra # extreme compression +/caveman wenyan # classical Chinese +stop caveman # back to normal prose +``` + +## Example output + +Question: "Why does my React component re-render?" + +Normal prose: +> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. + +Caveman (full): +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +Caveman (ultra): +> Inline obj prop → new ref → re-render. `useMemo`. + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/skills/caveman/SKILL.md b/skills/caveman/SKILL.md new file mode 100644 index 0000000..adf8bc5 --- /dev/null +++ b/skills/caveman/SKILL.md @@ -0,0 +1,78 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. + +No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | +| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop, new ref, re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" +- wenyan-ultra: "新參照則重繪。useMemo 包之。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool reuse open DB connections. No per-request handshake." +- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。" +- wenyan-ultra: "池蓄連,免逐請新開,省握手。" + +## Auto-Clarity + +Drop caveman when: +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/src/hooks/README.md b/src/hooks/README.md new file mode 100644 index 0000000..04e97e5 --- /dev/null +++ b/src/hooks/README.md @@ -0,0 +1,111 @@ +# Caveman Hooks + +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), 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_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 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_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 + +The statusline badge shows which caveman mode is active directly in your Claude Code status bar. + +**Plugin users:** If you do not already have a `statusLine` configured, Claude will detect that on your first session after install and offer to set it up for you. Accept and you're done. + +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:** 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`: + +```json +{ + "statusLine": { + "type": "command", + "command": "bash /path/to/caveman-statusline.sh" + } +} +``` + +```json +{ + "statusLine": { + "type": "command", + "command": "powershell -ExecutionPolicy Bypass -File C:\\path\\to\\caveman-statusline.ps1" + } +} +``` + +Replace the path with the actual script location (e.g. `~/.claude/hooks/` for standalone installs, or the plugin install directory for plugin installs). + +**Custom statusline:** If you already have a statusline script, add this snippet to it: + +```bash +caveman_text="" +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 + caveman_text=$'\033[38;5;172m[CAVEMAN]\033[0m' + else + caveman_suffix=$(echo "$caveman_mode" | tr '[:lower:]' '[:upper:]') + caveman_text=$'\033[38;5;172m[CAVEMAN:'"${caveman_suffix}"$']\033[0m' + fi +fi +``` + +Badge examples: +- `/caveman` → `[CAVEMAN]` +- `/caveman ultra` → `[CAVEMAN:ULTRA]` +- `/caveman wenyan` → `[CAVEMAN:WENYAN]` +- `/caveman-commit` → `[CAVEMAN:COMMIT]` +- `/caveman-review` → `[CAVEMAN:REVIEW]` + +## How It Works + +``` +SessionStart hook ──writes "full"──▶ $CLAUDE_CONFIG_DIR/.caveman-active ◀──writes mode── UserPromptSubmit hook + │ + reads + ▼ + Statusline script + [CAVEMAN:ULTRA] │ ... +``` + +SessionStart stdout is injected as hidden system context — Claude sees it, users don't. The statusline runs as a separate process. The flag file is the bridge. + +## Uninstall + +If installed via plugin: disable the plugin — hooks deactivate automatically. + +If installed via the standalone Node installer: +```bash +npx -y github:JuliusBrussee/caveman -- --uninstall +# or, from a clone: +node bin/install.js --uninstall +``` + +Or manually: +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/src/hooks/cavecrew-model-overrides.js b/src/hooks/cavecrew-model-overrides.js new file mode 100644 index 0000000..1df5d76 --- /dev/null +++ b/src/hooks/cavecrew-model-overrides.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// cavecrew model overrides — patch installed agent frontmatter from env vars. +// +// Called by caveman-activate.js early in SessionStart so users can pin +// per-agent models without shadow-copying entire agent files. +// +// Env vars: +// CAVECREW_REVIEWER_MODEL → agents/cavecrew-reviewer.md +// CAVECREW_BUILDER_MODEL → agents/cavecrew-builder.md +// CAVECREW_INVESTIGATOR_MODEL → agents/cavecrew-investigator.md +// +// Rules: +// - Unset / blank → no-op. +// - Values containing newlines or control characters → ignored. +// - Existing `model:` line in frontmatter → replaced in-place. +// - No `model:` line → inserted after `tools:` (or before closing `---`). +// - File missing / outside plugin layout → silent no-op. +// - All filesystem errors → silent fail. + +const fs = require('fs'); +const path = require('path'); + +const AGENT_ENV_MAP = [ + { envVar: 'CAVECREW_REVIEWER_MODEL', file: path.join('agents', 'cavecrew-reviewer.md') }, + { envVar: 'CAVECREW_BUILDER_MODEL', file: path.join('agents', 'cavecrew-builder.md') }, + { envVar: 'CAVECREW_INVESTIGATOR_MODEL', file: path.join('agents', 'cavecrew-investigator.md') }, +]; + +// Return the plugin root directory given the hooks directory path. +// Plugin layout: /hooks/ → plugin root = parent of hooks dir. +function resolvePluginRoot(hookDir) { + return path.resolve(hookDir, '..'); +} + +// Patch the YAML frontmatter of `content` to set `model: `. +// Returns the patched string, or the original if no frontmatter or already identical. +// Rejects `modelValue` strings that contain newlines or control characters. +function patchFrontmatterModel(content, modelValue) { + // Reject blank or unsafe model strings + if (!modelValue || /[\x00-\x1f\x7f]/.test(modelValue)) return content; + + // Must begin with YAML frontmatter delimiter + if (!content.startsWith('---')) return content; + + // Find the closing --- + const closeIdx = content.indexOf('\n---', 3); + if (closeIdx === -1) return content; + + const fmRaw = content.slice(0, closeIdx); // opening --- through last fm line + const after = content.slice(closeIdx); // \n--- onward (body) + + // Preserve original line ending so we don't create mixed CRLF/LF on Windows + const nl = fmRaw.includes('\r\n') ? '\r\n' : '\n'; + + const modelLine = 'model: ' + modelValue; + const modelRe = /^model:[ \t]*.*$/m; + + if (modelRe.test(fmRaw)) { + // Replace existing model: line + const patched = fmRaw.replace(modelRe, modelLine); + if (patched === fmRaw) return content; // already identical + return patched + after; + } + + // Insert after tools: line when present; else before closing --- + const toolsMatch = fmRaw.match(/^tools:[ \t]*.*$/m); + if (toolsMatch) { + const toolsEnd = fmRaw.indexOf(toolsMatch[0]) + toolsMatch[0].length; + return fmRaw.slice(0, toolsEnd) + nl + modelLine + fmRaw.slice(toolsEnd) + after; + } + + // Append before closing delimiter + return fmRaw + nl + modelLine + after; +} + +// Apply all env-var overrides to agent files under `pluginRoot`. +// `env` defaults to process.env; pass an object in tests. +function applyOverrides(pluginRoot, env) { + const envArg = env || process.env; + for (const { envVar, file } of AGENT_ENV_MAP) { + const raw = envArg[envVar]; + if (!raw || !raw.trim()) continue; + + const modelValue = raw.trim(); + if (/[\x00-\x1f\x7f]/.test(modelValue)) continue; + + const agentPath = path.join(pluginRoot, file); + let content; + try { + content = fs.readFileSync(agentPath, 'utf8'); + } catch (e) { + continue; // missing file or wrong layout → silent no-op + } + + const patched = patchFrontmatterModel(content, modelValue); + if (patched === content) continue; + + try { + fs.writeFileSync(agentPath, patched, 'utf8'); + } catch (e) { + // Silent fail — never block session start + } + } +} + +module.exports = { resolvePluginRoot, patchFrontmatterModel, applyOverrides, AGENT_ENV_MAP }; diff --git a/src/hooks/caveman-activate.js b/src/hooks/caveman-activate.js new file mode 100644 index 0000000..06b9eb9 --- /dev/null +++ b/src/hooks/caveman-activate.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// caveman — Claude Code SessionStart activation hook +// +// Runs on every session start: +// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.caveman-active (statusline reads this) +// 2. Emits caveman ruleset as hidden SessionStart context +// 3. Detects missing statusline config and emits setup nudge + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { getDefaultMode, safeWriteFlag, recordModeChange } = require('./caveman-config'); + +const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +const flagPath = path.join(claudeDir, '.caveman-active'); +const settingsPath = path.join(claudeDir, 'settings.json'); + +// Apply per-agent model overrides from env vars before emitting rules. +// Best-effort: any error is swallowed so SessionStart is never blocked. +try { + const { applyOverrides, resolvePluginRoot } = require('./cavecrew-model-overrides'); + applyOverrides(resolvePluginRoot(__dirname)); +} catch (e) {} + +const mode = getDefaultMode(); + +// "off" mode — skip activation entirely, don't write flag or emit rules +if (mode === 'off') { + recordModeChange(claudeDir, null); // #601: timestamped transition log + try { fs.unlinkSync(flagPath); } catch (e) {} + process.stdout.write('OK'); + process.exit(0); +} + +// 1. Write flag file (symlink-safe) +recordModeChange(claudeDir, mode); // #601 +safeWriteFlag(flagPath, mode); + +// 2. Emit full caveman ruleset, filtered to the active intensity level. +// The old 2-sentence summary was too weak — models drifted back to verbose +// mid-conversation, especially after context compression pruned it away. +// Full rules with examples anchor behavior much more reliably. +// +// Reads SKILL.md at runtime so edits to the source of truth propagate +// automatically — no hardcoded duplication to go stale. + +// Modes that have their own independent skill files — not caveman intensity levels. +// For these, emit a short activation line; the skill itself handles behavior. +const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + +if (INDEPENDENT_MODES.has(mode)) { + process.stdout.write('CAVEMAN MODE ACTIVE — level: ' + mode + '. Behavior defined by /caveman-' + mode + ' skill.'); + process.exit(0); +} + +// Resolve the canonical label for wenyan alias +const modeLabel = mode === 'wenyan' ? 'wenyan-full' : mode; + +// Read SKILL.md — the single source of truth for caveman behavior. +// Candidate locations, tried in order (#587/#589 — the old single '..' path +// resolved to /src/skills/, which doesn't exist, so plugin +// installs silently used the stale fallback ruleset): +// 1. $CLAUDE_PLUGIN_ROOT/skills/caveman/SKILL.md — Claude Code sets +// CLAUDE_PLUGIN_ROOT when invoking plugin hooks; authoritative when present. +// 2. ../../skills/caveman/SKILL.md — hook at /src/hooks/ +// (plugin.json layout) or a repo checkout. +// 3. ../skills/caveman/SKILL.md — standalone install with hooks at +// $CLAUDE_CONFIG_DIR/hooks/ and the skill at $CLAUDE_CONFIG_DIR/skills/caveman/. +// All misses fall through to the hardcoded fallback ruleset below. +const skillCandidates = []; +if (process.env.CLAUDE_PLUGIN_ROOT) { + skillCandidates.push(path.join(process.env.CLAUDE_PLUGIN_ROOT, 'skills', 'caveman', 'SKILL.md')); +} +skillCandidates.push( + path.join(__dirname, '..', '..', 'skills', 'caveman', 'SKILL.md'), + path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md') +); + +let skillContent = ''; +for (const candidate of skillCandidates) { + try { + skillContent = fs.readFileSync(candidate, 'utf8'); + break; + } catch (e) { /* try next candidate */ } +} + +let output; + +if (skillContent) { + // Strip YAML frontmatter + const body = skillContent.replace(/^---[\s\S]*?---\s*/, ''); + + // Filter intensity table: keep header rows + only the active level's row + const filtered = body.split('\n').reduce((acc, line) => { + // Intensity table rows start with | **level** | + const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/); + if (tableRowMatch) { + // Keep only the active level's row (and always keep header/separator) + if (tableRowMatch[1] === modeLabel) { + acc.push(line); + } + return acc; + } + + // Example lines start with "- level:" — keep only lines matching active level + const exampleMatch = line.match(/^- (\S+?):\s/); + if (exampleMatch) { + if (exampleMatch[1] === modeLabel) { + acc.push(line); + } + return acc; + } + + acc.push(line); + return acc; + }, []); + + output = 'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' + filtered.join('\n'); +} else { + // Fallback when SKILL.md is not found (standalone hook install without skills dir). + // This is the minimum viable ruleset — better than nothing. + output = + 'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' + + 'Respond terse like smart caveman. All technical substance stay. Only fluff die.\n\n' + + '## Persistence\n\n' + + 'ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".\n\n' + + 'Current level: **' + modeLabel + '**. Switch: `/caveman lite|full|ultra`.\n\n' + + '## Rules\n\n' + + 'Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. ' + + 'Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.\n\n' + + "Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, API names, commands, error strings stay verbatim.\n\n" + + 'No self-reference. Never name or announce the style. No "caveman mode on" tags. Output caveman-only.\n\n' + + 'Pattern: `[thing] [action] [reason]. [next step].`\n\n' + + 'Not: "Sure! I\'d be happy to help you with that. The issue you\'re experiencing is likely caused by..."\n' + + 'Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"\n\n' + + '## Auto-Clarity\n\n' + + 'Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.\n\n' + + '## Boundaries\n\n' + + 'Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.'; +} + +// 3. Detect missing statusline config — nudge Claude to help set it up +try { + let hasStatusline = false; + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (settings.statusLine) { + hasStatusline = true; + } + } + + if (!hasStatusline) { + const isWindows = process.platform === 'win32'; + const scriptName = isWindows ? 'caveman-statusline.ps1' : 'caveman-statusline.sh'; + const scriptPath = path.join(__dirname, scriptName); + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The caveman plugin includes a statusline badge showing active mode " + + "(e.g. [CAVEMAN], [CAVEMAN:ULTRA]). It is not configured yet. " + + "To enable, add this to " + path.join(claudeDir, 'settings.json') + ": " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } +} catch (e) { + // Silent fail — don't block session start over statusline detection +} + +process.stdout.write(output); diff --git a/src/hooks/caveman-config.js b/src/hooks/caveman-config.js new file mode 100644 index 0000000..9c4c289 --- /dev/null +++ b/src/hooks/caveman-config.js @@ -0,0 +1,348 @@ +#!/usr/bin/env node +// caveman — shared configuration resolver +// +// Resolution order for default mode: +// 1. CAVEMAN_DEFAULT_MODE environment variable +// 2. Repo-local config (checked-in, per-project default): +// - /.caveman/config.json +// - /.caveman.json +// Walks up from process.cwd() to the nearest ancestor containing one of +// these (stops at filesystem root). Lets a team pin a project's default +// mode without polluting every contributor's user-level config or env. +// 3. User config file defaultMode field: +// - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set) +// - ~/.config/caveman/config.json (macOS / Linux fallback) +// - %APPDATA%\caveman\config.json (Windows fallback) +// 4. 'full' + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const VALID_MODES = [ + 'off', 'lite', 'full', 'ultra', + 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', + 'commit', 'review', 'compress' +]; + +function getConfigDir() { + if (process.env.XDG_CONFIG_HOME) { + return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + } + if (process.platform === 'win32') { + return path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + 'caveman' + ); + } + return path.join(os.homedir(), '.config', 'caveman'); +} + +function getConfigPath() { + return path.join(getConfigDir(), 'config.json'); +} + +// Walk up from `start` looking for a repo-local caveman config. Returns the +// absolute path of the first match, or null. Stops at the filesystem root. +// Candidates per dir (first wins): .caveman/config.json, .caveman.json. +// +// Bounded to 64 levels to defend against symlink cycles on pathological mounts. +function findRepoConfigPath(start) { + try { + let dir = path.resolve(start || process.cwd()); + const candidates = ['.caveman/config.json', '.caveman.json']; + for (let i = 0; i < 64; i++) { + for (const rel of candidates) { + const p = path.join(dir, rel); + try { + const st = fs.lstatSync(p); + // Refuse symlinks — symmetric with safeWriteFlag/readFlag policy. + if (st.isSymbolicLink() || !st.isFile()) continue; + return p; + } catch (e) { + // not present, try next candidate + } + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } catch (e) { + // Defensive: any cwd / fs failure → no repo config + } + return null; +} + +function readModeFromConfigFile(configPath) { + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const config = JSON.parse(raw); + if (config && config.defaultMode && + VALID_MODES.includes(String(config.defaultMode).toLowerCase())) { + return String(config.defaultMode).toLowerCase(); + } + } catch (e) { + // Missing / unreadable / invalid JSON → caller falls through + } + return null; +} + +function getDefaultMode() { + // 1. Environment variable (highest priority) + const envMode = process.env.CAVEMAN_DEFAULT_MODE; + if (envMode && VALID_MODES.includes(envMode.toLowerCase())) { + return envMode.toLowerCase(); + } + + // 2. Repo-local config (checked-in, per-project default) + const repoConfigPath = findRepoConfigPath(process.cwd()); + if (repoConfigPath) { + const repoMode = readModeFromConfigFile(repoConfigPath); + if (repoMode) return repoMode; + } + + // 3. User config file + const userMode = readModeFromConfigFile(getConfigPath()); + if (userMode) return userMode; + + // 4. Default + return 'full'; +} + +// Symlink-safe flag file write. +// Uses O_NOFOLLOW where available, writes atomically via temp + rename with +// 0600 permissions. Protects against local attackers replacing the predictable +// flag path (~/.claude/.caveman-active) with a symlink to clobber other files. +// +// When the parent directory is itself a symlink (legitimate pattern: ~/.claude +// symlinked to another drive or shared config dir), resolves through to the +// real path and verifies ownership on Unix (uid match). This allows e.g. +// ln -s /opt/shared-claude-config ~/.claude +// while still refusing attacker-planted symlinks pointing to dirs owned by +// another user. +// +// On Windows, uid checks are unavailable — falls back to verifying the resolved +// path lives under the user's home directory. +// +// The flag file itself must never be a symlink (that's the actual clobber vector). +// +// Set CAVEMAN_DEBUG=1 to emit stderr diagnostics when flag writes are refused. +// +// Silent-fails on any filesystem error — the flag is best-effort. +function safeWriteFlag(flagPath, content) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const flagDir = path.dirname(flagPath); + fs.mkdirSync(flagDir, { recursive: true }); + + // When the parent directory is a symlink, resolve it and verify ownership. + // This allows legitimate symlinked ~/.claude dirs while still refusing + // attacker-planted symlinks pointing at dirs owned by another user. + let realFlagDir; + try { + const lstat = fs.lstatSync(flagDir); + if (lstat.isSymbolicLink()) { + realFlagDir = fs.realpathSync(flagDir); + const realStat = fs.statSync(realFlagDir); + if (!realStat.isDirectory()) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not a directory\n`); + return; + } + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`); + return; + } + } else { + const home = os.homedir(); + const normalizedReal = path.resolve(realFlagDir); + const normalizedHome = path.resolve(home); + if (!normalizedReal.toLowerCase().startsWith(normalizedHome.toLowerCase() + path.sep) && + normalizedReal.toLowerCase() !== normalizedHome.toLowerCase()) { + if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${normalizedReal} is outside home directory ${normalizedHome}\n`); + return; + } + } + } else { + realFlagDir = flagDir; + } + } catch (e) { + return; + } + + // The flag file itself must never be a symlink (that's the actual clobber vector). + const realFlagPath = path.join(realFlagDir, path.basename(flagPath)); + try { + if (fs.lstatSync(realFlagPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`); + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(tempPath, flags, 0o600); + fs.writeSync(fd, String(content)); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + fs.renameSync(tempPath, realFlagPath); + } catch (e) { + // Silent fail — flag is best-effort + } +} + +// Symlink-safe, size-capped, whitelist-validated flag file read. +// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read, +// and rejects anything that isn't a known mode. Returns null on any anomaly. +// +// Without this, a local attacker with write access to ~/.claude/ could replace +// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every +// reader — statusline, per-turn reinforcement — would slurp that content and +// either echo it to the terminal or inject it into model context. +// +// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is "wenyan-ultra" +// (12 bytes); 64 leaves slack without enabling exfil. +const MAX_FLAG_BYTES = 64; + +function readFlag(flagPath) { + try { + let st; + try { + st = fs.lstatSync(flagPath); + } catch (e) { + return null; + } + if (st.isSymbolicLink() || !st.isFile()) return null; + if (st.size > MAX_FLAG_BYTES) return null; + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let out; + try { + fd = fs.openSync(flagPath, flags); + const buf = Buffer.alloc(MAX_FLAG_BYTES); + const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); + out = buf.slice(0, n).toString('utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + + const raw = out.trim().toLowerCase(); + if (!VALID_MODES.includes(raw)) return null; + return raw; + } catch (e) { + return null; + } +} + +// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag, +// but opens with O_APPEND so concurrent writers from different sessions don't +// clobber each other. Used for the lifetime stats log +// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl). +// +// Silent-fails on any filesystem error. +function appendFlag(filePath, line) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + let realDir; + try { + const lstat = fs.lstatSync(dir); + if (lstat.isSymbolicLink()) { + realDir = fs.realpathSync(dir); + const realStat = fs.statSync(realDir); + if (!realStat.isDirectory()) return; + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`); + return; + } + } else { + const home = os.homedir(); + const normalized = path.resolve(realDir).toLowerCase(); + const normalizedHome = path.resolve(home).toLowerCase(); + if (!normalized.startsWith(normalizedHome + path.sep) && normalized !== normalizedHome) return; + } + } else { + realDir = dir; + } + } catch (e) { + return; + } + + const realPath = path.join(realDir, path.basename(filePath)); + try { + if (fs.lstatSync(realPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(realPath, flags, 0o600); + fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n'); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + } catch (e) { + // Silent fail — history is best-effort + } +} + +// Mode-transition log (#601). Whenever the active-mode flag actually changes, +// append {ts, mode, prev} to $CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl so +// caveman-stats can attribute output tokens to the mode that was active when +// each message was generated, instead of whatever mode the flag holds at +// stats time. mode/prev are a VALID_MODES string or null (null = caveman off). +// prev lets stats attribute messages that predate the first logged transition +// of a session. No-op when the mode is unchanged; best-effort like all flag IO. +const MODE_LOG_BASENAME = '.caveman-mode-log.jsonl'; + +function recordModeChange(claudeDir, newMode) { + try { + const current = readFlag(path.join(claudeDir, '.caveman-active')); + const next = newMode || null; + if ((current || null) === next) return; + appendFlag( + path.join(claudeDir, MODE_LOG_BASENAME), + JSON.stringify({ ts: Date.now(), mode: next, prev: current || null }) + ); + } catch (e) { + // Silent fail — the log is best-effort + } +} + +// Symlink-safe history read. Returns lines (untrimmed) or empty array on any +// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap +// the way readFlag does — history is expected to grow with use. +function readHistory(filePath) { + try { + const st = fs.lstatSync(filePath); + if (st.isSymbolicLink() || !st.isFile()) return []; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let raw; + try { + fd = fs.openSync(filePath, flags); + raw = fs.readFileSync(fd, 'utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + return raw.split('\n').filter(line => line.trim()); + } catch (e) { + return []; + } +} + +module.exports = { getDefaultMode, getConfigDir, getConfigPath, findRepoConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory, recordModeChange, MODE_LOG_BASENAME }; diff --git a/src/hooks/caveman-mode-tracker.js b/src/hooks/caveman-mode-tracker.js new file mode 100644 index 0000000..9f8bbb1 --- /dev/null +++ b/src/hooks/caveman-mode-tracker.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// caveman — UserPromptSubmit hook to track which caveman mode is active +// Inspects user input for /caveman commands and writes mode to flag file + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); +const { getDefaultMode, safeWriteFlag, readFlag, recordModeChange, VALID_MODES } = require('./caveman-config'); + +// Modes handled by their own slash commands (/caveman-commit, etc.) — not +// selectable via /caveman . +const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + +const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +const flagPath = path.join(claudeDir, '.caveman-active'); +// Remembers the prose mode active before a one-shot independent mode +// (/caveman-commit etc.) so the next ordinary prompt can restore it (#599). +const prevPath = path.join(claudeDir, '.caveman-active.prev'); + +let input = ''; +process.stdin.on('data', chunk => { input += chunk; }); +// Abnormal stdin close (broken pipe, parent crash) emits 'error'; without a +// listener Node throws it as an uncaught exception and the hook exits +// non-zero — a spurious hook failure (#538). Hooks must always exit 0. +process.stdin.on('error', () => process.exit(0)); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + // Collapse whitespace so phrase triggers still match multiline prompts — + // every regex below sees a single-line prompt (#598). + const prompt = (data.prompt || '').trim().toLowerCase().replace(/\s+/g, ' '); + + // Deactivation intent — computed FIRST so "turn caveman mode off" never + // falls through to the activation patterns (#598: the old contiguous + // "turn off" phrasing missed the "turn X off" word order entirely, and + // the activation regex then re-armed caveman at the default level). + const wantsOff = + /\b(stop|disable|deactivate|quit|exit|kill)\s+(the\s+)?caveman\b/.test(prompt) || + /\bcaveman(\s+mode)?\s+(off|stop|disabled?)\b/.test(prompt) || + /\bturn\s+off\s+(the\s+)?caveman\b/.test(prompt) || + // "normal mode" only as a command (prompt-initial, optionally led by a + // switch-back verb) or with caveman context — never mid-sentence for + // e.g. vim's normal mode ("how do I exit vim normal mode"). + /^(please\s+)?(go\s+|back\s+to\s+|switch\s+(back\s+)?to\s+|return\s+to\s+)?normal\s+mode\b/.test(prompt) || + (/\bnormal\s+mode\b/.test(prompt) && /\bcaveman\b/.test(prompt)); + + // Questions about caveman are not activation commands + // ("what is caveman mode?", "does caveman lite drop articles?"). + const isQuestion = + /^(what|whats|what's|how|why|when|where|who|does|do|did|is|are|can|could|would|should|tell me|explain)\b/.test(prompt); + + // Natural language activation (e.g. "activate caveman", "turn on caveman + // mode", "talk like caveman"). README tells users they can say these. + // Also brevity requests ("less tokens", "be brief/terse", "fewer tokens", + // "shorter answers") — but not when scoped to a single section + // ("be brief in the summary"), which is a one-off instruction, not a + // session-wide mode switch. + if (!wantsOff && !isQuestion) { + if (/\b(activate|enable|start|turn on|use|switch to|want|give me)\b[^.]{0,40}\bcaveman\b/.test(prompt) || + /\btalk like\b[^.]{0,40}\bcaveman\b/.test(prompt) || + /\bcaveman\s+mode\s+(on|please|now)\b/.test(prompt) || + /^caveman(\s+mode)?\s*[.!]*$/.test(prompt) || + /\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b(?!\s+(in|for|on|about|when|during|with)\b)/.test(prompt)) { + const mode = getDefaultMode(); + if (mode !== 'off') { + recordModeChange(claudeDir, mode); // #601: timestamped transition log + safeWriteFlag(flagPath, mode); + } + } + } + + // /caveman-stats [--share] — block the prompt and inject stats output as + // the hook's reason. The script reads the active session log, so we pass + // transcript_path through when Claude Code provides it. + const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt); + if (statsMatch) { + const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean); + try { + const statsPath = path.join(__dirname, 'caveman-stats.js'); + const argv = [statsPath]; + if (data.transcript_path) argv.push('--session-file', data.transcript_path); + if (tailArgs.includes('--share')) argv.push('--share'); + if (tailArgs.includes('--all')) argv.push('--all'); + const sinceIdx = tailArgs.indexOf('--since'); + if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) { + argv.push('--since', tailArgs[sinceIdx + 1]); + } + const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 }); + process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() })); + } catch (e) { + process.stdout.write(JSON.stringify({ + decision: 'block', + reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js' + })); + } + return; + } + + // Match /caveman commands. Independent one-shot modes remember the prose + // mode active before them so the next ordinary prompt restores it (#599) + // — SKILL.md promises "Level persist until changed or session end", and a + // one-shot skill invocation should not count as "changed" forever. + let setIndependentThisTurn = false; + if (prompt.startsWith('/caveman')) { + const parts = prompt.split(/\s+/); + const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. + const arg = parts[1] || ''; + + let mode = null; + + // Marketplace plugin installs surface commands namespaced as + // /caveman:caveman- — accept both forms for every skill (#599: + // only compress and stats had the namespaced variant). + if (cmd === '/caveman-commit' || cmd === '/caveman:caveman-commit') { + mode = 'commit'; + } else if (cmd === '/caveman-review' || cmd === '/caveman:caveman-review') { + mode = 'review'; + } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { + mode = 'compress'; + } else if (cmd === '/caveman' || cmd === '/caveman:caveman') { + // Bare /caveman → activate at configured default + if (!arg) { + mode = getDefaultMode(); + } else if (arg === 'off' || arg === 'stop' || arg === 'disable') { + mode = 'off'; + } else if (arg === 'wenyan-full') { + // Canonical alias — config stores as 'wenyan' + mode = 'wenyan'; + } else if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) { + mode = arg; + } + // Unknown arg → mode stays null, flag untouched (no silent overwrite) + } + + if (mode && mode !== 'off') { + if (INDEPENDENT_MODES.has(mode)) { + // Save the prose mode being displaced — but never overwrite an + // already-saved one with another independent mode (/caveman-commit + // followed by /caveman-review must still restore the original). + const current = readFlag(flagPath); + if (current && !INDEPENDENT_MODES.has(current)) { + safeWriteFlag(prevPath, current); + } + setIndependentThisTurn = true; + } + recordModeChange(claudeDir, mode); // #601 + safeWriteFlag(flagPath, mode); + } else if (mode === 'off') { + recordModeChange(claudeDir, null); // #601 + try { fs.unlinkSync(flagPath); } catch (e) {} + try { fs.unlinkSync(prevPath); } catch (e) {} + } + } + + // Apply deactivation detected above + if (wantsOff) { + recordModeChange(claudeDir, null); // #601 + try { fs.unlinkSync(flagPath); } catch (e) {} + try { fs.unlinkSync(prevPath); } catch (e) {} + } + + // Per-turn reinforcement: emit a structured reminder when caveman is active. + // The SessionStart hook injects the full ruleset once, but models lose it + // when other plugins inject competing style instructions every turn. + // This keeps caveman visible in the model's attention on every user message. + // + // Skip independent modes (commit, review, compress) — they have their own + // skill behavior and the base caveman rules would conflict. + // readFlag enforces symlink-safe read + size cap + VALID_MODES whitelist. + // If the flag is missing, corrupted, oversized, or a symlink pointing at + // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing + // — never inject untrusted bytes into model context. + let activeMode = readFlag(flagPath); + + // One-shot restore (#599): an independent mode set on a PREVIOUS prompt + // has served its turn — bring back the prose mode that was active before + // it, or deactivate if caveman wasn't active then. + if (activeMode && INDEPENDENT_MODES.has(activeMode) && !setIndependentThisTurn) { + const prev = readFlag(prevPath); + try { fs.unlinkSync(prevPath); } catch (e) {} + if (prev && !INDEPENDENT_MODES.has(prev)) { + recordModeChange(claudeDir, prev); // #601 + safeWriteFlag(flagPath, prev); + activeMode = prev; + } else { + recordModeChange(claudeDir, null); // #601 + try { fs.unlinkSync(flagPath); } catch (e) {} + activeMode = null; + } + } + + if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " + + "Drop articles/filler/pleasantries/hedging. Fragments OK. " + + "Code/commits/security: write normal." + } + })); + } + } catch (e) { + // Silent fail + } +}); diff --git a/src/hooks/caveman-stats.js b/src/hooks/caveman-stats.js new file mode 100644 index 0000000..376f91e --- /dev/null +++ b/src/hooks/caveman-stats.js @@ -0,0 +1,533 @@ +#!/usr/bin/env node +// caveman-stats — read the active Claude Code session log, print real token +// usage plus an estimated savings figure from the benchmark in benchmarks/. +// +// Run directly: node hooks/caveman-stats.js +// Inside Claude: /caveman-stats triggers this via the UserPromptSubmit hook. +// Hook integration passes --session-file so we always read +// the active session, not whichever JSONL was modified most recently. + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config'); + +// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across +// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra / +// wenyan modes show no estimate until benchmarked. Add an entry here when a new +// run is committed. +const COMPRESSION = { 'full': 0.65 }; + +// Approximate Anthropic public output-token pricing, USD per million. +// Match by model id prefix so this stays correct across point releases +// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from +// https://www.anthropic.com/pricing if a release changes the tier. +// Most-specific prefixes MUST come first — priceForModel returns the first match. +const MODEL_OUTPUT_PRICE_PER_M = [ + // Legacy Opus 4.0 / 4.1 (pre-4.5) billed at the old $75/M output tier, + // including the dated ids (e.g. claude-opus-4-20250514). + ['claude-opus-4-0', 75.00], + ['claude-opus-4-1', 75.00], + ['claude-opus-4-2025', 75.00], + // Opus 4.5–4.8 dropped to $25/M output (rate card held since 4.5). + ['claude-opus-4', 25.00], + ['claude-sonnet-4', 15.00], + ['claude-haiku-4', 5.00], // Haiku 4.5 = $5/M output + ['claude-3-5-sonnet', 15.00], + ['claude-3-5-haiku', 4.00], + ['claude-3-opus', 75.00], +]; + +function priceForModel(model) { + if (!model) return null; + for (const [prefix, price] of MODEL_OUTPUT_PRICE_PER_M) { + if (model.startsWith(prefix)) return price; + } + return null; +} + +function formatUsd(amount) { + if (amount >= 1) return `$${amount.toFixed(2)}`; + if (amount >= 0.01) return `$${amount.toFixed(3)}`; + return `$${amount.toFixed(4)}`; +} + +function findRecentSession(claudeDir) { + const projectsDir = path.join(claudeDir, 'projects'); + let entries; + try { entries = fs.readdirSync(projectsDir, { withFileTypes: true }); } + catch { return null; } + + let best = null; + const stack = entries.map(e => path.join(projectsDir, e.name)); + while (stack.length) { + const p = stack.pop(); + let st; + try { st = fs.statSync(p); } catch { continue; } + if (st.isDirectory()) { + try { + for (const child of fs.readdirSync(p)) stack.push(path.join(p, child)); + } catch {} + } else if (p.endsWith('.jsonl') && (!best || st.mtimeMs > best.mtime)) { + best = { file: p, mtime: st.mtimeMs }; + } + } + return best ? best.file : null; +} + +function parseSession(filePath) { + let raw; + try { raw = fs.readFileSync(filePath, 'utf8'); } + catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null, messages: [] }; } + + let outputTokens = 0; + let cacheReadTokens = 0; + let turns = 0; + let model = null; + const messages = []; // per-message {ts, outputTokens} for mode attribution (#601) + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type !== 'assistant' || !entry.message) continue; + const usage = entry.message.usage; + if (!usage) continue; + outputTokens += usage.output_tokens || 0; + cacheReadTokens += usage.cache_read_input_tokens || 0; + turns++; + if (!model && entry.message.model) model = entry.message.model; + const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN; + messages.push({ + ts: Number.isFinite(ts) ? ts : null, + outputTokens: usage.output_tokens || 0, + }); + } + return { outputTokens, cacheReadTokens, turns, model, messages }; +} + +// Detect *.original.md / *.md pairs left behind by caveman-compress. The +// presence of a *.original.md backup means the *.md sibling is a compressed +// memory file — every session start reads the compressed version, so the +// delta is per-session input-token savings (passive). Returns a summary or +// null if nothing was found in the given dirs. +function findCompressedPairs(dirs) { + const pairs = []; + for (const dir of dirs) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.original.md')) continue; + const base = entry.name.slice(0, -'.original.md'.length); + const originalPath = path.join(dir, entry.name); + const compressedPath = path.join(dir, `${base}.md`); + let oSize, cSize; + try { + oSize = fs.statSync(originalPath).size; + cSize = fs.statSync(compressedPath).size; + } catch { continue; } + if (oSize <= cSize) continue; + pairs.push({ name: base, dir, originalSize: oSize, compressedSize: cSize }); + } + } + return pairs; +} + +function summarizeCompressed(pairs) { + if (!pairs || pairs.length === 0) return null; + const totalOriginal = pairs.reduce((s, p) => s + p.originalSize, 0); + const totalCompressed = pairs.reduce((s, p) => s + p.compressedSize, 0); + const bytesSaved = totalOriginal - totalCompressed; + // English prose runs ~4 chars per token. Label result as approximate so we + // don't make claims tighter than the method warrants. + const tokensSaved = Math.round(bytesSaved / 4); + return { count: pairs.length, bytesSaved, tokensSaved }; +} + +// ── Per-mode attribution (#601) ───────────────────────────────────────────── +// The whole session's tokens must never be credited to whatever mode the flag +// happens to hold at stats time — a mid-session mode change would inflate the +// estimate (verbose tokens counted as compressed) or zero it (caveman tokens +// counted as uncompressed). The mode tracker + SessionStart hook append +// {ts, mode, prev} rows to .caveman-mode-log.jsonl on every actual transition; +// stats joins those timestamps against the session JSONL message timestamps. + +// Read + validate the transition log. Returns rows sorted by ts. +function readModeLog(logPath) { + const rows = []; + for (const line of readHistory(logPath)) { + let e; + try { e = JSON.parse(line); } catch { continue; } + if (!e || typeof e !== 'object' || !Number.isFinite(e.ts)) continue; + const norm = (v) => (v == null ? null : (VALID_MODES.includes(String(v)) ? String(v) : undefined)); + const mode = norm(e.mode); + const prev = norm(e.prev); + if (mode === undefined || prev === undefined) continue; // reject non-whitelisted values + rows.push({ ts: e.ts, mode, prev }); + } + rows.sort((a, b) => a.ts - b.ts); + return rows; +} + +// Attribute each message's output tokens to the mode active when it was +// generated. Sources, most to least exact: +// 'log' — the transition log covers the message (rows at/before its +// ts, or the first row's `prev` for the pre-inception span). +// 'flag-mtime' — no log rows, but the flag was written mid-session: tokens +// from the write onward belong to the current mode; earlier +// tokens have UNKNOWN mode and are excluded, never guessed +// (no-fake-savings). Messages without timestamps are also +// unknown in this case. +// 'whole-session' — no log and no evidence of a mid-session change: the +// current mode covers the whole session (correct when the +// mode never changed; pre-#601 behavior). +// Returns { byMode: {modeKey: tokens}, unknownTokens, basis } where modeKey is +// a mode string or 'none' (caveman inactive). +function attributeByMode({ messages, modeLog, mode, flagMtimeMs, outputTokens }) { + const currentKey = mode || 'none'; + const msgs = messages || []; + let firstTs = null; + for (const m of msgs) { + if (m.ts != null && (firstTs === null || m.ts < firstTs)) firstTs = m.ts; + } + + let events = modeLog || []; + let basis = 'log'; + let prefixMode; // mode for messages before the first event (undefined = unknown) + if (events.length === 0) { + if (flagMtimeMs != null && firstTs != null && flagMtimeMs > firstTs) { + // Flag written mid-session with no transition log: only the span from + // the write onward is attributable. The write may have been a + // reaffirmation of the same mode, but assuming so would guess savings + // into existence — exclude the prefix instead. + events = [{ ts: flagMtimeMs, mode: mode || null }]; + basis = 'flag-mtime'; + prefixMode = undefined; + } else { + return { byMode: { [currentKey]: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' }; + } + } else { + // Every transition since log inception is recorded, so the span before + // the first row ran under that row's `prev` mode. + prefixMode = events[0].prev; + } + + const byMode = {}; + let unknownTokens = 0; + const add = (key, tokens) => { byMode[key] = (byMode[key] || 0) + tokens; }; + for (const m of msgs) { + if (m.ts == null) { unknownTokens += m.outputTokens; continue; } + let active; + for (const ev of events) { + if (ev.ts <= m.ts) active = ev; + else break; + } + if (active !== undefined) add(active.mode || 'none', m.outputTokens); + else if (prefixMode !== undefined) add(prefixMode || 'none', m.outputTokens); + else unknownTokens += m.outputTokens; + } + return { byMode, unknownTokens, basis }; +} + +// Attribution shape for callers without a session log to join against +// (kept for formatStats/formatShare backward compatibility in tests). +function wholeSessionAttribution(mode, outputTokens) { + return { byMode: { [mode || 'none']: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' }; +} + +// Compute the savings figures we want to log/share for one session snapshot. +// Sums per-mode: only spans whose mode has benchmark data earn an estimate; +// unknown spans earn nothing. +function deriveSavings({ byMode, model }) { + let estSavedTokens = 0; + for (const [key, tokens] of Object.entries(byMode || {})) { + const ratio = COMPRESSION[key]; + if (ratio == null || tokens <= 0) continue; + estSavedTokens += Math.round(tokens / (1 - ratio)) - tokens; + } + const price = priceForModel(model); + const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0; + return { estSavedTokens, estSavedUsd }; +} + +// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input. +function parseDuration(spec) { + if (!spec) return null; + const m = /^(\d+)([dh])$/.exec(spec.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + return m[2] === 'd' ? n * 86_400_000 : n * 3_600_000; +} + +// Aggregate history into latest-per-session totals, optionally filtered to a +// time window. Returns { sessions, outputTokens, estSavedTokens, estSavedUsd }. +function aggregateHistory(historyPath, sinceMs) { + const lines = readHistory(historyPath); + const cutoff = sinceMs ? Date.now() - sinceMs : null; + const latestPerSession = new Map(); + for (const line of lines) { + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (!entry || typeof entry !== 'object') continue; + if (cutoff !== null && (entry.ts || 0) < cutoff) continue; + const id = entry.session_id || '_'; + const prev = latestPerSession.get(id); + if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry); + } + let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0; + for (const e of latestPerSession.values()) { + outputTokens += e.output_tokens || 0; + estSavedTokens += e.est_saved_tokens || 0; + estSavedUsd += e.est_saved_usd || 0; + } + return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd }; +} + +// Output-reduction share: saved / (saved + used) = the fraction of the +// would-be OUTPUT tokens that caveman avoided. That is the only ratio we can +// honestly compute from output counts alone. It is NOT a share of session or +// limit usage — input + cache tokens dominate agentic sessions, count against +// Pro/Max limits, and are not reduced by caveman, so real limit relief is far +// smaller (docs/HONEST-NUMBERS.md: session-level totals land ~14–21%, below +// zero on terse workloads). Never label this "usage" or "budget". Returns a +// rounded percent, or null when there is nothing measured to divide. +function outputReductionPct(savedTokens, usedTokens) { + if (!Number.isFinite(savedTokens) || !Number.isFinite(usedTokens)) return null; + if (savedTokens <= 0 || usedTokens < 0) return null; + const total = savedTokens + usedTokens; + if (total <= 0) return null; + return Math.round((savedTokens / total) * 100); +} + +function humanizeTokens(n) { + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(Math.round(n)); +} + +function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) { + const sep = '──────────────────────────────────'; + const window = since ? ` (last ${since})` : ''; + if (sessions === 0) { + return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`; + } + const usdLine = estSavedUsd > 0 ? `Est. saved (USD): ~${formatUsd(estSavedUsd)}\n` : ''; + const pct = outputReductionPct(estSavedTokens, outputTokens); + const budgetLine = pct !== null + ? `Est. output reduction: ~${pct}% (output tokens only, est.)\n` + : ''; + return `\nCaveman Stats — Lifetime${window}\n${sep}\n` + + `Sessions: ${sessions.toLocaleString()}\n${sep}\n` + + `Output tokens: ${outputTokens.toLocaleString()}\n` + + `Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` + + budgetLine + usdLine + sep + '\n'; +} + +// Single-line tweetable summary. Stays human-friendly when no ratio is known. +// Savings come from per-mode attribution (#601) so a mid-session mode change +// never inflates the shared number. +function formatShare({ outputTokens, turns, mode, model, attribution }) { + if (turns === 0) { + return '🪨 caveman armed but no turns yet — caveman.sh'; + } + const attr = attribution || wholeSessionAttribution(mode, outputTokens); + const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model }); + + if (estSavedTokens > 0) { + const usd = estSavedUsd > 0 ? ` (~${formatUsd(estSavedUsd)})` : ''; + return `🪨 Saved ${estSavedTokens.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`; + } + return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`; +} + +// Pure formatter — separated from main() so tests can pass synthetic inputs. +// `attribution` (from attributeByMode, #601) splits output tokens per mode; +// when omitted, the current mode is assumed for the whole session. +function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed, attribution }) { + const sep = '──────────────────────────────────'; + const shortPath = sessionPath && sessionPath.length > 45 + ? '...' + sessionPath.slice(-45) + : (sessionPath || ''); + + if (turns === 0) { + return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`; + } + + const attr = attribution || wholeSessionAttribution(mode, outputTokens); + const activeKeys = Object.keys(attr.byMode).filter(k => attr.byMode[k] > 0); + // Uniform = every token ran under the CURRENT mode. Anything else — a + // second mode, tokens under a mode the flag no longer shows, or spans we + // could not attribute — gets the per-mode breakdown below. + const uniform = attr.unknownTokens === 0 && + (activeKeys.length === 0 || (activeKeys.length === 1 && activeKeys[0] === (mode || 'none'))); + + const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null; + const price = priceForModel(model); + + let savings; + let footer = ''; + if (!uniform) { + const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model }); + const lines = [attr.basis === 'flag-mtime' + ? 'Mode was set mid-session — only output after the change is attributed:' + : 'Mode changed mid-session — output attributed per mode:']; + for (const key of activeKeys) { + const tokens = attr.byMode[key]; + const r = COMPRESSION[key]; + const label = key === 'none' ? 'caveman off' : key; + const note = r != null + ? `est. ${(Math.round(tokens / (1 - r)) - tokens).toLocaleString()} saved` + : 'no benchmark estimate'; + lines.push(` ${label}: ${tokens.toLocaleString()} tokens (${note})`); + } + if (attr.unknownTokens > 0) { + lines.push(` unattributed: ${attr.unknownTokens.toLocaleString()} tokens (mode unknown — excluded from estimate)`); + } + lines.push(`Est. tokens saved: ${estSavedTokens.toLocaleString()}`); + if (estSavedUsd > 0) lines.push(`Est. saved (USD): ~${formatUsd(estSavedUsd)}`); + savings = lines.join('\n'); + + footer = 'Savings est. from benchmarks/ (mean per-task), applied only to spans whose mode is known.'; + if (estSavedUsd > 0) footer += ` Pricing for ${model}.`; + if (attr.basis === 'flag-mtime') { + footer += ' Tokens before the mode change could not be attributed and are excluded rather than guessed.'; + } else if (attr.unknownTokens > 0) { + footer += ' Unattributed tokens are excluded rather than guessed.'; + } + footer += ' Reduction is of output tokens only; input/cache usage is unchanged.'; + } else if (ratio !== null) { + const estNormal = Math.round(outputTokens / (1 - ratio)); + const estSaved = estNormal - outputTokens; + let usdLine = ''; + if (price !== null) { + const usd = (estSaved / 1_000_000) * price; + usdLine = `Est. saved (USD): ~${formatUsd(usd)}\n`; + footer = `Savings est. from benchmarks/ (mean per-task). Pricing for ${model}. Actual varies by task.`; + } else { + footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.'; + } + // No "% of your usage/budget" line here on purpose: from output tokens + // alone the only computable ratio is the output reduction already shown + // on the line above, and input + cache tokens (which dominate agentic + // sessions and count against Pro/Max limits) are untouched by caveman — + // any session-usage % would overstate real limit relief. See + // docs/HONEST-NUMBERS.md. + footer += ' Reduction is of output tokens only; input/cache usage is unchanged.'; + savings = (`Est. without caveman: ${estNormal.toLocaleString()}\n` + + `Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}% of output)\n` + + usdLine).replace(/\n$/, ''); + } else if (mode && mode !== 'off') { + savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`; + } else { + savings = 'Caveman not active this session.'; + } + + let memoryLine = ''; + if (compressed && compressed.count > 0) { + const tokensApprox = compressed.tokensSaved.toLocaleString(); + memoryLine = `${sep}\nMemory compressed: ${compressed.count} file${compressed.count === 1 ? '' : 's'}, ` + + `~${tokensApprox} tokens saved per session start (approx)\n`; + } + + return `\nCaveman Stats\n${sep}\n` + + (shortPath ? `Session: ${shortPath}\n` : '') + + `Turns: ${turns}\n${sep}\n` + + `Output tokens: ${outputTokens.toLocaleString()}\n` + + `Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` + + `${savings}\n` + + memoryLine + + (footer ? footer + '\n' : ''); +} + +function main() { + const args = process.argv.slice(2); + const i = args.indexOf('--session-file'); + const sessionFileArg = i !== -1 ? args[i + 1] : null; + const share = args.includes('--share'); + const all = args.includes('--all'); + const sinceIdx = args.indexOf('--since'); + const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null; + + const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const historyPath = path.join(claudeDir, '.caveman-history.jsonl'); + + // Lifetime aggregation paths short-circuit before we need a live session. + if (all || sinceArg) { + const sinceMs = parseDuration(sinceArg); + if (sinceArg && sinceMs === null) { + process.stderr.write(`caveman-stats: --since takes Nh or Nd (e.g. 7d, 24h), got: ${sinceArg}\n`); + process.exit(2); + } + const agg = aggregateHistory(historyPath, sinceMs); + process.stdout.write(formatHistory({ ...agg, since: sinceArg || null })); + return; + } + + const sessionFile = sessionFileArg || findRecentSession(claudeDir); + + if (!sessionFile) { + process.stderr.write('caveman-stats: no Claude Code session found.\n'); + process.exit(1); + } + + const parsed = parseSession(sessionFile); + const flagPath = path.join(claudeDir, '.caveman-active'); + const mode = readFlag(flagPath); + + // #601: attribute tokens to the mode active when each message happened, + // via the transition log the hooks maintain (fallbacks documented on + // attributeByMode). Never credit the whole session to the current flag. + let flagMtimeMs = null; + try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {} + const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME)); + const attribution = attributeByMode({ + messages: parsed.messages, + modeLog, + mode, + flagMtimeMs, + outputTokens: parsed.outputTokens, + }); + + // Append a snapshot of this session's totals to the lifetime log. Multiple + // /caveman-stats calls in one session emit multiple lines for the same + // session_id; aggregateHistory keeps only the latest per session_id. + if (parsed.turns > 0) { + const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model }); + const sessionId = path.basename(sessionFile, '.jsonl'); + appendFlag(historyPath, JSON.stringify({ + ts: Date.now(), + session_id: sessionId, + mode: mode || null, + model: parsed.model || null, + output_tokens: parsed.outputTokens, + est_saved_tokens: estSavedTokens, + est_saved_usd: estSavedUsd, + })); + + // Statusline suffix: tiny pre-rendered string the shell statusline can + // cat without parsing JSONL. Updated on every /caveman-stats run. + // Routed through safeWriteFlag — the suffix path is predictable and + // user-owned, same symlink-clobber surface as the .caveman-active flag. + const agg = aggregateHistory(historyPath, null); + const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : ''; + safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix); + } + + if (share) { + process.stdout.write(formatShare({ ...parsed, mode, attribution }) + '\n'); + } else { + const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i); + const compressed = summarizeCompressed(findCompressedPairs(scanDirs)); + process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed, attribution })); + } +} + +if (require.main === module) main(); + +module.exports = { + formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings, + parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M, + findCompressedPairs, summarizeCompressed, humanizeTokens, outputReductionPct, + readModeLog, attributeByMode, +}; diff --git a/src/hooks/caveman-statusline.ps1 b/src/hooks/caveman-statusline.ps1 new file mode 100644 index 0000000..8eaf110 --- /dev/null +++ b/src/hooks/caveman-statusline.ps1 @@ -0,0 +1,62 @@ +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" } +$Flag = Join-Path $ClaudeDir ".caveman-active" +if (-not (Test-Path $Flag)) { exit 0 } + +# Refuse reparse points (symlinks / junctions) and oversized files. Without +# this, a local attacker could point the flag at a secret file and have the +# statusline render its bytes (including ANSI escape sequences) to the terminal +# every keystroke. +try { + $Item = Get-Item -LiteralPath $Flag -Force -ErrorAction Stop + if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { exit 0 } + if ($Item.Length -gt 64) { exit 0 } +} catch { + exit 0 +} + +$Mode = "" +try { + $Raw = Get-Content -LiteralPath $Flag -TotalCount 1 -ErrorAction Stop + if ($null -ne $Raw) { $Mode = ([string]$Raw).Trim() } +} catch { + exit 0 +} + +# Strip anything outside [a-z0-9-] — blocks terminal-escape and OSC hyperlink +# injection via the flag contents. Then whitelist-validate. +$Mode = $Mode.ToLowerInvariant() +$Mode = ($Mode -replace '[^a-z0-9-]', '') + +$Valid = @('off','lite','full','ultra','wenyan-lite','wenyan','wenyan-full','wenyan-ultra','commit','review','compress') +if (-not ($Valid -contains $Mode)) { exit 0 } + +$Esc = [char]27 +if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") { + [Console]::Write("${Esc}[38;5;172m[CAVEMAN]${Esc}[0m") +} else { + $Suffix = $Mode.ToUpperInvariant() + [Console]::Write("${Esc}[38;5;172m[CAVEMAN:$Suffix]${Esc}[0m") +} + +# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0. +# Reads a pre-rendered string written by caveman-stats.js. Refuses reparse +# points and strips control bytes (matches statusline.sh hardening). Until +# /caveman-stats has run at least once, the suffix file is absent and nothing +# is rendered — safe default for fresh installs. +if ($env:CAVEMAN_STATUSLINE_SAVINGS -ne "0") { + $SavingsFile = Join-Path $ClaudeDir ".caveman-statusline-suffix" + if (Test-Path $SavingsFile) { + try { + $SavingsItem = Get-Item -LiteralPath $SavingsFile -Force -ErrorAction Stop + if (-not ($SavingsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and + $SavingsItem.Length -le 64) { + $Savings = (Get-Content -LiteralPath $SavingsFile -Encoding UTF8 -Raw -ErrorAction Stop).TrimEnd() + $Savings = ($Savings -replace '[\x00-\x1F]', '') + if ($Savings.Length -gt 0) { + [Console]::Write(" ${Esc}[38;5;172m$Savings${Esc}[0m") + } + } + } catch {} + } +} diff --git a/src/hooks/caveman-statusline.sh b/src/hooks/caveman-statusline.sh new file mode 100755 index 0000000..94f40c0 --- /dev/null +++ b/src/hooks/caveman-statusline.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# caveman — statusline badge script for Claude Code +# Reads the caveman mode flag file and outputs a colored badge. +# +# Usage in ~/.claude/settings.json: +# "statusLine": { "type": "command", "command": "bash /path/to/caveman-statusline.sh" } +# +# Plugin users: Claude will offer to set this up on first session. +# Standalone users: install.sh wires this automatically. + +FLAG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active" + +# Refuse symlinks — a local attacker could point the flag at ~/.ssh/id_rsa and +# have the statusline render its bytes (including ANSI escape sequences) to +# the terminal every keystroke. +[ -L "$FLAG" ] && exit 0 +[ ! -f "$FLAG" ] && exit 0 + +# Hard-cap the read at 64 bytes and strip anything outside [a-z0-9-] — blocks +# terminal-escape injection and OSC hyperlink spoofing via the flag contents. +MODE=$(head -c 64 "$FLAG" 2>/dev/null | tr -d '\n\r' | tr '[:upper:]' '[:lower:]') +MODE=$(printf '%s' "$MODE" | tr -cd 'a-z0-9-') + +# Whitelist. Anything else → render nothing rather than echo attacker bytes. +case "$MODE" in + off|lite|full|ultra|wenyan-lite|wenyan|wenyan-full|wenyan-ultra|commit|review|compress) ;; + *) exit 0 ;; +esac + +if [ -z "$MODE" ] || [ "$MODE" = "full" ]; then + printf '\033[38;5;172m[CAVEMAN]\033[0m' +else + SUFFIX=$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]') + printf '\033[38;5;172m[CAVEMAN:%s]\033[0m' "$SUFFIX" +fi + +# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0. +# Reads a pre-rendered string written by caveman-stats.js so we don't shell out +# to node on every keystroke. Refuses symlinks and strips control bytes — +# same hardening as the flag file (a local attacker could plant a file with +# ANSI escape codes otherwise). Until /caveman-stats has run at least once, +# the suffix file is absent and nothing is rendered — so the default is safe +# for fresh installs (no fake number, no crash). +if [ "${CAVEMAN_STATUSLINE_SAVINGS:-1}" != "0" ]; then + SAVINGS_FILE="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-statusline-suffix" + if [ -f "$SAVINGS_FILE" ] && [ ! -L "$SAVINGS_FILE" ]; then + SAVINGS=$(head -c 64 "$SAVINGS_FILE" 2>/dev/null | tr -d '\000-\037') + [ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS" + fi +fi diff --git a/src/hooks/checksums.sha256 b/src/hooks/checksums.sha256 new file mode 100644 index 0000000..f40380a --- /dev/null +++ b/src/hooks/checksums.sha256 @@ -0,0 +1,8 @@ +8005a3491db7d92f36ac66369861589f9c47123d3a7c71e643fc2c06168cd45a package.json +f47fe2e6440578eeb20408bc3131b63fb8e1ae4f6ea26dbdcca33e7901e5ee8c caveman-config.js +bc0af3ba327657630e5f6c60be619e6b5394bf03edc29b1384a15f09a1b348bf caveman-activate.js +3e34f004d4a9e65609f8c95b47dfe5f0e3a6f2d16b88c7f8af730187ee2fb246 caveman-mode-tracker.js +433a345a279c2d54be51864ef77c0a7b19d0f8604b9a441ce0a1eded31f113e8 caveman-stats.js +d2deff457d0a5d8e1848193e6af6a68a0ebdba4fbdf250889400d5ea231e088f caveman-statusline.sh +1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1 +9f2601e8551653609f0b9d700c9bd75bffa43747d2db18ffe83fb73de5dc3607 cavecrew-model-overrides.js diff --git a/src/hooks/install.ps1 b/src/hooks/install.ps1 new file mode 100644 index 0000000..1a9ae1d --- /dev/null +++ b/src/hooks/install.ps1 @@ -0,0 +1,198 @@ +# caveman — one-command hook installer for Claude Code (Windows PowerShell) +# Installs: SessionStart hook (auto-load rules) + UserPromptSubmit hook (mode tracking) +# Usage: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1 +# or: powershell -ExecutionPolicy Bypass -File src\hooks\install.ps1 -Force +# or (remote, no -Force support via pipe): +# irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/hooks/install.ps1 | iex +# Note: irm ... | iex cannot pass -Force. For force reinstall, save the file and run with -File. +param( + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +# Require node +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Host "ERROR: 'node' is required to install the caveman hooks (used to merge" -ForegroundColor Red + Write-Host " the hook config into settings.json safely)." -ForegroundColor Red + Write-Host " Install Node.js from https://nodejs.org and re-run this script." -ForegroundColor Red + exit 1 +} + +$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".claude" } +$HooksDir = Join-Path $ClaudeDir "hooks" +$Settings = Join-Path $ClaudeDir "settings.json" +$RepoUrl = "https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks" + +$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js") + +# Resolve source — works from repo clone or remote +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null } + +# Check if already installed (unless -Force). Older installs only had two hook +# files, so require the full current set plus the hook registrations before we +# short-circuit. +if (-not $Force) { + $AllFilesPresent = $true + foreach ($hook in $HookFiles) { + if (-not (Test-Path (Join-Path $HooksDir $hook))) { + $AllFilesPresent = $false + break + } + } + + $HooksWired = $false + $HasStatusLine = $false + if ($AllFilesPresent -and (Test-Path $Settings)) { + try { + $settingsObj = Get-Content $Settings -Raw | ConvertFrom-Json + $hasCavemanHook = { + param([string]$eventName) + if (-not $settingsObj.hooks) { return $false } + $entries = $settingsObj.hooks.$eventName + if (-not $entries) { return $false } + foreach ($entry in $entries) { + if ($entry.hooks) { + foreach ($hookDef in $entry.hooks) { + if ($hookDef.command -and $hookDef.command.Contains("caveman")) { + return $true + } + } + } + } + return $false + } + $HooksWired = (& $hasCavemanHook "SessionStart") -and (& $hasCavemanHook "UserPromptSubmit") + $HasStatusLine = $null -ne $settingsObj.statusLine + } catch { + $HooksWired = $false + $HasStatusLine = $false + } + } + + if ($AllFilesPresent -and $HooksWired -and $HasStatusLine) { + Write-Host "Caveman hooks already installed in $HooksDir" + Write-Host " Re-run with -Force to overwrite: powershell -File hooks\install.ps1 -Force" + Write-Host "" + Write-Host "Nothing to do. Hooks are already in place." + exit 0 + } +} + +if ($Force -and (Test-Path (Join-Path $HooksDir "caveman-activate.js"))) { + Write-Host "Reinstalling caveman hooks (-Force)..." +} else { + Write-Host "Installing caveman hooks..." +} + +# 1. Ensure hooks dir exists +if (-not (Test-Path $HooksDir)) { + New-Item -ItemType Directory -Path $HooksDir -Force | Out-Null +} + +# 2. Copy or download hook files +foreach ($hook in $HookFiles) { + $dest = Join-Path $HooksDir $hook + $localSource = if ($ScriptDir) { Join-Path $ScriptDir $hook } else { $null } + + if ($localSource -and (Test-Path $localSource)) { + Copy-Item $localSource $dest -Force + } else { + Invoke-WebRequest -Uri "$RepoUrl/$hook" -OutFile $dest -UseBasicParsing + } + Write-Host " Installed: $dest" +} + +# 3. Wire hooks + statusline into settings.json (idempotent) +if (-not (Test-Path $Settings)) { + Set-Content -Path $Settings -Value "{}" +} + +# Back up existing settings.json before touching it +Copy-Item $Settings "$Settings.bak" -Force + +# Use node for safe JSON merging — pass paths via env vars to avoid injection +# if the username contains a single quote (e.g., O'Brien). +# Use a single-quote here-string so PowerShell does NOT expand $variables inside. +$env:CAVEMAN_SETTINGS = $Settings -replace '\\', '/' +$env:CAVEMAN_HOOKS_DIR = $HooksDir -replace '\\', '/' + +$nodeScript = @' +const fs = require('fs'); +const settingsPath = process.env.CAVEMAN_SETTINGS; +const hooksDir = process.env.CAVEMAN_HOOKS_DIR; +const managedStatusLinePath = hooksDir + '/caveman-statusline.ps1'; +const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); +if (!settings.hooks) settings.hooks = {}; + +// SessionStart +if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; +const hasStart = settings.hooks.SessionStart.some(e => + e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman')) +); +if (!hasStart) { + settings.hooks.SessionStart.push({ + hooks: [{ + type: 'command', + command: 'node "' + hooksDir + '/caveman-activate.js"', + timeout: 5, + statusMessage: 'Loading caveman mode...' + }] + }); +} + +// UserPromptSubmit +if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = []; +const hasPrompt = settings.hooks.UserPromptSubmit.some(e => + e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman')) +); +if (!hasPrompt) { + settings.hooks.UserPromptSubmit.push({ + hooks: [{ + type: 'command', + command: 'node "' + hooksDir + '/caveman-mode-tracker.js"', + timeout: 5, + statusMessage: 'Tracking caveman mode...' + }] + }); +} + +// Statusline +if (!settings.statusLine) { + settings.statusLine = { + type: 'command', + command: 'powershell -ExecutionPolicy Bypass -File "' + managedStatusLinePath + '"' + }; + console.log(' Statusline badge configured.'); +} else { + const cmd = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (cmd.includes(managedStatusLinePath)) { + console.log(' Statusline badge already configured.'); + } else { + console.log(' NOTE: Existing statusline detected - caveman badge NOT added.'); + console.log(' See src/hooks/README.md to add the badge to your existing statusline.'); + } +} + +fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); +console.log(' Hooks wired in settings.json'); +'@ + +$tmpScript = Join-Path $env:TEMP "caveman-install-$([System.Diagnostics.Process]::GetCurrentProcess().Id).js" +try { + [System.IO.File]::WriteAllText($tmpScript, $nodeScript, [System.Text.Encoding]::UTF8) + node $tmpScript +} finally { + if (Test-Path $tmpScript) { Remove-Item $tmpScript -Force } +} + +Write-Host "" +Write-Host "Done! Restart Claude Code to activate." -ForegroundColor Green +Write-Host "" +Write-Host "What's installed:" +Write-Host " - SessionStart hook: auto-loads caveman rules every session" +Write-Host " - Mode tracker hook: updates statusline badge when you switch modes" +Write-Host " (/caveman lite, /caveman ultra, /caveman-commit, etc.)" +Write-Host " - Statusline badge: shows [CAVEMAN] or [CAVEMAN:ULTRA] etc." diff --git a/src/hooks/install.sh b/src/hooks/install.sh new file mode 100755 index 0000000..deafd8d --- /dev/null +++ b/src/hooks/install.sh @@ -0,0 +1,199 @@ +#!/bin/bash +# caveman — one-command hook installer for Claude Code +# Installs: SessionStart hook (auto-load rules) + UserPromptSubmit hook (mode tracking) +# Usage: bash src/hooks/install.sh +# or: bash <(curl -s https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/hooks/install.sh) +# or: bash src/hooks/install.sh --force (re-install over existing hooks) +set -e + +FORCE=0 +for arg in "$@"; do + case "$arg" in + --force|-f) FORCE=1 ;; + esac +done + +# Detect Windows (Git Bash / MSYS / MINGW) — not WSL (WSL reports "linux-gnu") +case "$OSTYPE" in + msys*|cygwin*|mingw*) + echo "WARNING: Running on Windows ($OSTYPE)." + echo " This script works in Git Bash/MSYS but symlinks may require" + echo " Developer Mode or admin privileges." + echo " If you installed via 'claude plugin install', you don't need this script." + echo "" + ;; +esac + +# Require node — we use it to merge the hook config into settings.json +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: 'node' is required to install the caveman hooks (used to merge" + echo " the hook config into ~/.claude/settings.json safely)." + echo " Install Node.js from https://nodejs.org and re-run this script." + exit 1 +fi + +CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +HOOKS_DIR="$CLAUDE_DIR/hooks" +SETTINGS="$CLAUDE_DIR/settings.json" +REPO_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks" + +HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js") + +# Resolve source — works from repo clone or curl pipe +SCRIPT_DIR="" +if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +fi + +# Check if already installed (unless --force). Older installs only had two hook +# files, so require the full current set plus the hook registrations before we +# short-circuit. +ALREADY_INSTALLED=0 +if [ "$FORCE" -eq 0 ]; then + ALL_FILES_PRESENT=1 + for hook in "${HOOK_FILES[@]}"; do + if [ ! -f "$HOOKS_DIR/$hook" ]; then + ALL_FILES_PRESENT=0 + break + fi + done + + HOOKS_WIRED=0 + HAS_STATUSLINE=0 + if [ "$ALL_FILES_PRESENT" -eq 1 ] && [ -f "$SETTINGS" ]; then + if CAVEMAN_SETTINGS="$SETTINGS" node -e " + const fs = require('fs'); + const settings = JSON.parse(fs.readFileSync(process.env.CAVEMAN_SETTINGS, 'utf8')); + const hasCavemanHook = (event) => + Array.isArray(settings.hooks?.[event]) && + settings.hooks[event].some(e => + e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman')) + ); + process.exit( + hasCavemanHook('SessionStart') && + hasCavemanHook('UserPromptSubmit') && + !!settings.statusLine + ? 0 + : 1 + ); + " >/dev/null 2>&1; then + HOOKS_WIRED=1 + HAS_STATUSLINE=1 + fi + fi + + if [ "$ALL_FILES_PRESENT" -eq 1 ] && [ "$HOOKS_WIRED" -eq 1 ] && [ "$HAS_STATUSLINE" -eq 1 ]; then + ALREADY_INSTALLED=1 + echo "Caveman hooks already installed in $HOOKS_DIR" + echo " Re-run with --force to overwrite: bash src/hooks/install.sh --force" + echo "" + fi +fi + +if [ "$ALREADY_INSTALLED" -eq 1 ] && [ "$FORCE" -eq 0 ]; then + echo "Nothing to do. Hooks are already in place." + exit 0 +fi + +if [ "$FORCE" -eq 1 ] && [ -f "$HOOKS_DIR/caveman-activate.js" ]; then + echo "Reinstalling caveman hooks (--force)..." +else + echo "Installing caveman hooks..." +fi + +# 1. Ensure hooks dir exists +mkdir -p "$HOOKS_DIR" + +# 2. Copy or download hook files +for hook in "${HOOK_FILES[@]}"; do + if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/$hook" ]; then + cp "$SCRIPT_DIR/$hook" "$HOOKS_DIR/$hook" + else + curl -fsSL "$REPO_URL/$hook" -o "$HOOKS_DIR/$hook" + fi + echo " Installed: $HOOKS_DIR/$hook" +done + +# Make statusline script executable +chmod +x "$HOOKS_DIR/caveman-statusline.sh" + +# 3. Wire hooks + statusline into settings.json (idempotent) +if [ ! -f "$SETTINGS" ]; then + echo '{}' > "$SETTINGS" +fi + +# Back up existing settings.json before touching it +cp "$SETTINGS" "$SETTINGS.bak" + +# Pass paths via env vars — avoids shell injection if $HOME contains single quotes +CAVEMAN_SETTINGS="$SETTINGS" CAVEMAN_HOOKS_DIR="$HOOKS_DIR" node -e " + const fs = require('fs'); + const settingsPath = process.env.CAVEMAN_SETTINGS; + const hooksDir = process.env.CAVEMAN_HOOKS_DIR; + const managedStatusLinePath = hooksDir + '/caveman-statusline.sh'; + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (!settings.hooks) settings.hooks = {}; + + // SessionStart — auto-load caveman rules + if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; + const hasStart = settings.hooks.SessionStart.some(e => + e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman')) + ); + if (!hasStart) { + settings.hooks.SessionStart.push({ + hooks: [{ + type: 'command', + command: 'node \"' + hooksDir + '/caveman-activate.js\"', + timeout: 5, + statusMessage: 'Loading caveman mode...' + }] + }); + } + + // UserPromptSubmit — track mode changes when user types /caveman commands + if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = []; + const hasPrompt = settings.hooks.UserPromptSubmit.some(e => + e.hooks && e.hooks.some(h => h.command && h.command.includes('caveman')) + ); + if (!hasPrompt) { + settings.hooks.UserPromptSubmit.push({ + hooks: [{ + type: 'command', + command: 'node \"' + hooksDir + '/caveman-mode-tracker.js\"', + timeout: 5, + statusMessage: 'Tracking caveman mode...' + }] + }); + } + + // Statusline — wire caveman badge (report if skipped) + if (!settings.statusLine) { + settings.statusLine = { + type: 'command', + command: 'bash \"' + managedStatusLinePath + '\"' + }; + console.log(' Statusline badge configured.'); + } else { + const cmd = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (cmd.includes(managedStatusLinePath)) { + console.log(' Statusline badge already configured.'); + } else { + console.log(' NOTE: Existing statusline detected — caveman badge NOT added.'); + console.log(' See src/hooks/README.md to add the badge to your existing statusline.'); + } + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + console.log(' Hooks wired in settings.json'); +" + +echo "" +echo "Done! Restart Claude Code to activate." +echo "" +echo "What's installed:" +echo " - SessionStart hook: auto-loads caveman rules every session" +echo " - Mode tracker hook: updates statusline badge when you switch modes" +echo " (/caveman lite, /caveman ultra, /caveman-commit, etc.)" +echo " - Statusline badge: shows [CAVEMAN] or [CAVEMAN:ULTRA] etc." diff --git a/src/hooks/package.json b/src/hooks/package.json new file mode 100644 index 0000000..5bbefff --- /dev/null +++ b/src/hooks/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/src/hooks/uninstall.ps1 b/src/hooks/uninstall.ps1 new file mode 100644 index 0000000..364ad4a --- /dev/null +++ b/src/hooks/uninstall.ps1 @@ -0,0 +1,135 @@ +# caveman — uninstaller for the SessionStart + UserPromptSubmit hooks (Windows PowerShell) +# Removes: hook files in ~/.claude/hooks, settings.json entries, and the flag file +# Usage: powershell -ExecutionPolicy Bypass -File src\hooks\uninstall.ps1 +# or: irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/hooks/uninstall.ps1 | iex +param() + +$ErrorActionPreference = "Stop" + +$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".claude" } +$HooksDir = Join-Path $ClaudeDir "hooks" +$Settings = Join-Path $ClaudeDir "settings.json" +$FlagFile = Join-Path $ClaudeDir ".caveman-active" + +$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js") + +# Detect if caveman is installed as a plugin +$PluginInstalled = $false +$PluginsDir = Join-Path $ClaudeDir "plugins" +if (Test-Path $PluginsDir) { + $found = Get-ChildItem -Path $PluginsDir -Recurse -Filter "plugin.json" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "caveman" } + if ($found) { $PluginInstalled = $true } +} + +if ($PluginInstalled) { + Write-Host "Caveman appears to be installed as a Claude Code plugin." -ForegroundColor Yellow + Write-Host "To uninstall the plugin, run:" + Write-Host "" + Write-Host " claude plugin disable caveman" -ForegroundColor Cyan + Write-Host "" + Write-Host "This script removes standalone hooks (installed via install.ps1)." + Write-Host "Continuing with standalone hook removal..." + Write-Host "" +} + +Write-Host "Uninstalling caveman hooks..." + +# 1. Remove hook files +$RemovedFiles = 0 +foreach ($hook in $HookFiles) { + $path = Join-Path $HooksDir $hook + if (Test-Path $path) { + Remove-Item $path -Force + Write-Host " Removed: $path" + $RemovedFiles++ + } +} + +if ($RemovedFiles -eq 0) { + Write-Host " No hook files found in $HooksDir" +} + +# 2. Remove caveman entries from settings.json (idempotent) +if (Test-Path $Settings) { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Host "WARNING: 'node' not found - cannot safely edit settings.json." -ForegroundColor Yellow + Write-Host " Remove the caveman SessionStart and UserPromptSubmit" + Write-Host " entries from $Settings manually." + } else { + # Back up before editing + Copy-Item $Settings "$Settings.bak" -Force + + # Pass path via env var — avoids injection if username contains a single quote. + # Use a single-quote here-string so PowerShell does NOT expand $variables inside. + $env:CAVEMAN_SETTINGS = $Settings -replace '\\', '/' + $env:CAVEMAN_HOOKS_DIR = $HooksDir -replace '\\', '/' + + $nodeScript = @' +const fs = require('fs'); +const settingsPath = process.env.CAVEMAN_SETTINGS; +const hooksDir = process.env.CAVEMAN_HOOKS_DIR; +const managedStatusLinePath = hooksDir + '/caveman-statusline.ps1'; +const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + +const isCavemanEntry = (entry) => + entry && entry.hooks && entry.hooks.some(h => + h.command && h.command.includes('caveman') + ); + +let removed = 0; +if (settings.hooks) { + for (const event of ['SessionStart', 'UserPromptSubmit']) { + if (Array.isArray(settings.hooks[event])) { + const before = settings.hooks[event].length; + settings.hooks[event] = settings.hooks[event].filter(e => !isCavemanEntry(e)); + removed += before - settings.hooks[event].length; + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } + } + } + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } +} + +if (settings.statusLine) { + const cmd = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (cmd.includes(managedStatusLinePath)) { + delete settings.statusLine; + console.log(' Removed caveman statusLine from settings.json'); + } +} + +fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); +console.log(' Removed ' + removed + ' caveman hook entries from settings.json'); +'@ + + node -e $nodeScript + + # Clean up backup file left by installer + if (Test-Path "$Settings.bak") { + Remove-Item "$Settings.bak" -Force + Write-Host " Removed: $Settings.bak" + } + } +} + +# 3. Remove flag file +if (Test-Path $FlagFile) { + Remove-Item $FlagFile -Force + Write-Host " Removed: $FlagFile" +} + +Write-Host "" +Write-Host "Done! Restart Claude Code to complete the uninstall." -ForegroundColor Green + +# Guidance for other agents +Write-Host "" +Write-Host "Other agents:" +Write-Host " npx skills remove caveman # Cursor, Windsurf, Cline, Copilot, etc." +Write-Host " claude plugin disable caveman # Claude Code plugin" +Write-Host " gemini extensions uninstall caveman # Gemini CLI" diff --git a/src/hooks/uninstall.sh b/src/hooks/uninstall.sh new file mode 100755 index 0000000..8e715a1 --- /dev/null +++ b/src/hooks/uninstall.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# caveman — uninstaller for the SessionStart + UserPromptSubmit hooks +# Removes: hook files in ~/.claude/hooks, settings.json entries, and the flag file +# Usage: bash src/hooks/uninstall.sh +# or: bash <(curl -s https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/hooks/uninstall.sh) +set -e + +CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +HOOKS_DIR="$CLAUDE_DIR/hooks" +SETTINGS="$CLAUDE_DIR/settings.json" +FLAG_FILE="$CLAUDE_DIR/.caveman-active" + +HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js") + +# Detect if caveman is installed as a plugin (check plugin cache) +PLUGIN_INSTALLED=0 +if [ -d "$CLAUDE_DIR/plugins" ]; then + if find "$CLAUDE_DIR/plugins" -path "*/caveman*" -name "plugin.json" -print -quit 2>/dev/null | grep -q .; then + PLUGIN_INSTALLED=1 + fi +fi + +if [ "$PLUGIN_INSTALLED" -eq 1 ]; then + echo "Caveman appears to be installed as a Claude Code plugin." + echo "To uninstall the plugin, run:" + echo "" + echo " claude plugin disable caveman" + echo "" + echo "This script removes standalone hooks (installed via install.sh)." + echo "Continuing with standalone hook removal..." + echo "" +fi + +echo "Uninstalling caveman hooks..." + +# 1. Remove hook files +REMOVED_FILES=0 +for hook in "${HOOK_FILES[@]}"; do + if [ -f "$HOOKS_DIR/$hook" ]; then + rm "$HOOKS_DIR/$hook" + echo " Removed: $HOOKS_DIR/$hook" + REMOVED_FILES=$((REMOVED_FILES + 1)) + fi +done + +if [ "$REMOVED_FILES" -eq 0 ]; then + echo " No hook files found in $HOOKS_DIR" +fi + +# 2. Remove caveman entries from settings.json (idempotent) +if [ -f "$SETTINGS" ]; then + # Require node for the same reason install.sh does — safe JSON editing + if ! command -v node >/dev/null 2>&1; then + echo "WARNING: 'node' not found — cannot safely edit settings.json." + echo " Remove the caveman SessionStart and UserPromptSubmit" + echo " entries from $SETTINGS manually." + else + # Back up before editing, same policy as install.sh + cp "$SETTINGS" "$SETTINGS.bak" + + # Pass paths via env vars — avoids shell injection if $HOME contains single quotes + CAVEMAN_SETTINGS="$SETTINGS" CAVEMAN_HOOKS_DIR="$HOOKS_DIR" node -e " + const fs = require('fs'); + const settingsPath = process.env.CAVEMAN_SETTINGS; + const hooksDir = process.env.CAVEMAN_HOOKS_DIR; + const managedStatusLinePath = hooksDir + '/caveman-statusline.sh'; + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + const isCavemanEntry = (entry) => + entry && entry.hooks && entry.hooks.some(h => + h.command && h.command.includes('caveman') + ); + + let removed = 0; + if (settings.hooks) { + for (const event of ['SessionStart', 'UserPromptSubmit']) { + if (Array.isArray(settings.hooks[event])) { + const before = settings.hooks[event].length; + settings.hooks[event] = settings.hooks[event].filter(e => !isCavemanEntry(e)); + removed += before - settings.hooks[event].length; + // Drop the event key if it's now empty (keeps settings.json tidy) + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } + } + } + // Drop settings.hooks if it's now empty + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + } + + // Remove statusLine if it references caveman + if (settings.statusLine) { + const cmd = typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); + if (cmd.includes(managedStatusLinePath)) { + delete settings.statusLine; + console.log(' Removed caveman statusLine from settings.json'); + } + } + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + console.log(' Removed ' + removed + ' caveman hook entries from settings.json'); + " + fi +fi + +# 3. Clean up backup file left by installer +if [ -f "$SETTINGS.bak" ]; then + rm "$SETTINGS.bak" + echo " Removed: $SETTINGS.bak" +fi + +# 4. Remove flag file +if [ -f "$FLAG_FILE" ]; then + rm "$FLAG_FILE" + echo " Removed: $FLAG_FILE" +fi + +echo "" +echo "Done! Restart Claude Code to complete the uninstall." + +# Guidance for other agents +echo "" +echo "Other agents:" +echo " npx skills remove caveman # Cursor, Windsurf, Cline, Copilot, etc." +echo " claude plugin disable caveman # Claude Code plugin" +echo " gemini extensions uninstall caveman # Gemini CLI" diff --git a/src/mcp-servers/caveman-shrink/README.md b/src/mcp-servers/caveman-shrink/README.md new file mode 100644 index 0000000..7de2b3b --- /dev/null +++ b/src/mcp-servers/caveman-shrink/README.md @@ -0,0 +1,58 @@ +# caveman-shrink + +> MCP middleware. Wrap any MCP server. Cut the prose. Keep the substance. + +`caveman-shrink` is a stdio proxy for the [Model Context Protocol](https://modelcontextprotocol.io). It sits between Claude (or any MCP client) and an upstream MCP server, and compresses the prose fields (`description`, etc.) using the same boundaries as the [caveman](../..) skill — preserving code, URLs, paths, and identifiers while stripping articles, filler, hedging, and pleasantries. + +The result: tool catalogs that the model burns fewer tokens to read, with no change to tool semantics. + +## Install + +```bash +npm install -g caveman-shrink +# or run directly via npx +npx caveman-shrink [...args] +``` + +## Use it + +Wrap any MCP server in your Claude Code (or other client) config: + +```jsonc +{ + "mcpServers": { + "fs-shrunk": { + "command": "npx", + "args": [ + "caveman-shrink", + "npx", "@modelcontextprotocol/server-filesystem", "/path/to/dir" + ] + } + } +} +``` + +The proxy spawns the upstream as a subprocess, intercepts `tools/list`, `prompts/list`, `resources/list` responses, and rewrites the `description` fields (and anything else you list in `CAVEMAN_SHRINK_FIELDS`). + +## What it does NOT touch + +By design, v1 is conservative: + +- **Request bodies** going to the upstream are passed through unchanged. +- **Tool call responses** (`tools/call`) are passed through unchanged. We don't want to risk silently mutating the data the upstream returns to the model. +- **Identifiers, URLs, paths, and code-looking tokens** inside any prose are preserved exactly. Same boundaries as the parent caveman skill. + +## Configuration + +| Env var | Default | What | +|---|---|---| +| `CAVEMAN_SHRINK_FIELDS` | `description` | Comma-separated list of field names to compress | +| `CAVEMAN_SHRINK_DEBUG` | `0` | Set to `1` to log per-field compression deltas to stderr | + +## Status + +Pre-1.0 — the compression rules and field set may change. The plugin is part of the [caveman ecosystem](https://github.com/JuliusBrussee/caveman); see the parent repo for the full skill suite (`caveman`, `cavemem`, `cavekit`, `cavecrew`, `caveman-stats`, `caveman-init`). + +## License + +MIT. diff --git a/src/mcp-servers/caveman-shrink/compress.js b/src/mcp-servers/caveman-shrink/compress.js new file mode 100644 index 0000000..8475227 Binary files /dev/null and b/src/mcp-servers/caveman-shrink/compress.js differ diff --git a/src/mcp-servers/caveman-shrink/index.js b/src/mcp-servers/caveman-shrink/index.js new file mode 100644 index 0000000..aed3bcc --- /dev/null +++ b/src/mcp-servers/caveman-shrink/index.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// caveman-shrink — MCP middleware that proxies an upstream MCP server and +// compresses prose fields so the model sees fewer tokens. +// +// Usage: +// caveman-shrink [...args] +// +// Example wrapping the filesystem MCP server: +// "mcpServers": { +// "fs-shrunk": { +// "command": "npx", +// "args": ["caveman-shrink", "npx", "@modelcontextprotocol/server-filesystem", "/some/path"] +// } +// } +// +// Compression is applied to: +// - "description" fields in tools/list, prompts/list, resources/list responses +// - same boundaries as caveman-compress: code, URLs, paths, identifiers preserved +// +// What we deliberately DON'T touch in v1: +// - tools/call response content (high risk of breaking downstream parsing) +// - request payloads going TO the upstream server +// +// Configuration (env vars): +// CAVEMAN_SHRINK_FIELDS comma-separated extra field names to compress +// (default: description) +// CAVEMAN_SHRINK_DEBUG=1 log compression deltas to stderr + +const { spawn } = require('child_process'); +const { compressDescriptionsInPlace, compress } = require('./compress'); + +const args = process.argv.slice(2); +if (args.length === 0) { + process.stderr.write('caveman-shrink: missing upstream command.\n'); + process.stderr.write('Usage: caveman-shrink [...args]\n'); + process.exit(2); +} + +const debug = process.env.CAVEMAN_SHRINK_DEBUG === '1'; +const fields = (process.env.CAVEMAN_SHRINK_FIELDS || 'description') + .split(',').map(s => s.trim()).filter(Boolean); + +const { getSpawnOptions } = require('./spawn-options'); + +const upstream = spawn(args[0], args.slice(1), getSpawnOptions()); + +upstream.on('error', err => { + process.stderr.write(`caveman-shrink: failed to spawn upstream: ${err.message}\n`); + process.exit(1); +}); + +upstream.on('exit', (code, signal) => { + if (signal) process.exit(128 + (signal === 'SIGTERM' ? 15 : 9)); + process.exit(code || 0); +}); + +// JSON-RPC framing over stdio: messages are separated by newlines (the +// MCP stdio transport uses LSP-like content but most servers emit one JSON +// object per line). We line-buffer in both directions and parse opportunistically. +function makeLineBuffer(onLine) { + let buf = ''; + return chunk => { + buf += chunk.toString('utf8'); + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (line.trim()) onLine(line); + } + }; +} + +function transformResponse(msg) { + // Compress description fields on list-style responses. Match by method + // shape — we don't always know the original request's method, so we + // detect by the presence of a tools/prompts/resources array. + if (!msg || !msg.result || typeof msg.result !== 'object') return msg; + const r = msg.result; + let compressedSomething = false; + + for (const arrayName of ['tools', 'prompts', 'resources', 'resourceTemplates']) { + if (Array.isArray(r[arrayName])) { + for (const item of r[arrayName]) { + for (const field of fields) { + if (typeof item[field] === 'string') { + const before = item[field]; + const out = compress(before).compressed; + if (out !== before) { + item[field] = out; + compressedSomething = true; + if (debug) { + process.stderr.write( + `[caveman-shrink] ${arrayName}.${item.name || '?'}.${field}: ` + + `${before.length}→${out.length} bytes\n` + ); + } + } + } + } + } + } + } + + // Some servers stuff descriptions in nested schemas. Only walk if nothing + // matched at the top level; avoids double-processing a tool's nested params. + if (!compressedSomething) compressDescriptionsInPlace(r, fields); + + return msg; +} + +// Upstream → us → client (model). Transform here. +upstream.stdout.on('data', makeLineBuffer(line => { + let msg; + try { msg = JSON.parse(line); } catch { + // Pass through unparseable lines unchanged. + process.stdout.write(line + '\n'); + return; + } + const out = transformResponse(msg); + process.stdout.write(JSON.stringify(out) + '\n'); +})); + +// Client → us → upstream. Pass through unchanged for v1. +process.stdin.on('data', chunk => upstream.stdin.write(chunk)); +process.stdin.on('end', () => upstream.stdin.end()); diff --git a/src/mcp-servers/caveman-shrink/package.json b/src/mcp-servers/caveman-shrink/package.json new file mode 100644 index 0000000..c4fca3b --- /dev/null +++ b/src/mcp-servers/caveman-shrink/package.json @@ -0,0 +1,31 @@ +{ + "name": "caveman-shrink", + "version": "0.1.1", + "description": "MCP proxy that compresses prose fields (tool descriptions, etc.) using caveman rules. Same accuracy, fewer context tokens.", + "license": "MIT", + "homepage": "https://github.com/JuliusBrussee/caveman", + "repository": { + "type": "git", + "url": "git+https://github.com/JuliusBrussee/caveman.git", + "directory": "src/mcp-servers/caveman-shrink" + }, + "author": "Julius Brussee", + "keywords": [ + "mcp", + "model-context-protocol", + "claude", + "caveman", + "proxy", + "tokens" + ], + "main": "compress.js", + "bin": { + "caveman-shrink": "index.js" + }, + "files": [ + "index.js", + "compress.js", + "spawn-options.js", + "README.md" + ] +} diff --git a/src/mcp-servers/caveman-shrink/spawn-options.js b/src/mcp-servers/caveman-shrink/spawn-options.js new file mode 100644 index 0000000..9658957 --- /dev/null +++ b/src/mcp-servers/caveman-shrink/spawn-options.js @@ -0,0 +1,21 @@ +// Spawn options for the upstream MCP child process. +// +// Windows: spawn('npx', ...) (and any .cmd shim such as 'gemini') hits ENOENT +// because PATHEXT resolution only happens when child_process spawns through +// a shell. POSIX systems resolve fine without a shell. Keep shell:false on +// POSIX to avoid argv quoting surprises. +// +// Exported standalone so the behavior is unit-testable without re-running +// the CLI entry point (index.js exits immediately when args are empty). + +'use strict'; + +function getSpawnOptions(platform = process.platform) { + return { + stdio: ['pipe', 'pipe', 'inherit'], + shell: platform === 'win32', + windowsHide: true, + }; +} + +module.exports = { getSpawnOptions }; diff --git a/src/plugins/opencode/README.md b/src/plugins/opencode/README.md new file mode 100644 index 0000000..1c68198 --- /dev/null +++ b/src/plugins/opencode/README.md @@ -0,0 +1,46 @@ +# caveman — opencode plugin + +Native opencode plugin. Mirrors the Claude Code hook architecture using +opencode's `session.created` + `tui.prompt.append` lifecycle hooks. + +## What this ships + +| File | Role | +|---|---| +| `plugin.js` | ESM Bun module. Default-exports an opencode `Plugin` factory. | +| `package.json` | Marks the directory as ESM so Bun loads `plugin.js` correctly. | +| `commands/*.md` | Six slash-command prompt templates (`/caveman`, `/caveman-commit`, …). | + +The installer (`bin/install.js --only opencode`) copies these alongside +`src/hooks/caveman-config.js` (for the symlink-safe flag-write helpers, renamed +to `caveman-config.cjs` because this directory is `"type": "module"`) into +`~/.config/opencode/plugins/caveman/` and patches `opencode.json` with a +`"plugin"` array entry. + +## What it does + +- `session.created` → writes the configured default mode to + `~/.config/opencode/.caveman-active` via the same `safeWriteFlag` helper + Claude Code uses (O_NOFOLLOW, atomic temp+rename, 0600 perms, symlink + refusal, ownership check). +- `tui.prompt.append` → flips the flag in response to `/caveman[ ]`, + `/caveman-commit`, `/caveman-review`, `/caveman-compress`, and natural + language ("turn on caveman", "stop caveman", "normal mode"). When a + non-independent mode is active, appends a one-line reinforcement to keep + caveman in the model's attention each turn. + +## What it does NOT do + +- **No statusline badge.** opencode's TUI does not expose a plugin-writable + statusline. The flag file is at `~/.config/opencode/.caveman-active` if + you want to surface mode in your shell prompt. +- **No system-prompt injection from `session.created`.** opencode's docs + don't expose a return shape for that. The always-on caveman ruleset comes + from `~/.config/opencode/AGENTS.md` (also written by the installer) so + the rules load even when the plugin runtime is broken. + +## Why no separate npm package + +Plugin code reuses `caveman-config.js` from the main repo. Shipping as an +in-repo plugin avoids a second release cadence and a name collision with +the existing third-party `opencode-caveman` npm package. diff --git a/src/plugins/opencode/commands/caveman-commit.md b/src/plugins/opencode/commands/caveman-commit.md new file mode 100644 index 0000000..7c5025d --- /dev/null +++ b/src/plugins/opencode/commands/caveman-commit.md @@ -0,0 +1,8 @@ +--- +description: Generate a terse caveman-style commit message for staged changes +--- +Generate a commit message for the current staged changes. + +Conventional Commits format. Subject line ≤50 chars, imperative, lowercase +after the type. No period on subject. Body only when the "why" isn't obvious +from the subject — explain why over what. Drop filler, hedging, padding. diff --git a/src/plugins/opencode/commands/caveman-compress.md b/src/plugins/opencode/commands/caveman-compress.md new file mode 100644 index 0000000..50fff74 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-compress.md @@ -0,0 +1,15 @@ +--- +description: Compress a markdown/text file into caveman format to save tokens +--- +Compress the file at: $ARGUMENTS + +Run the `caveman-compress` skill against the given filepath. The skill rewrites +prose into terse caveman style — drops articles, filler, hedging — while +preserving code blocks, inline code, URLs, file paths, commands, and markdown +structure exactly. Original is backed up as `.original.md` before +overwrite. + +Only compress natural-language files (`.md`, `.txt`, `.typ`, `.tex`, +extensionless). Refuse source/config files (`.py`, `.js`, `.ts`, `.json`, +`.yaml`, `.toml`, `.sh`, etc.). Never compress an existing `*.original.md` +backup. diff --git a/src/plugins/opencode/commands/caveman-help.md b/src/plugins/opencode/commands/caveman-help.md new file mode 100644 index 0000000..4538f88 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-help.md @@ -0,0 +1,20 @@ +--- +description: Quick reference card for caveman modes, slash commands, and triggers +--- +Show the caveman quick-reference card. + +| Command | What | +|---|---| +| `/caveman` | Activate at default level (full) | +| `/caveman lite` | Light compression — ~30% tokens dropped | +| `/caveman ultra` | Maximum compression | +| `/caveman wenyan[-lite\|-ultra]` | Classical Chinese compression | +| `/caveman off` | Deactivate | +| `/caveman-commit` | Terse commit message | +| `/caveman-review` | One-line review findings | +| `/caveman-compress ` | Compress a Markdown file | +| `/caveman-stats` | Lifetime token-savings | + +Natural language also works: "turn on caveman", "stop caveman", "normal mode". + +Code, commits, security warnings: caveman drops out automatically. diff --git a/src/plugins/opencode/commands/caveman-review.md b/src/plugins/opencode/commands/caveman-review.md new file mode 100644 index 0000000..0bfdc57 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-review.md @@ -0,0 +1,8 @@ +--- +description: Caveman-style code review — one-line findings with severity +--- +Review the current diff (or files: $ARGUMENTS). + +One line per finding. Format: `L: . .` +Severity emoji: 🔴 critical · 🟡 warn · 🟢 nit. Skip non-issues. +Group by file. End with a one-line verdict. diff --git a/src/plugins/opencode/commands/caveman-stats.md b/src/plugins/opencode/commands/caveman-stats.md new file mode 100644 index 0000000..b76ec27 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-stats.md @@ -0,0 +1,8 @@ +--- +description: Show caveman lifetime token-savings stats +--- +Show caveman stats — total tokens saved, sessions, average compression ratio. + +Read the lifetime history log at `~/.config/caveman/.caveman-history.jsonl` +(or wherever the caveman-stats script writes it). Output: total saved, +sessions counted, avg ratio. One short table. diff --git a/src/plugins/opencode/commands/caveman.md b/src/plugins/opencode/commands/caveman.md new file mode 100644 index 0000000..10f98c7 --- /dev/null +++ b/src/plugins/opencode/commands/caveman.md @@ -0,0 +1,13 @@ +--- +description: Activate caveman compression mode (lite | full | ultra | wenyan | off) +--- +Activate caveman mode: $ARGUMENTS + +If no level given, use full. If "off", deactivate. + +Respond terse like smart caveman. Drop articles, filler, pleasantries, hedging. +Fragments OK. Technical terms exact. Code unchanged. +Pattern: [thing] [action] [reason]. [next step]. + +Behavior persists until session ends or user says "stop caveman" / "normal mode". +Code, commits, security warnings: write normal English. diff --git a/src/plugins/opencode/package.json b/src/plugins/opencode/package.json new file mode 100644 index 0000000..f0971a1 --- /dev/null +++ b/src/plugins/opencode/package.json @@ -0,0 +1,8 @@ +{ + "name": "caveman-opencode-plugin", + "version": "0.1.0", + "description": "caveman plugin for opencode — terse mode tracking + slash commands", + "type": "module", + "main": "plugin.js", + "private": true +} diff --git a/src/plugins/opencode/plugin.js b/src/plugins/opencode/plugin.js new file mode 100644 index 0000000..521b846 --- /dev/null +++ b/src/plugins/opencode/plugin.js @@ -0,0 +1,229 @@ +// caveman — opencode plugin +// +// Provides dynamic caveman mode tracking for opencode: +// - Writes the mode flag on each session start (via the `event` dispatcher) +// - Parses user messages for /caveman commands and natural-language toggles +// - Injects per-turn reinforcement into the system prompt +// +// Bun ESM module; loads the existing security-hardened helpers from +// caveman-config.js via createRequire so the symlink-safe flag-write code +// lives in one place. +// +// Layout once installed: +// ~/.config/opencode/plugins/caveman/ +// ├── package.json +// ├── plugin.js ← this file +// └── caveman-config.cjs ← copied sibling of src/hooks/caveman-config.js +// +// The always-on caveman ruleset is provided separately via +// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic +// state only: flag writes, slash-command parsing, natural-language +// activation, and per-turn reinforcement. +// +// Hook mapping (opencode >= 1.15.x): +// - event (event.type === 'session.created'): session-init flag write, +// re-fires per session rather than once per plugin-process load +// - chat.message: intercept user prompts for mode changes +// - experimental.chat.system.transform: inject reinforcement per-turn +// +// Note: opencode does NOT support 'session.created' or 'tui.prompt.append' +// as named plugin-hook keys. 'session.created' is an event *type* dispatched +// through the single `event` handler; the old direct-key handlers were +// silently ignored. See: +// https://github.com/JuliusBrussee/caveman/issues/418 +// https://github.com/JuliusBrussee/caveman/issues/421 + +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { existsSync, unlinkSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// When installed: caveman-config.cjs sits next to plugin.js (copied by +// bin/install.js, renamed to .cjs because this directory's package.json +// declares "type": "module" — bare .js would be loaded as ESM). When loaded +// from the source tree (tests, dev): fall back to the canonical +// src/hooks/caveman-config.js, which lives in a directory whose own +// package.json pins "type": "commonjs". One source of truth either way. +// +// Loaded by evaluating the file as CommonJS by hand, NOT via the module +// loader: opencode runs plugins inside a compiled Bun binary where +// require() of on-disk files is rejected ("require() async module is +// unsupported") and await import() of a CJS file yields an empty namespace — +// both silently break the plugin (#418 follow-up). createRequire() still +// resolves node BUILT-INS fine in the compiled binary, which is all +// caveman-config needs (fs/path/os). +function loadConfig() { + const installed = join(here, 'caveman-config.cjs'); + const dev = join(here, '..', '..', 'hooks', 'caveman-config.js'); + const target = existsSync(installed) ? installed : dev; + const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, ''); + const mod = { exports: {} }; + new Function('module', 'exports', 'require', '__dirname', '__filename', code)( + mod, mod.exports, createRequire(import.meta.url), dirname(target), target + ); + return mod.exports; +} +const config = loadConfig(); + +const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config; + +// Modes handled by independent skills — not selectable via /caveman . +const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + +// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode +// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode +// (NOT %APPDATA%). os.homedir() is %USERPROFILE% on win32, so the default branch +// is already correct cross-platform. +function opencodeConfigDir() { + if (process.env.XDG_CONFIG_HOME) { + return path.join(process.env.XDG_CONFIG_HOME, 'opencode'); + } + return path.join(os.homedir(), '.config', 'opencode'); +} + +const flagPath = path.join(opencodeConfigDir(), '.caveman-active'); + +function reinforcementLine(mode) { + return 'CAVEMAN MODE ACTIVE (' + mode + '). ' + + 'Drop articles/filler/pleasantries/hedging. Fragments OK. ' + + 'Code/commits/security: write normal.'; +} + +// Parse a prompt for slash-command activation or natural-language toggles. +// Returns the new mode to write, the literal string 'off' to deactivate, or +// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js. +function parseModeChange(promptRaw) { + let prompt = (promptRaw || '').trim(); + // opencode's non-interactive `run` path delivers the message wrapped in + // literal quote characters ("/caveman ultra"\n) — unwrap symmetric quotes + // so the slash-command branch still matches. + const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt); + if (wrapped) prompt = wrapped[2].trim(); + prompt = prompt.toLowerCase(); + if (!prompt) return null; + + // Natural-language deactivation — checked before activation so "stop talking + // like caveman" doesn't trip the activation regex. + if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt)) { + return 'off'; + } + + // Expanded /caveman command template. opencode replaces a typed + // "/caveman " with the command file's body ("Activate caveman + // mode: $ARGUMENTS ...") before chat.message fires, so the literal + // slash-command branch below never sees it — recover the level argument + // from the template's first line instead. Must run before the generic + // NL-activation match, which would swallow it and drop the level. + const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(prompt); + if (tpl) { + const arg = tpl[1] || ''; + if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off'; + if (arg === 'wenyan-full') return 'wenyan'; + if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg; + return getDefaultMode(); + } + + // Natural-language activation + if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) { + const mode = getDefaultMode(); + return mode === 'off' ? null : mode; + } + + // Slash-command parsing — opencode also expands command files, but if the + // user types the literal slash command we still want to flip the flag. + if (prompt.startsWith('/caveman')) { + const parts = prompt.split(/\s+/); + const cmd = parts[0]; + const arg = parts[1] || ''; + + if (cmd === '/caveman-commit') return 'commit'; + if (cmd === '/caveman-review') return 'review'; + if (cmd === '/caveman-compress') return 'compress'; + + if (cmd === '/caveman') { + if (!arg) return getDefaultMode(); + if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off'; + if (arg === 'wenyan-full') return 'wenyan'; + if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg; + // Unknown arg — leave flag alone. No silent overwrite. + return null; + } + } + + return null; +} + +function applyModeChange(mode) { + if (!mode) return; + if (mode === 'off') { + try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {} + return; + } + safeWriteFlag(flagPath, mode); +} + +// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15) +// drives one shared implementation. Re-fires on every `session.created` event, +// so a new session in a long-lived plugin process re-asserts the flag. +function handleSessionCreated() { + const mode = getDefaultMode(); + if (mode === 'off') { + try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {} + return; + } + safeWriteFlag(flagPath, mode); +} + +export const CavemanPlugin = async (_ctx) => { + // Assert the flag at plugin load as well: in one-shot `opencode run` the + // first session.created publishes before plugin event dispatch is wired, + // so the event handler alone misses it. The factory-time write covers that + // race; the event handler re-asserts on every later session in long-lived + // TUI processes. + handleSessionCreated(); + + return { + // opencode dispatches session/lifecycle events through a single `event` + // handler keyed on event.type; the older direct top-level + // 'session.created' key is silently ignored. Routing session-init through + // here means the flag is rewritten on every new session, not just once when + // the plugin module loads. See https://opencode.ai/docs/plugins#events. + event: async ({ event } = {}) => { + if (event && event.type === 'session.created') handleSessionCreated(); + }, + + // Intercept user messages to detect /caveman commands and natural-language + // mode toggles. opencode fires chat.message with (input, output) where + // output.parts is the array of message parts; text parts carry .text. + // Return value is ignored — state changes happen via the flag file. + 'chat.message': async (_input, output) => { + if (!output || !output.parts) return; + for (const part of output.parts) { + if (part && part.type === 'text' && part.text) { + const change = parseModeChange(part.text); + if (change) applyModeChange(change); + } + } + }, + + // Inject the reinforcement line into the system prompt when caveman is + // active. opencode calls this before every LLM request and expects the hook + // to mutate output.system (a string[]); the return value is discarded. + 'experimental.chat.system.transform': async (_input, output) => { + if (!output || !Array.isArray(output.system)) return; + const active = readFlag(flagPath); + if (active && !INDEPENDENT_MODES.has(active)) { + output.system.push(reinforcementLine(active)); + } + }, + }; +}; + +export default CavemanPlugin; diff --git a/src/rules/caveman-activate.md b/src/rules/caveman-activate.md new file mode 100644 index 0000000..a9ab1bb --- /dev/null +++ b/src/rules/caveman-activate.md @@ -0,0 +1,15 @@ +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +Rules: +- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging +- Fragments OK. Short synonyms. Technical terms exact. Code unchanged. +- Pattern: [thing] [action] [reason]. [next step]. +- Not: "Sure! I'd be happy to help you with that." +- Yes: "Bug in auth middleware. Fix:" + +Switch level: /caveman lite|full|ultra|wenyan +Stop: "stop caveman" or "normal mode" + +Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after. + +Boundaries: code/commits/PRs written normal. diff --git a/src/rules/caveman-openclaw-bootstrap.md b/src/rules/caveman-openclaw-bootstrap.md new file mode 100644 index 0000000..aa2f1c5 --- /dev/null +++ b/src/rules/caveman-openclaw-bootstrap.md @@ -0,0 +1,18 @@ + +## Caveman mode (always on) + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +The full ruleset and intensity levels live in this workspace's caveman skill: + + skills/caveman/SKILL.md + +Default intensity: `full`. Switch with `/caveman lite|full|ultra|wenyan`. +Stop with: "stop caveman" / "normal mode" / "deactivate caveman". + +Auto-Clarity: drop caveman for security warnings, irreversible action +confirmations, multi-step sequences where fragments risk misread, or when +user is confused or repeating. Resume after. + +Boundaries: code, commit messages, and PR descriptions stay normal prose. + diff --git a/src/tools/caveman-init.js b/src/tools/caveman-init.js new file mode 100644 index 0000000..49082df --- /dev/null +++ b/src/tools/caveman-init.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// caveman init — drop the always-on caveman activation rule into a target +// repo for every IDE agent we support. Idempotent. Safe to re-run. +// +// Usage: +// node src/tools/caveman-init.js [target-dir] [--dry-run] [--force] [--only ] +// curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/tools/caveman-init.js | node - [args] +// +// Without args, runs in cwd. Generates the rule files for Cursor, Windsurf, +// Cline, Copilot, and AGENTS.md. Does NOT modify CLAUDE.md or compress +// existing memory files — that's the job of `/caveman:compress`. + +const fs = require('fs'); +const path = require('path'); + +// Embedded so the tool works standalone (npx-style) without the src/rules/ dir. +// Mirrors src/rules/caveman-activate.md verbatim — keep these in sync. +const RULE_BODY = `Respond terse like smart caveman. All technical substance stay. Only fluff die. + +Rules: +- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging +- Fragments OK. Short synonyms. Technical terms exact. Code unchanged. +- Pattern: [thing] [action] [reason]. [next step]. +- Not: "Sure! I'd be happy to help you with that." +- Yes: "Bug in auth middleware. Fix:" + +Switch level: /caveman lite|full|ultra|wenyan +Stop: "stop caveman" or "normal mode" + +Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after. + +Boundaries: code/commits/PRs written normal. +`; + +const SENTINEL = 'Respond terse like smart caveman'; + +// OpenClaw is a global workspace tool (not per-repo) and needs two write +// targets — a skill folder + a SOUL.md bootstrap block. The shared helper +// lives at bin/lib/openclaw.js; we require it lazily so caveman-init.js +// keeps working when run standalone (curl|node) without the helper on disk. +function loadOpenclawHelper() { + try { + return require(path.join(__dirname, '..', '..', 'bin', 'lib', 'openclaw.js')); + } catch (_) { return null; } +} + +const AGENTS = [ + { id: 'cursor', file: '.cursor/rules/caveman.mdc', + frontmatter: '---\ndescription: "Caveman mode — terse communication, 65% fewer output tokens (measured), full technical accuracy"\nalwaysApply: true\n---\n\n', + mode: 'replace' }, + { id: 'windsurf', file: '.windsurf/rules/caveman.md', + frontmatter: '---\ntrigger: always_on\n---\n\n', + mode: 'replace' }, + { id: 'cline', file: '.clinerules/caveman.md', + frontmatter: '', + mode: 'replace' }, + { id: 'copilot', file: '.github/copilot-instructions.md', + frontmatter: '', + mode: 'append' }, + { id: 'opencode', file: '.opencode/AGENTS.md', + frontmatter: '', + mode: 'append' }, + { id: 'agents', file: 'AGENTS.md', + frontmatter: '', + mode: 'append' }, + // OpenClaw — global workspace install, not per-repo. The `installer` + // callback escape hatch bypasses the file/frontmatter/mode triple and + // hands off to the shared helper. `description` is what `--help` prints. + { id: 'openclaw', description: '~/.openclaw/workspace/{skills/caveman/, SOUL.md}', + installer: 'openclaw' }, +]; + +function loadRuleBody() { + // Prefer the in-repo source-of-truth when available. + try { + const local = path.join(__dirname, '..', 'rules', 'caveman-activate.md'); + if (fs.existsSync(local)) return fs.readFileSync(local, 'utf8').trimEnd() + '\n'; + } catch (e) {} + return RULE_BODY; +} + +function processAgent(agent, targetDir, ruleBody, opts) { + if (agent.installer === 'openclaw') { + return processOpenclaw(opts); + } + const fullPath = path.join(targetDir, agent.file); + const exists = fs.existsSync(fullPath); + + if (!exists) { + if (!opts.dryRun) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, agent.frontmatter + ruleBody, { mode: 0o644 }); + } + return { status: 'added', label: '+' }; + } + + const existing = fs.readFileSync(fullPath, 'utf8'); + if (existing.includes(SENTINEL)) { + return { status: 'skipped-already-installed', label: '=' }; + } + + if (agent.mode === 'append') { + if (!opts.dryRun) { + const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); + fs.writeFileSync(fullPath, existing + sep + ruleBody, { mode: 0o644 }); + } + return { status: 'appended', label: '~' }; + } + + if (opts.force) { + if (!opts.dryRun) { + fs.writeFileSync(fullPath, agent.frontmatter + ruleBody, { mode: 0o644 }); + } + return { status: 'overwritten', label: '!' }; + } + + return { status: 'skipped-exists', label: '?' }; +} + +function processOpenclaw(opts) { + const helper = loadOpenclawHelper(); + if (!helper) { + return { + status: 'unsupported-standalone', + label: 'x', + detail: '~/.openclaw/workspace (helper unavailable in standalone curl|node mode — use `npx -y github:JuliusBrussee/caveman -- --only openclaw`)', + }; + } + const repoRoot = path.resolve(__dirname, '..', '..'); + const log = { + write: (_) => {}, + note: (_) => {}, + warn: (_) => {}, + }; + const r = helper.installOpenclaw({ + workspace: process.env.OPENCLAW_WORKSPACE || undefined, + repoRoot, + dryRun: opts.dryRun, + force: opts.force, + log, + }); + if (!r.ok) { + return { status: 'skipped-' + (r.reason || 'failed'), label: '?', detail: helper.resolveWorkspace ? helper.resolveWorkspace() : '~/.openclaw/workspace' }; + } + if (r.dryRun) return { status: 'would-add', label: '+', detail: helper.resolveWorkspace() }; + return { status: 'installed', label: '+', detail: helper.resolveWorkspace() }; +} + +function parseArgs(argv) { + const opts = { dryRun: false, force: false, only: null, target: process.cwd() }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--dry-run') opts.dryRun = true; + else if (a === '--force' || a === '-f') opts.force = true; + else if (a === '--only') { opts.only = argv[++i]; } + else if (a === '-h' || a === '--help') opts.help = true; + else if (!a.startsWith('-')) opts.target = path.resolve(a); + } + return opts; +} + +function help() { + console.log(`caveman init — drop always-on caveman rule into a target repo + +Usage: caveman-init.js [target-dir] [--dry-run] [--force] [--only ] + +Defaults to current working directory. Idempotent — safe to re-run. + +Targets installed: +${AGENTS.map(a => ` ${a.id.padEnd(10)} ${a.file || a.description || ''}`).join('\n')} + +Flags: + --dry-run show what would change, do not write + --force overwrite existing rule files (default: skip) + --only only install for one agent (id from list above) +`); +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { help(); return; } + + console.log(`🪨 caveman init — ${opts.target}${opts.dryRun ? ' (dry run)' : ''}\n`); + + const ruleBody = loadRuleBody(); + const counts = { added: 0, appended: 0, overwritten: 0, skipped: 0 }; + + for (const agent of AGENTS) { + if (opts.only && opts.only !== agent.id) continue; + const result = processAgent(agent, opts.target, ruleBody, opts); + const target = agent.file || result.detail || agent.description || agent.id; + console.log(` ${result.label} ${target} (${result.status})`); + if (result.status === 'added' || result.status === 'installed' || result.status === 'would-add') counts.added++; + else if (result.status === 'appended') counts.appended++; + else if (result.status === 'overwritten') counts.overwritten++; + else counts.skipped++; + } + + console.log(`\n${counts.added} added, ${counts.appended} appended, ` + + `${counts.overwritten} overwritten, ${counts.skipped} skipped`); + if (opts.dryRun) console.log('(dry run — no files were written)'); +} + +// Run when executed directly AND when piped via `curl … | node -` (the +// documented standalone path, #603): under stdin execution require.main is +// undefined and module.id is '[stdin]', so the classic guard alone silently +// no-ops with exit code 0 — the worst kind of failure. +if (require.main === module || (!require.main && module.id === '[stdin]')) main(); + +module.exports = { processAgent, loadRuleBody, AGENTS, SENTINEL, RULE_BODY }; diff --git a/tests/caveman-compress/claude-md-preferences.md b/tests/caveman-compress/claude-md-preferences.md new file mode 100644 index 0000000..734fa9d --- /dev/null +++ b/tests/caveman-compress/claude-md-preferences.md @@ -0,0 +1,43 @@ +# User Preferences + +## Code Style + +Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early. + +React: functional components + hooks only. No class components. State local as possible; lift only when truly needed. Rather extra re-renders than complex global state. + +Imports: organized, external/internal/relative separated. Use path aliases (`@/components/...`) not deep relative paths. + +## Testing Approach + +Always write tests for new functionality. Test behavior from user perspective, not implementation. "Clicking submit creates task" not "handleSubmit calls createTask." + +React: Testing Library, no internal state/lifecycle testing. API endpoints: integration tests hit real DB — mocked tests passed but prod broke too many times. + +No 100% coverage needed. Critical paths matter. Services: thorough unit tests. UI: happy path + key error states. + +## Communication Style + +Senior engineer, 2 years on project. Skip basic concepts. Concise, direct. Tradeoffs: options + pros/cons, no paragraphs. + +Show actual code, not descriptions. Multiple files: show all at once. + +Comments explain "why" not "what." Code needing "what" comments needs refactoring instead. + +## Workflow Preferences + +Read existing code before changes. Follow existing conventions over preferred approach — consistency > preference in team codebase. + +PRs: small, focused. 3 small > 1 large. Each PR does one thing. Found something else? Separate PR. + +Run linter + type checker before committing. Run manually after large refactors even with pre-commit hooks. + +## Things to Avoid + +No `console.log` — use `src/lib/logger.ts`. Logs reach prod and clutter output. + +No new deps without discussion. Keep bundle small, avoid duplicate libraries. Prefer built-in Node/browser APIs over packages. + +No single-consumer abstractions. Premature abstraction worse than duplication. Wait for 3+ use cases before extracting shared utility. + +Never commit `.env` or secrets/keys/credentials. `.env.example` gets placeholder values only. \ No newline at end of file diff --git a/tests/caveman-compress/claude-md-preferences.original.md b/tests/caveman-compress/claude-md-preferences.original.md new file mode 100644 index 0000000..5bae2a7 --- /dev/null +++ b/tests/caveman-compress/claude-md-preferences.original.md @@ -0,0 +1,43 @@ +# User Preferences + +## Code Style + +I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to properly type things catches a lot of bugs before they ever make it to runtime. + +When writing React components, I always want to use functional components with hooks. I have no interest in class components — they're harder to read and test in my experience. For state management, I prefer keeping state as local as possible and only lifting it up when truly necessary. I'd rather have a component re-render a bit more than have a complex global state tree. + +I like to keep my imports organized with a clear separation between external packages, internal modules, and relative imports. Please use path aliases (like `@/components/...`) instead of deeply nested relative paths. It makes refactoring much easier and the code more readable. + +## Testing Approach + +Please always write tests for any new functionality. I prefer writing tests that describe behavior from the user's perspective rather than testing implementation details. For example, test that "clicking the submit button creates a new task" rather than "the handleSubmit function calls the createTask service." + +For React components, use Testing Library and avoid directly testing internal state or lifecycle methods. For API endpoints, write integration tests that hit the real database — we've had too many incidents where mocked tests passed but production broke. + +I don't need 100% code coverage, but I do want meaningful coverage on critical paths. Business logic in services should have thorough unit tests. UI components should have tests for the happy path and key error states. + +## Communication Style + +I'm a senior engineer who has been working on this project for about two years. You don't need to explain basic programming concepts to me. I appreciate concise, direct communication that gets to the point quickly. If there's a tradeoff to make, just lay out the options with pros and cons rather than writing paragraphs of explanation. + +When suggesting code changes, please show me the actual code rather than describing what to change in words. I can read code faster than I can read a paragraph describing code. If you're making changes across multiple files, show them all at once rather than one at a time. + +Don't add comments to obvious code. Comments should explain "why" not "what." If the code needs a comment to explain what it does, it probably needs to be refactored instead. + +## Workflow Preferences + +Before making any changes, please read the existing code first to understand the patterns already in use. I'd rather you follow the existing conventions even if they're not your preferred approach — consistency matters more than individual preference in a team codebase. + +When making pull requests, keep them focused and small. I'd rather review three small PRs than one large one. Each PR should ideally do one thing and do it well. If you find something else that needs fixing while working on a feature, create a separate PR for it. + +Run the linter and type checker before committing. The pre-commit hooks should catch most issues, but it's good practice to run them manually too, especially after a large refactoring session. + +## Things to Avoid + +Please do not add console.log statements for debugging — use the structured logging utility at `src/lib/logger.ts` instead. Console logs have a bad habit of making it to production and cluttering the output. + +Don't install new dependencies without discussing it first. I want to keep the bundle size manageable and avoid situations where we have three libraries that do the same thing. If there's a built-in Node.js or browser API that can do the job, prefer that over adding a package. + +Avoid creating abstraction layers that only have one consumer. Premature abstraction is worse than duplication in my experience. Wait until you have at least three places that need the same thing before extracting a shared utility. + +Never commit `.env` files or any file containing secrets, API keys, or credentials. The `.env.example` file should have placeholder values that indicate what each variable is for without revealing actual secrets. diff --git a/tests/caveman-compress/claude-md-project.md b/tests/caveman-compress/claude-md-project.md new file mode 100644 index 0000000..ccc5c2f --- /dev/null +++ b/tests/caveman-compress/claude-md-project.md @@ -0,0 +1,161 @@ +# CLAUDE.md — Taskflow Project + +## Overview + +Taskflow full-stack task management app. Teams create, assign, track, manage tasks across projects with real-time collaboration. Started internal tool, now open-source. + +Active dev focus: improve performance, add integrations (Slack, GitHub, Jira). + +## Architecture + +Three-tier architecture: frontend, backend API, data layer. + +### Frontend + +React 18 + TypeScript. Next.js 14 (SSR + API routes). +UI: Radix UI + Tailwind CSS. +State: React Context (global), TanStack Query (server state + caching). + +Code structure: + +* `src/app/` — App Router +* `src/components/` — shared components +* `src/lib/` — utilities +* `src/types/` — type definitions + +### Backend + +Node.js + Express API, port 3001 (dev). +Pattern: controller-service-repository. + +* controllers handle HTTP +* services contain business logic +* repositories manage DB access + +Structure under `server/src/`: + +* `server/src/controllers/` — route handlers + validation +* `server/src/services/` — business logic +* `server/src/repositories/` — DB queries (Knex.js) +* `server/src/middleware/` — auth, rate limit, errors +* `server/src/jobs/` — background jobs (BullMQ) + +### Database + +PostgreSQL 15 primary DB. +Migrations: Knex.js in `server/migrations/`. + +Tables: users, teams, projects, tasks, comments, attachments, audit logs. + +Redis: caching, sessions, BullMQ message broker. + +### Infrastructure + +AWS deploy using ECS Fargate. + +CI/CD (GitHub Actions): + +1. PR: lint, type-check, unit + integration tests +2. Merge to main: build Docker, push to ECR, deploy staging +3. Release tag: promote staging → production + +## Key Conventions + +### Code Style + +ESLint (Airbnb + TypeScript), Prettier formatting. +Pre-commit: Husky + lint-staged run linters. + +Rules: + +* Use strict TypeScript +* Avoid `any`, if used explain why +* Prefer interfaces over type aliases +* Use discriminated unions for state + +### Testing + +Test suite: + +* Unit: `*.test.ts`, Vitest + Testing Library +* Integration: `tests/integration/`, real PostgreSQL (Docker), run `npm run test:integration` +* E2E: `tests/e2e/`, Playwright, CI only + +Rules: + +* Test behavior, not implementation +* Mock external services +* Do NOT mock DB in integration tests + +## Git Workflow + +Trunk-based development. +Short-lived feature branches → PR → merge to `main`. + +Branch format: `/-` +Example: `feat/TF-123-add-slack-integration` + +Commits: Conventional Commits — `type(scope): description` +Types: feat, fix, refactor, test, docs, chore, perf + +Rules: + +* Require ≥1 approval +* CI must pass +* Prefer squash merge + +## Common Commands + +```bash +# Development +npm run dev # Start frontend + backend in parallel +npm run dev:frontend # Start only Next.js dev server +npm run dev:backend # Start only Express API server + +# Testing +npm run test # Run unit tests with Vitest +npm run test:watch # Run tests in watch mode +npm run test:integration # Run integration tests (requires Docker) +npm run test:e2e # Run Playwright E2E tests + +# Database +npm run db:migrate # Run pending migrations +npm run db:rollback # Rollback last migration batch +npm run db:seed # Seed database with sample data +npm run db:reset # Drop, recreate, migrate, and seed + +# Build & Deploy +npm run build # Build frontend and backend +npm run lint # Run ESLint on all files +npm run typecheck # Run TypeScript compiler checks +docker compose up -d # Start all services locally with Docker +``` + +## Environment Variables + +Required env vars. Copy `.env.example` → `.env.local`. + +* `DATABASE_URL` — PostgreSQL connection string (`postgresql://user:pass@localhost:5432/taskflow`) +* `REDIS_URL` — Redis connection string (`redis://localhost:6379`) +* `JWT_SECRET` — JWT signing key (≥32 chars) +* `NEXT_PUBLIC_API_URL` — API URL (`http://localhost:3001`) +* `SLACK_WEBHOOK_URL` — optional Slack webhook +* `GITHUB_TOKEN` — optional GitHub token + +## Known Issues + +1. WebSocket reconnection fails after network drop. Race condition with auth refresh. Issue TF-456 + +2. Large uploads (>10MB) timeout on slow network. Need chunked upload. Planned next sprint + +3. Dashboard slow >500 tasks. Need query optimization + virtual scrolling. Issue TF-489 + +4. Timezone issue. Stored UTC, displayed server timezone, not user local. Need API + frontend fix + +## Team + +* Alex Chen — tech lead, backend + infra +* Maya Patel — frontend lead, design system +* Jordan Kim — full-stack, Slack + GitHub integrations +* Sam Rivera — backend, dashboard performance + diff --git a/tests/caveman-compress/claude-md-project.original.md b/tests/caveman-compress/claude-md-project.original.md new file mode 100644 index 0000000..46d1ef7 --- /dev/null +++ b/tests/caveman-compress/claude-md-project.original.md @@ -0,0 +1,122 @@ +# CLAUDE.md — Taskflow Project + +## Overview + +Taskflow is a full-stack task management application built with a modern web stack. The application allows teams to create, assign, track, and manage tasks across multiple projects with real-time collaboration features. It was originally created as an internal tool for our engineering team and has since been open-sourced. + +The project is currently in active development with a focus on improving performance and adding integration capabilities with third-party services like Slack, GitHub, and Jira. + +## Architecture + +The application follows a standard three-tier architecture with clear separation of concerns between the frontend, backend API, and data layer. + +### Frontend + +The frontend is a React 18 application written in TypeScript. We use Next.js 14 as the meta-framework for server-side rendering and API routes. The UI component library is built on top of Radix UI primitives with Tailwind CSS for styling. State management is handled through a combination of React Context for global state and TanStack Query (formerly React Query) for server state management and caching. + +The frontend source code lives in `src/app/` following the Next.js App Router convention. Shared components are in `src/components/`, utility functions in `src/lib/`, and type definitions in `src/types/`. + +### Backend + +The backend API is built with Node.js and Express, running on port 3001 in development. It follows a controller-service-repository pattern where controllers handle HTTP concerns, services contain business logic, and repositories manage database access. The API code lives in `server/src/` with the following structure: + +- `server/src/controllers/` — Route handlers and request validation +- `server/src/services/` — Business logic and orchestration +- `server/src/repositories/` — Database queries using Knex.js query builder +- `server/src/middleware/` — Authentication, rate limiting, error handling +- `server/src/jobs/` — Background job processors using BullMQ + +### Database + +We use PostgreSQL 15 as the primary database. Migrations are managed with Knex.js and live in `server/migrations/`. The database schema includes tables for users, teams, projects, tasks, comments, attachments, and audit logs. Redis is used for caching, session storage, and as the message broker for BullMQ background jobs. + +### Infrastructure + +The application is deployed on AWS using ECS Fargate for containerized services. The CI/CD pipeline runs on GitHub Actions with the following workflow: + +1. On every pull request: lint, type-check, unit tests, and integration tests +2. On merge to main: build Docker images, push to ECR, deploy to staging +3. On release tag: promote staging image to production + +## Key Conventions + +### Code Style + +We use ESLint with a custom configuration that extends the Airbnb style guide with TypeScript-specific rules. Prettier is configured for consistent formatting. The pre-commit hook runs both linters automatically using Husky and lint-staged. + +All TypeScript code should use strict mode. Avoid using `any` type unless absolutely necessary, and if you do, add a comment explaining why. Prefer interfaces over type aliases for object shapes, and use discriminated unions for state management. + +### Testing + +We maintain a comprehensive test suite with the following structure: + +- **Unit tests**: Located alongside source files as `*.test.ts`. Use Vitest as the test runner with Testing Library for component tests. Aim for high coverage on business logic and utility functions. +- **Integration tests**: Located in `tests/integration/`. These tests hit a real PostgreSQL database (managed by Docker Compose) and verify end-to-end API behavior. Run with `npm run test:integration`. +- **E2E tests**: Located in `tests/e2e/`. Use Playwright for browser automation. These are slower and run only in CI, not as part of the pre-commit hook. + +When writing tests, prefer testing behavior over implementation details. Mock external services but never mock the database in integration tests — we learned this the hard way when mocked tests passed but a production migration broke things. + +### Git Workflow + +We use a trunk-based development model. All development happens on short-lived feature branches that are merged into `main` via pull requests. Branch names should follow the pattern `/-` (e.g., `feat/TF-123-add-slack-integration`). + +Commit messages follow Conventional Commits format: `type(scope): description`. The types we use are: feat, fix, refactor, test, docs, chore, perf. + +Pull requests require at least one approval from a team member. The CI pipeline must pass before merging. We prefer squash merges for feature branches to keep the main branch history clean. + +## Common Commands + +```bash +# Development +npm run dev # Start frontend + backend in parallel +npm run dev:frontend # Start only Next.js dev server +npm run dev:backend # Start only Express API server + +# Testing +npm run test # Run unit tests with Vitest +npm run test:watch # Run tests in watch mode +npm run test:integration # Run integration tests (requires Docker) +npm run test:e2e # Run Playwright E2E tests + +# Database +npm run db:migrate # Run pending migrations +npm run db:rollback # Rollback last migration batch +npm run db:seed # Seed database with sample data +npm run db:reset # Drop, recreate, migrate, and seed + +# Build & Deploy +npm run build # Build frontend and backend +npm run lint # Run ESLint on all files +npm run typecheck # Run TypeScript compiler checks +docker compose up -d # Start all services locally with Docker +``` + +## Environment Variables + +The application requires the following environment variables. Copy `.env.example` to `.env.local` and fill in the values: + +- `DATABASE_URL` — PostgreSQL connection string (e.g., `postgresql://user:pass@localhost:5432/taskflow`) +- `REDIS_URL` — Redis connection string (e.g., `redis://localhost:6379`) +- `JWT_SECRET` — Secret key for signing JWT tokens (minimum 32 characters) +- `NEXT_PUBLIC_API_URL` — Backend API URL for the frontend (e.g., `http://localhost:3001`) +- `SLACK_WEBHOOK_URL` — Optional: Slack webhook for notifications +- `GITHUB_TOKEN` — Optional: GitHub personal access token for issue sync + +## Known Issues + +There are a few known issues that the team is currently aware of and working on: + +1. **WebSocket reconnection** — The real-time collaboration feature sometimes fails to reconnect after a network interruption. The client-side reconnection logic has a race condition with the authentication refresh flow. Tracked in issue TF-456. + +2. **Large file uploads** — Attachments larger than 10MB occasionally timeout on slower connections. The upload endpoint needs to be refactored to support chunked uploads. This is planned for the next sprint. + +3. **Dashboard performance** — The main dashboard becomes sluggish when a project has more than 500 tasks. The query needs optimization and we should implement virtual scrolling on the frontend. Issue TF-489. + +4. **Timezone handling** — Due dates are stored in UTC but displayed in the server's timezone instead of the user's local timezone. This causes confusion for distributed teams. The fix requires updating both the API response serialization and the frontend date formatting utilities. + +## Team + +- **Alex Chen** — Tech lead, owns backend architecture and infrastructure +- **Maya Patel** — Frontend lead, owns component library and design system +- **Jordan Kim** — Full-stack, currently focused on the Slack and GitHub integrations +- **Sam Rivera** — Backend, currently working on performance optimizations for the dashboard queries diff --git a/tests/caveman-compress/mixed-with-code.md b/tests/caveman-compress/mixed-with-code.md new file mode 100644 index 0000000..28744fe --- /dev/null +++ b/tests/caveman-compress/mixed-with-code.md @@ -0,0 +1,209 @@ +# API Integration Guide + +## Authentication + +All API requests include valid JWT in Authorization header. +Get token from login endpoint using credentials. +If expired, use refresh token to get new access token, retry request. + +Auth example: + +```typescript +const login = async (email: string, password: string) => { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const { accessToken, refreshToken } = await response.json(); + return { accessToken, refreshToken }; +}; +``` + +Access token expires in 15 min. +On 401 → refresh token. + +```typescript +const refreshAccessToken = async (refreshToken: string) => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) throw new Error('Refresh failed'); + const { accessToken } = await response.json(); + return accessToken; +}; +``` + +## Creating Tasks + +Create task → POST `/api/v2/tasks`. + +Required: `projectId`, `title` +Optional: others use defaults + +`priority`: 1 (low) → 5 (high), default 3 + +```typescript +interface CreateTaskPayload { + projectId: string; + title: string; + description?: string; + assigneeId?: string; + priority?: 1 | 2 | 3 | 4 | 5; + dueDate?: string; // ISO 8601 format + labels?: string[]; +} + +const createTask = async (payload: CreateTaskPayload, token: string) => { + const response = await fetch('/api/v2/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + return response.json(); +}; +``` + +Response includes: `id`, `createdAt`, `status` (default `"todo"`). + +## Error Handling + +All errors return: + +* `code` — machine-readable +* `message` — human-readable +* `details` — optional extra info + +Common errors: + +* `AUTH_TOKEN_EXPIRED` — refresh + retry +* `AUTH_TOKEN_INVALID` — login again +* `VALIDATION_ERROR` — check `details` +* `NOT_FOUND` — resource missing / no access +* `RATE_LIMIT_EXCEEDED` — wait (`Retry-After`) + +Pattern: + +```typescript +class ApiError extends Error { + constructor( + public code: string, + public status: number, + message: string, + public details?: Record + ) { + super(message); + } +} + +const apiClient = async (url: string, options: RequestInit = {}) => { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new ApiError(error.code, response.status, error.message, error.details); + } + + return response.json(); +}; +``` + +## Pagination + +All list endpoints use cursor-based pagination. +Better than offset for consistency with concurrent changes. + +Response includes `cursor`. +Pass as query param for next page. + +Defaults: + +* page size: 50 +* max: 100 (`limit`) + +Fetch all tasks: + +```typescript +const fetchAllTasks = async (projectId: string, token: string) => { + let cursor: string | undefined; + const allTasks = []; + + do { + const params = new URLSearchParams({ limit: '50' }); + if (cursor) params.set('cursor', cursor); + + const response = await apiClient( + `/api/v2/projects/${projectId}/tasks?${params}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + allTasks.push(...response.data); + cursor = response.cursor; + } while (cursor); + + return allTasks; +}; +``` + +## Rate Limiting + +Limits: + +* Authenticated: 100 req/min +* Unauthenticated: 20 req/min + +On exceed → 429 + `Retry-After`. + +Client strategy: + +* Use exponential backoff +* Start with `Retry-After` +* Double each retry +* Max wait: 60s + +Prevents thundering herd. + +## Webhooks + +Supports outgoing webhooks for events: + +* task created, updated, deleted, assigned, status change + +Configured in project settings. +Sends POST with event payload. + +Security: + +* Header: `X-Taskflow-Signature` +* HMAC-SHA256 of body using webhook secret +* Always verify before processing + +```typescript +import crypto from 'crypto'; + +const verifyWebhookSignature = ( + payload: string, + signature: string, + secret: string +): boolean => { + const expected = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +}; +``` diff --git a/tests/caveman-compress/mixed-with-code.original.md b/tests/caveman-compress/mixed-with-code.original.md new file mode 100644 index 0000000..0b992d7 --- /dev/null +++ b/tests/caveman-compress/mixed-with-code.original.md @@ -0,0 +1,167 @@ +# API Integration Guide + +## Authentication + +All API requests must include a valid JWT token in the Authorization header. The token is obtained by calling the login endpoint with valid credentials. If the token has expired, the client should use the refresh token to obtain a new access token before retrying the failed request. + +Here's how to authenticate: + +```typescript +const login = async (email: string, password: string) => { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const { accessToken, refreshToken } = await response.json(); + return { accessToken, refreshToken }; +}; +``` + +The access token expires after 15 minutes. When you receive a 401 response, you should attempt to refresh the token: + +```typescript +const refreshAccessToken = async (refreshToken: string) => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!response.ok) throw new Error('Refresh failed'); + const { accessToken } = await response.json(); + return accessToken; +}; +``` + +## Creating Tasks + +To create a new task, you need to send a POST request to the tasks endpoint with the required fields. The `projectId` and `title` fields are required. All other fields are optional and will use sensible defaults if not provided. The `priority` field accepts values from 1 (lowest) to 5 (highest), with 3 being the default. + +```typescript +interface CreateTaskPayload { + projectId: string; + title: string; + description?: string; + assigneeId?: string; + priority?: 1 | 2 | 3 | 4 | 5; + dueDate?: string; // ISO 8601 format + labels?: string[]; +} + +const createTask = async (payload: CreateTaskPayload, token: string) => { + const response = await fetch('/api/v2/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + return response.json(); +}; +``` + +The response will include the created task with a generated `id`, `createdAt` timestamp, and `status` set to "todo" by default. + +## Error Handling + +The API returns consistent error responses across all endpoints. Every error response includes a `code` field with a machine-readable error identifier and a `message` field with a human-readable description. Some errors also include a `details` field with additional context. + +Common error codes you should handle in your client application: + +- `AUTH_TOKEN_EXPIRED` — The access token has expired. Refresh it and retry the request. +- `AUTH_TOKEN_INVALID` — The token is malformed or has been tampered with. The user needs to log in again. +- `VALIDATION_ERROR` — The request body failed validation. Check the `details` field for specific field errors. +- `NOT_FOUND` — The requested resource does not exist or the user doesn't have permission to access it. +- `RATE_LIMIT_EXCEEDED` — Too many requests. The `Retry-After` header indicates when you can retry. + +Here's a recommended error handling pattern for your API client: + +```typescript +class ApiError extends Error { + constructor( + public code: string, + public status: number, + message: string, + public details?: Record + ) { + super(message); + } +} + +const apiClient = async (url: string, options: RequestInit = {}) => { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json(); + throw new ApiError(error.code, response.status, error.message, error.details); + } + + return response.json(); +}; +``` + +## Pagination + +All list endpoints support cursor-based pagination. This approach was chosen over offset-based pagination because it provides consistent results even when items are being added or removed concurrently. Each response includes a `cursor` field that should be passed as a query parameter to fetch the next page. + +The default page size is 50 items, which can be adjusted using the `limit` query parameter (maximum 100). To fetch all tasks in a project with pagination: + +```typescript +const fetchAllTasks = async (projectId: string, token: string) => { + let cursor: string | undefined; + const allTasks = []; + + do { + const params = new URLSearchParams({ limit: '50' }); + if (cursor) params.set('cursor', cursor); + + const response = await apiClient( + `/api/v2/projects/${projectId}/tasks?${params}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + allTasks.push(...response.data); + cursor = response.cursor; + } while (cursor); + + return allTasks; +}; +``` + +## Rate Limiting + +The API enforces rate limits to ensure fair usage and protect the service from abuse. Authenticated requests are limited to 100 requests per minute. Unauthenticated requests (such as the login endpoint) are limited to 20 requests per minute. When you exceed the rate limit, the API responds with a 429 status code and includes a `Retry-After` header indicating the number of seconds to wait before making another request. + +It is recommended that your client application implements exponential backoff when encountering rate limit errors. Starting with the `Retry-After` value, double the wait time on each subsequent 429 response, up to a maximum of 60 seconds. This prevents thundering herd problems when multiple clients hit the rate limit simultaneously. + +## Webhooks + +Taskflow supports outgoing webhooks for real-time event notifications. You can configure webhook URLs in the project settings. When an event occurs (task created, updated, deleted, assigned, or status changed), the system sends a POST request to your configured URL with the event payload. + +Webhook payloads include an `X-Taskflow-Signature` header containing an HMAC-SHA256 signature of the request body using your webhook secret. Always verify this signature before processing the webhook to ensure the request is authentic. + +```typescript +import crypto from 'crypto'; + +const verifyWebhookSignature = ( + payload: string, + signature: string, + secret: string +): boolean => { + const expected = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +}; +``` diff --git a/tests/caveman-compress/project-notes.md b/tests/caveman-compress/project-notes.md new file mode 100644 index 0000000..5486e05 --- /dev/null +++ b/tests/caveman-compress/project-notes.md @@ -0,0 +1,64 @@ +# Project Notes — Taskflow + +## Architecture Decision: Background Job Processing (March 2026) + +Team pick BullMQ for background jobs. No custom build, no AWS SQS. Why: + +Already run Redis for cache+sessions. BullMQ need Redis. No new infra. SQS break local dev, hurt contributor setup. + +BullMQ have retries, exponential backoff, priority queues, rate limiting, scheduling — no need build own. Active maintenance, good TypeScript support. + +First job types: email notifications, file uploads (thumbnails, virus scan), third-party sync (Slack, GitHub), expired session cleanup. More later. + +Ops concern: monitoring complexity. Fix: BullMQ dashboard added as admin route at `/admin/jobs`. See queue health, failed jobs, rates. No extra tool needed. + +## Performance Investigation: Dashboard Slowness (March 2026) + +Sam investigate: dashboard unusable at 500+ tasks. Findings: + +Main bottleneck: N+1 query. Load all tasks, then per-task query for assignee profile. 500 tasks = 501 queries. Slow. + +Frontend issue: task list render all at once, no virtualization. React struggle with 500+ task cards each with children, tooltips, dropdowns. + +Proposed solutions: +1. Add JOIN to load assignees in one query +2. Add composite index on `tasks(project_id, status, updated_at)` +3. Cursor-based pagination on API (50 tasks/page) +4. Virtual scrolling via TanStack Virtual +5. Redis cache dashboard response, short TTL (30s), invalidate on change + +Do 1, 2, 3 first — fix root cause. 4 and 5 later if needed. + +## Meeting Notes: Security Review (February 2026) + +External audit find issues: + +Critical: SQL injection in task search + user-by-email endpoints. Cause: string interpolation in queries. Fixed: switched to Knex.js parameterized queries everywhere. Added ESLint rule to catch raw string concat in query builders. + +JWT expiry too long (30 days). Reduced: access token 15min, refresh token 7 days. Refresh token in HttpOnly cookie, access token in memory only — never `localStorage`. + +Missing Content Security Policy headers. Added to Next.js middleware. Currently report-only mode. Switch to enforcement after 2 weeks monitoring. + +Rate limiting missing from public API. Alex add `express-rate-limit` with Redis store — share state across API instances. + +## Design Decision: Component Library (January 2026) + +Maya lead eval. Options: + +1. **shadcn/ui with Radix primitives** — copy components into project, Radix for a11y, Tailwind for styles. Pros: full code ownership, easy customize, great a11y. Cons: more setup, self-maintain. + +2. **Material UI (MUI)** — most popular React lib. Pros: mature, docs, big community. Cons: large bundle, opinionated, hard customize, vendor lock-in. + +3. **Chakra UI** — prop-based styling. Pros: good DX, accessible. Cons: runtime CSS-in-JS slow, smaller ecosystem. + +Pick option 1 (shadcn/ui + Radix). Max control, Radix a11y, Tailwind match existing strategy, small bundle. Tradeoff: self-maintain. Team OK with that. + +## Technical Debt Inventory (January 2026) + +Auth system: rushed at launch, messy. Token refresh split across 3 files, inconsistent error handling. WebSocket auth separate from HTTP auth — causing reconnect race condition now. Needs refactor, but team wait for better test coverage first. + +Test suite: inconsistent. Mix of old Enzyme and Testing Library. Mocking varies: `jest.mock`, manual mocks, MSW. Standardize on Testing Library + MSW, migrate Enzyme tests. + +DB migrations: early ones mix schema changes + data transforms. Slow, hard to rollback. Rule going forward: schema-only migrations, data transforms in separate scripts. + +Frontend build: migrated Webpack → Vite, fixed slow builds. Leftover Webpack configs and polyfills still present, need cleanup. \ No newline at end of file diff --git a/tests/caveman-compress/project-notes.original.md b/tests/caveman-compress/project-notes.original.md new file mode 100644 index 0000000..e4e0f82 --- /dev/null +++ b/tests/caveman-compress/project-notes.original.md @@ -0,0 +1,66 @@ +# Project Notes — Taskflow + +## Architecture Decision: Background Job Processing (March 2026) + +After extensive discussion, the team decided to adopt BullMQ for background job processing instead of building a custom solution or using AWS SQS. The primary reasons for this decision were: + +The team is already familiar with Redis, which is a requirement for BullMQ, and we're already running Redis for caching and session storage. Adding BullMQ doesn't introduce any new infrastructure dependencies. The alternative of using AWS SQS would have required significant changes to our local development setup and would have made it harder for contributors to run the full stack locally. + +BullMQ provides built-in support for job retries with exponential backoff, priority queues, rate limiting, and job scheduling — all features we would have had to build ourselves with a custom solution. The library is actively maintained and has good TypeScript support. + +The initial use cases for background jobs are: sending email notifications, processing file uploads (generating thumbnails, virus scanning), syncing data with third-party integrations (Slack, GitHub), and cleaning up expired sessions. We expect to add more job types as the application grows. + +One concern raised during the discussion was the operational complexity of monitoring background jobs. To address this, we added the BullMQ dashboard as an admin-only route at `/admin/jobs`. This provides visibility into queue health, failed jobs, and processing rates without requiring a separate monitoring tool. + +## Performance Investigation: Dashboard Slowness (March 2026) + +Sam spent a week investigating why the main dashboard becomes unusable for projects with more than 500 tasks. Here are the findings: + +The primary bottleneck is the database query that loads the task list. The current implementation fetches all tasks for a project in a single query, then for each task, makes a separate query to load the assignee's profile. This classic N+1 problem means that loading 500 tasks results in 501 database queries. With network latency to the database, this adds up to several seconds. + +The secondary issue is on the frontend. The task list component renders all tasks at once without any form of virtualization. React's reconciliation algorithm struggles with a DOM tree containing 500+ task cards, each with multiple child elements, tooltips, and dropdown menus. + +Proposed solutions: +1. Add a JOIN to the task query to load assignees in a single query instead of N+1 +2. Add a composite index on `tasks(project_id, status, updated_at)` for the default sort order +3. Implement cursor-based pagination on the API (load 50 tasks at a time) +4. Add virtual scrolling on the frontend using TanStack Virtual +5. Cache the dashboard response in Redis with a short TTL (30 seconds) and invalidate on task changes + +We decided to implement solutions 1, 2, and 3 first, as they address the root cause. Solutions 4 and 5 will be added later if the first three aren't sufficient. + +## Meeting Notes: Security Review (February 2026) + +The security audit conducted by an external firm identified several areas for improvement: + +The most critical finding was that our SQL queries in several older endpoints were using string interpolation instead of parameterized queries. This created SQL injection vulnerabilities in the task search endpoint and the user lookup by email endpoint. These have since been fixed by switching to Knex.js parameterized queries throughout the codebase. We also added an ESLint rule to flag raw string concatenation in query builder calls. + +The audit also found that our JWT tokens had an excessively long expiration time of 30 days. We reduced this to 15 minutes for access tokens and introduced a separate refresh token with a 7-day expiration. The refresh token is stored in an HttpOnly cookie and the access token is kept in memory only, never in localStorage. + +Another recommendation was to implement Content Security Policy headers, which we have added to the Next.js middleware. The CSP is currently in report-only mode while we verify that it doesn't break any legitimate functionality. We plan to switch to enforcement mode after two weeks of monitoring. + +Rate limiting was also flagged as missing from our public API endpoints. Alex implemented this using express-rate-limit with a Redis store, allowing rate limit state to be shared across multiple API server instances. + +## Design Decision: Component Library (January 2026) + +Maya led the evaluation of component libraries for the frontend redesign. The options considered were: + +1. **shadcn/ui with Radix primitives** — Not a traditional component library, but a collection of beautifully designed, accessible components that you copy into your project. Built on Radix UI primitives for accessibility, styled with Tailwind CSS. Pros: full ownership of the code, easy to customize, great accessibility. Cons: more initial setup, need to maintain the components ourselves. + +2. **Material UI (MUI)** — The most popular React component library. Comprehensive set of components with built-in theming. Pros: mature, extensive documentation, large community. Cons: large bundle size, opinionated design language that's hard to customize, vendor lock-in. + +3. **Chakra UI** — A component library focused on developer experience with a prop-based styling API. Pros: good DX, accessible by default. Cons: runtime CSS-in-JS has performance implications, smaller ecosystem than MUI. + +We chose option 1 (shadcn/ui with Radix) because it gives us the most control over our component code while still providing excellent accessibility through Radix primitives. The Tailwind CSS approach aligns with our existing styling strategy and keeps the bundle size minimal. The main tradeoff is that we need to maintain these components ourselves, but the team felt this was worthwhile for the level of customization we need. + +## Technical Debt Inventory (January 2026) + +A summary of the major technical debt items identified during our quarterly review: + +The authentication system was originally implemented in a rush for the initial launch and has accumulated significant complexity. The token refresh logic is spread across three different files with inconsistent error handling. The WebSocket authentication is handled separately from the HTTP authentication, leading to the reconnection race condition we're currently experiencing. This needs a comprehensive refactoring, but the team is hesitant to touch it until we have better test coverage on the auth flows. + +The test suite has grown organically and has several inconsistencies. Some tests use the old Enzyme library while newer tests use Testing Library. The mocking approach varies between test files — some use jest.mock, others use manual mocks, and a few use MSW for network mocking. We should standardize on Testing Library and MSW and gradually migrate the remaining Enzyme tests. + +The database migration history has some issues. Several early migrations contain both schema changes and data transformations, which makes them slow to run and difficult to rollback. Going forward, all migrations should contain only schema changes. Data transformations should be handled by separate scripts that can be run independently. + +The frontend build pipeline was recently migrated from Webpack to Vite, which resolved the slow build times. However, there are still some leftover Webpack-specific configurations and polyfills that should be cleaned up. diff --git a/tests/caveman-compress/todo-list.md b/tests/caveman-compress/todo-list.md new file mode 100644 index 0000000..0aa5f52 --- /dev/null +++ b/tests/caveman-compress/todo-list.md @@ -0,0 +1,31 @@ +# Sprint 24 — Task List + +## High Priority + +- [ ] **TF-456: Fix WebSocket reconnection race condition** — RT collab fail reconnect after network drop. WS reconnect race JWT refresh. Client reconnect w/ expired token before refresh done. Alex Chen. Due Apr 11. Blocks enterprise demo Apr 14. + +- [ ] **TF-489: Optimize dashboard query for large projects** — Dashboard 8s+ load when project >500 tasks. Missing composite index on `tasks(project_id, status, updated_at)` + N+1 query in task assignee resolution. Sam Rivera. Due Apr 9. + +- [ ] **TF-501: Implement chunked file upload** — Attachments >10MB timeout on slow connections. Refactor upload endpoint: multipart chunked uploads w/ resume. Frontend: show progress, allow cancel. Jordan Kim. Due Apr 15. + +## Medium Priority + +- [ ] **TF-478: Add Slack notification integration** — Notify Slack channel on task assign/status change. Webhook infra ready. Wire event handlers in task service + Slack msg formatting. Jordan Kim. Due Apr 18. + +- [ ] **TF-492: Fix timezone display for due dates** — Dates show UTC not user local tz. Fix API serialization (add tz to user profile response) + frontend date utils. `formatDate` in `src/lib/dates.ts` needs tz param. Maya Patel. Due Apr 16. + +- [ ] **TF-503: Add keyboard shortcuts for common actions** — Users want shortcuts: new task (Ctrl+N), search (Ctrl+K), view nav. Use centralized shortcut manager, not individual listeners. Consider `tinykeys` (700b gzipped). Maya Patel. Due Apr 20. + +## Low Priority + +- [ ] **TF-467: Update README with new architecture diagram** — Diagram outdated, missing background job system + Redis cache layer. Update before open-source community call Apr 25. Unassigned. + +- [ ] **TF-510: Investigate Playwright test flakiness** — E2E drag-and-drop reorder fails ~1/5 CI runs. Timing issue w/ animation completion detection. Not blocking, but hurts test confidence. Unassigned. + +- [ ] **TF-498: Clean up deprecated API endpoints** — v1 endpoints deprecated 3mo ago, safe to remove. Frontend on v2 exclusively. Remove: `GET /api/v1/tasks`, `POST /api/v1/tasks`, `PUT /api/v1/tasks/:id`. Unassigned. + +## Completed This Sprint + +- [x] **TF-445: Migrate from Webpack to Vite** — Maya, Apr 2. Build 45s→8s. HMR much faster. +- [x] **TF-451: Add rate limiting to public API endpoints** — Alex, Apr 3. `express-rate-limit` w/ Redis. 100 req/min authed, 20 unauthed. +- [x] **TF-460: Fix CORS configuration for staging environment** — Sam, Apr 1. Staging domain missing from allowed origins. \ No newline at end of file diff --git a/tests/caveman-compress/todo-list.original.md b/tests/caveman-compress/todo-list.original.md new file mode 100644 index 0000000..421cdd5 --- /dev/null +++ b/tests/caveman-compress/todo-list.original.md @@ -0,0 +1,31 @@ +# Sprint 24 — Task List + +## High Priority + +- [ ] **TF-456: Fix WebSocket reconnection race condition** — The real-time collaboration feature fails to reconnect after network interruption because the WebSocket reconnection logic races with the JWT refresh flow. The client tries to reconnect with an expired token before the refresh completes. Assigned to Alex Chen. Due by April 11, 2026. This is blocking the enterprise demo scheduled for April 14. + +- [ ] **TF-489: Optimize dashboard query for large projects** — The main dashboard takes over 8 seconds to load when a project has more than 500 tasks. Sam has identified that the issue is a missing composite index on `tasks(project_id, status, updated_at)` combined with an N+1 query in the task assignee resolution. Assigned to Sam Rivera. Due by April 9, 2026. + +- [ ] **TF-501: Implement chunked file upload** — Large attachments over 10MB timeout on slower connections. We need to refactor the upload endpoint to support multipart chunked uploads with resume capability. The frontend should show upload progress and allow cancellation. Assigned to Jordan Kim. Due by April 15, 2026. + +## Medium Priority + +- [ ] **TF-478: Add Slack notification integration** — When a task is assigned or its status changes, send a notification to the configured Slack channel. We've already set up the webhook infrastructure. Jordan needs to wire up the event handlers in the task service and add the Slack message formatting. Assigned to Jordan Kim. Due by April 18, 2026. + +- [ ] **TF-492: Fix timezone display for due dates** — Due dates are currently displayed in UTC instead of the user's local timezone. This requires changes in both the API response serialization (add timezone info to the user profile response) and the frontend date formatting utilities. There's a shared `formatDate` helper in `src/lib/dates.ts` that needs to accept a timezone parameter. Assigned to Maya Patel. Due by April 16, 2026. + +- [ ] **TF-503: Add keyboard shortcuts for common actions** — Users have requested keyboard shortcuts for creating new tasks (Ctrl+N), searching (Ctrl+K), and navigating between views. We should use a centralized keyboard shortcut manager rather than adding individual event listeners. Consider using the `tinykeys` library which is only 700 bytes gzipped. Assigned to Maya Patel. Due by April 20, 2026. + +## Low Priority + +- [ ] **TF-467: Update README with new architecture diagram** — The current architecture diagram in the README is outdated and doesn't reflect the recent addition of the background job processing system or the Redis caching layer. Should be updated before the next open-source community call on April 25, 2026. Unassigned. + +- [ ] **TF-510: Investigate Playwright test flakiness** — The E2E test for the drag-and-drop task reordering feature fails intermittently in CI (about 1 in 5 runs). It appears to be a timing issue with the animation completion detection. Not blocking anything currently but it's annoying and reduces confidence in the test suite. Unassigned. + +- [ ] **TF-498: Clean up deprecated API endpoints** — Several v1 API endpoints were deprecated three months ago and can now be safely removed. The frontend has been updated to use v2 endpoints exclusively. The old endpoints are: `GET /api/v1/tasks`, `POST /api/v1/tasks`, `PUT /api/v1/tasks/:id`. Unassigned. + +## Completed This Sprint + +- [x] **TF-445: Migrate from Webpack to Vite** — Completed by Maya on April 2. Build time reduced from 45 seconds to 8 seconds. Hot module replacement is significantly faster. +- [x] **TF-451: Add rate limiting to public API endpoints** — Completed by Alex on April 3. Using `express-rate-limit` with Redis store. Limits set to 100 requests per minute for authenticated users, 20 for unauthenticated. +- [x] **TF-460: Fix CORS configuration for staging environment** — Completed by Sam on April 1. The staging domain was missing from the allowed origins list. diff --git a/tests/installer/e2e.dryrun.test.mjs b/tests/installer/e2e.dryrun.test.mjs new file mode 100644 index 0000000..2784118 --- /dev/null +++ b/tests/installer/e2e.dryrun.test.mjs @@ -0,0 +1,59 @@ +// End-to-end: dry-run installer prints expected file plan without touching disk. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js'); + +function freshTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cm-dryrun-')); +} + +test('dry-run --only claude prints plan and writes nothing', () => { + const cfg = freshTmpDir(); + const r = spawnSync('node', [INSTALLER, + // --with-hooks: since #392/#393 the default only wires standalone hooks + // when the plugin install fails. Force the hook-planning path so the + // "would install / would merge" assertions below are exercised. + '--dry-run', '--only', 'claude', '--with-hooks', '--no-mcp-shrink', '--non-interactive', + '--config-dir', cfg, + ], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } }); + assert.equal(r.status, 0); + // Only fires if `claude` is on PATH on the test runner. If not, this assertion + // is a no-op (the installer just prints "nothing detected" and exits 0). + if (/Claude Code detected/.test(r.stdout)) { + assert.match(r.stdout, /would run: claude plugin marketplace add/); + assert.match(r.stdout, /would run: claude plugin install caveman@caveman/); + assert.match(r.stdout, /would mkdir -p .*\/hooks/); + assert.match(r.stdout, /would install .*caveman-activate\.js/); + assert.match(r.stdout, /would merge SessionStart \+ UserPromptSubmit \+ statusline/); + } + // Nothing should have been written. + assert.equal(fs.existsSync(path.join(cfg, 'settings.json')), false); + assert.equal(fs.existsSync(path.join(cfg, 'hooks')), false); +}); + +test('dry-run --uninstall does not delete files', () => { + const cfg = freshTmpDir(); + // Seed a fake installation + fs.mkdirSync(path.join(cfg, 'hooks'), { recursive: true }); + const fake = path.join(cfg, 'hooks', 'caveman-activate.js'); + fs.writeFileSync(fake, '// fake'); + fs.writeFileSync(path.join(cfg, 'settings.json'), + JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node ' + fake }] }] } }, null, 2)); + const before = fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'); + + const r = spawnSync('node', [INSTALLER, '--uninstall', '--dry-run', '--non-interactive', '--config-dir', cfg], + { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: cfg } }); + assert.equal(r.status, 0); + + // File still present, settings unchanged. + assert.equal(fs.existsSync(fake), true); + assert.equal(fs.readFileSync(path.join(cfg, 'settings.json'), 'utf8'), before); +}); diff --git a/tests/installer/e2e.freshinstall.test.mjs b/tests/installer/e2e.freshinstall.test.mjs new file mode 100644 index 0000000..515c6b6 --- /dev/null +++ b/tests/installer/e2e.freshinstall.test.mjs @@ -0,0 +1,556 @@ +// End-to-end: real fresh install against an isolated $CLAUDE_CONFIG_DIR. +// +// Unlike e2e.dryrun.test.mjs (which only verifies the planned output), this +// suite actually writes hooks, merges settings.json, and asserts the on-disk +// state. It catches regressions in the install pipeline that a dry-run can't +// see — missing hook files, malformed settings entries, broken statusline +// wiring, idempotency bugs, JSONC-tolerance regressions (#249-class). +// +// Limitations: +// - The Claude Code provider only triggers when `claude` is on PATH. Tests +// that depend on it skip cleanly when missing (most CI runners and dev +// boxes won't have it). Tests that don't need `claude` (idempotence, +// JSONC tolerance) always run. +// - The installer's uninstall path also calls `claude plugin uninstall` and +// `gemini extensions uninstall` against whatever binary is on PATH. We +// strip those out of PATH in the uninstall test so the user's real +// plugin/extension state is never touched. +// - The plugin install step makes a network call (clones the marketplace). +// We tolerate failure there — only the hook/settings assertions matter. +// Since #392/#393, default install wires standalone hooks ONLY when the +// plugin install fails (to avoid double-firing), so these tests pass +// --with-hooks to force the standalone wiring path deterministically. +// - Each fresh-install case spawns a real `claude plugin install` (~300MB +// of git clone). Run the test runner with `--test-concurrency=1` to +// avoid OOM on memory-constrained CI runners. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js'); +const requireCjs = createRequire(import.meta.url); +const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js')); + +function freshTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-freshinstall-')); +} + +function pathWithout(binNames) { + // Walk every PATH entry; drop any that contains one of the named binaries. + // Cross-platform: works on macOS/Linux (`:` sep) and Windows (`;` sep). + const sep = process.platform === 'win32' ? ';' : ':'; + const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + const want = new Set(binNames); + return (process.env.PATH || '') + .split(sep) + .filter(dir => { + if (!dir) return false; + for (const b of want) { + for (const ext of exts) { + try { if (fs.existsSync(path.join(dir, b + ext))) return false; } catch (_) {} + } + } + return true; + }) + .join(sep); +} + +function runInstaller(args, configDir, extraEnv = {}) { + return spawnSync('node', [INSTALLER, ...args, '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink'], { + env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1', ...extraEnv }, + encoding: 'utf8', + }); +} + +function hasClaudeCli() { + // We can't import bin/install.js's hasCmd directly (CJS, not exported), but + // a plain `command -v` / `where` shell-out is equivalent for this purpose. + if (process.platform === 'win32') { + return spawnSync('where', ['claude'], { stdio: 'ignore' }).status === 0; + } + return spawnSync('sh', ['-c', 'command -v claude'], { stdio: 'ignore' }).status === 0; +} + +const STATUSLINE_FILE = process.platform === 'win32' + ? 'caveman-statusline.ps1' + : 'caveman-statusline.sh'; + +function getStatuslineCommand(settings) { + if (!settings.statusLine) return ''; + return typeof settings.statusLine === 'string' + ? settings.statusLine + : (settings.statusLine.command || ''); +} + +function cavemanHookCommands(settings, event, marker) { + return (settings.hooks?.[event] || []) + .flatMap(e => (Array.isArray(e?.hooks) ? e.hooks : [])) + .filter(h => h && typeof h.command === 'string' && h.command.includes(marker)); +} + +// ── Test: fresh install populates expected files ─────────────────────────── +test('fresh install populates hooks dir and settings.json (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; the claude provider is the only path that wires hooks' }, () => { + const dir = freshTmpDir(); + try { + const r = runInstaller(['--only', 'claude', '--with-hooks'], dir); + // The plugin install step may fail (network, auth); --with-hooks forces + // the standalone hook wiring regardless. We only require the hooks-side state. + assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`); + + const hooks = path.join(dir, 'hooks'); + assert.ok(fs.existsSync(path.join(hooks, 'caveman-activate.js')), 'caveman-activate.js missing'); + assert.ok(fs.existsSync(path.join(hooks, 'caveman-mode-tracker.js')), 'caveman-mode-tracker.js missing'); + assert.ok(fs.existsSync(path.join(hooks, 'caveman-config.js')), 'caveman-config.js missing'); + assert.ok(fs.existsSync(path.join(hooks, 'package.json')), 'hooks/package.json (CJS marker) missing'); + assert.ok(fs.existsSync(path.join(hooks, STATUSLINE_FILE)), `${STATUSLINE_FILE} missing`); + + // Settings merged correctly. + const settingsPath = path.join(dir, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), 'settings.json missing'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + assert.ok(SETTINGS.hasCavemanHook(settings, 'SessionStart', 'caveman-activate'), + 'SessionStart hook missing or wrong marker'); + assert.ok(SETTINGS.hasCavemanHook(settings, 'UserPromptSubmit', 'caveman-mode-tracker'), + 'UserPromptSubmit hook missing or wrong marker'); + assert.ok(settings.statusLine, 'statusLine not set'); + assert.match(getStatuslineCommand(settings), /caveman-statusline/, + 'statusLine command does not reference caveman'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: idempotent install (run twice, no duplication) ─────────────────── +test('idempotent install does not duplicate hook entries (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => { + const dir = freshTmpDir(); + try { + const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir); + assert.notEqual(r1.status, 2, `first install argv error: ${r1.stderr}`); + const r2 = runInstaller(['--only', 'claude', '--with-hooks'], dir); + assert.notEqual(r2.status, 2, `second install argv error: ${r2.stderr}`); + + const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + + const sessStart = cavemanHookCommands(settings, 'SessionStart', 'caveman-activate'); + assert.equal(sessStart.length, 1, `expected 1 SessionStart caveman hook, got ${sessStart.length}`); + + const ups = cavemanHookCommands(settings, 'UserPromptSubmit', 'caveman-mode-tracker'); + assert.equal(ups.length, 1, `expected 1 UserPromptSubmit caveman hook, got ${ups.length}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: uninstall removes hooks, preserves unrelated entries ───────────── +test('uninstall strips caveman hooks but preserves user-authored ones (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; uninstall test depends on a prior real install' }, () => { + const dir = freshTmpDir(); + try { + // Seed user's existing settings so we can verify they survive. + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ + model: 'opus', + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-owned-hook' }] }], + }, + }, null, 2)); + + const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir); + assert.notEqual(r1.status, 2, `install argv error: ${r1.stderr}`); + + // Strip claude/gemini from PATH for uninstall so we don't touch the user's + // real plugin/extension state — only file/settings cleanup runs. + const cleanPath = pathWithout(['claude', 'gemini']); + const r2 = runInstaller(['--uninstall'], dir, { PATH: cleanPath }); + assert.notEqual(r2.status, 2, `uninstall argv error: ${r2.stderr}`); + + // Hook scripts deleted. + const hooks = path.join(dir, 'hooks'); + if (fs.existsSync(hooks)) { + for (const f of ['caveman-activate.js', 'caveman-mode-tracker.js', 'caveman-config.js', STATUSLINE_FILE]) { + assert.equal(fs.existsSync(path.join(hooks, f)), false, `${f} should be removed`); + } + } + + // Settings cleaned up. + const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + // No remaining caveman-marked hooks anywhere. + for (const ev of Object.keys(settings.hooks || {})) { + const arr = settings.hooks[ev] || []; + for (const e of arr) { + for (const h of (e.hooks || [])) { + assert.doesNotMatch(h.command || '', /caveman/, `${ev} still has caveman hook: ${h.command}`); + } + } + } + // User's pre-existing hook preserved. + const preservedUser = cavemanHookCommands(settings, 'SessionStart', 'user-owned-hook').length > 0; + assert.ok(preservedUser, 'user-authored SessionStart hook was wiped during uninstall'); + + // Statusline pointing at caveman should be removed. + assert.doesNotMatch(getStatuslineCommand(settings), /caveman-statusline/, + 'caveman statusline survived uninstall'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: settings.json with JSONC comments doesn't crash (#249) ─────────── +// Regression guard: the installer used to crash here because JSON.parse can't +// eat // or /* */. bin/lib/settings.js now strips them before merging. +test('install tolerates JSONC settings.json (comments + trailing commas)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => { + const dir = freshTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'settings.json'), + `// user wrote this by hand +{ + /* keep it simple */ + "model": "opus", + "hooks": {}, +} +`); + + const r = runInstaller(['--only', 'claude', '--with-hooks'], dir); + assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`); + + // After install, settings.json must be strict-JSON parseable. + const raw = fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'); + let parsed; + assert.doesNotThrow(() => { parsed = JSON.parse(raw); }, 'settings.json must round-trip as strict JSON'); + + // User's `model` key must survive the merge. + assert.equal(parsed.model, 'opus', 'user-authored model setting was dropped'); + + // Caveman hooks must be wired. + assert.ok(SETTINGS.hasCavemanHook(parsed, 'SessionStart', 'caveman-activate')); + assert.ok(SETTINGS.hasCavemanHook(parsed, 'UserPromptSubmit', 'caveman-mode-tracker')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Tests: OpenClaw workspace install (always run, no real OpenClaw needed) +// installOpenclaw writes plain files into a workspace dir we point at via +// OPENCLAW_WORKSPACE — no network, no external CLI, no plugin install. Safe +// to run on every CI box. + +const SKILL_BODY_SRC = path.join(REPO_ROOT, 'skills', 'caveman', 'SKILL.md'); + +test('openclaw install writes skill folder + SOUL.md bootstrap', () => { + const dir = freshTmpDir(); + const ws = path.join(dir, 'ws'); + fs.mkdirSync(ws); + try { + const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { + env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, + encoding: 'utf8', + }); + assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`); + + // 1. Skill body written with merged frontmatter. + const skillFile = path.join(ws, 'skills', 'caveman', 'SKILL.md'); + assert.ok(fs.existsSync(skillFile), 'skill SKILL.md missing'); + const skillRaw = fs.readFileSync(skillFile, 'utf8'); + assert.match(skillRaw, /^---\n/, 'skill missing frontmatter'); + assert.match(skillRaw, /\nversion:\s*\d+\.\d+\.\d+/, 'skill missing version frontmatter'); + assert.match(skillRaw, /\nalways:\s*true/, 'skill missing always: true frontmatter'); + + // Body after the merged frontmatter must match the source body. + const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); + const srcRaw = fs.readFileSync(SKILL_BODY_SRC, 'utf8'); + const srcBody = helper.splitFrontmatter(srcRaw).body; + const installedBody = helper.splitFrontmatter(skillRaw).body; + assert.equal(installedBody, srcBody, 'installed skill body diverged from source'); + + // 2. SOUL.md has marker block. + const soul = path.join(ws, 'SOUL.md'); + assert.ok(fs.existsSync(soul), 'SOUL.md missing'); + const soulRaw = fs.readFileSync(soul, 'utf8'); + assert.match(soulRaw, //, 'SOUL.md missing begin marker'); + assert.match(soulRaw, //, 'SOUL.md missing end marker'); + assert.match(soulRaw, /Respond terse like smart caveman/, 'SOUL.md missing sentinel'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw install is idempotent: skill frontmatter not double-prepended, SOUL.md has one marker block', () => { + const dir = freshTmpDir(); + const ws = path.join(dir, 'ws'); + fs.mkdirSync(ws); + try { + const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }; + const args = ['--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir]; + spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' }); + spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' }); + + const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8'); + // version key should appear exactly once (idempotent merge). + const versionMatches = skillRaw.match(/^version:/gm) || []; + assert.equal(versionMatches.length, 1, `expected 1 version key after re-run, got ${versionMatches.length}`); + const alwaysMatches = skillRaw.match(/^always:/gm) || []; + assert.equal(alwaysMatches.length, 1, `expected 1 always key after re-run, got ${alwaysMatches.length}`); + + const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); + const beginMatches = soulRaw.match(//g) || []; + assert.equal(beginMatches.length, 1, `expected 1 marker block after re-run, got ${beginMatches.length}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw install preserves user content in SOUL.md (append, not overwrite)', () => { + const dir = freshTmpDir(); + const ws = path.join(dir, 'ws'); + fs.mkdirSync(ws); + const userContent = '# my workspace\n\nfoo bar baz\n'; + fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent); + try { + spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { + env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, + encoding: 'utf8', + }); + const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); + assert.match(soulRaw, /# my workspace/, 'user heading wiped during install'); + assert.match(soulRaw, /foo bar baz/, 'user content wiped during install'); + assert.match(soulRaw, //, 'caveman block not appended'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw uninstall removes skill folder + strips SOUL.md block, preserving user content', () => { + const dir = freshTmpDir(); + const ws = path.join(dir, 'ws'); + fs.mkdirSync(ws); + const userContent = '# my workspace\n\nfoo bar baz\n'; + fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent); + try { + const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }; + spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env, encoding: 'utf8' }); + + // Strip claude/gemini from PATH so uninstall doesn't touch real plugins. + const cleanPath = pathWithout(['claude', 'gemini']); + const r = spawnSync('node', [INSTALLER, '--uninstall', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { + env: { ...env, PATH: cleanPath }, + encoding: 'utf8', + }); + assert.notEqual(r.status, 2, `uninstall argv error: ${r.stderr}`); + + assert.equal(fs.existsSync(path.join(ws, 'skills', 'caveman')), false, 'skill folder should be removed'); + const soulAfter = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); + assert.doesNotMatch(soulAfter, //, 'caveman block survived uninstall'); + assert.doesNotMatch(soulAfter, //, 'caveman end marker survived uninstall'); + assert.match(soulAfter, /# my workspace/, 'user heading wiped during uninstall'); + assert.match(soulAfter, /foo bar baz/, 'user content wiped during uninstall'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('caveman-init.js --only openclaw routes through the same helper', () => { + const dir = freshTmpDir(); + const ws = path.join(dir, 'ws'); + fs.mkdirSync(ws); + try { + const initScript = path.join(REPO_ROOT, 'src', 'tools', 'caveman-init.js'); + const r = spawnSync('node', [initScript, dir, '--only', 'openclaw'], { + env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' }, + encoding: 'utf8', + }); + assert.equal(r.status, 0, `caveman-init failed: ${r.stderr || r.stdout}`); + assert.ok(fs.existsSync(path.join(ws, 'skills', 'caveman', 'SKILL.md')), 'skill missing via init route'); + assert.ok(fs.existsSync(path.join(ws, 'SOUL.md')), 'SOUL.md missing via init route'); + const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8'); + assert.match(soulRaw, /Respond terse like smart caveman/, 'sentinel missing via init route'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: idempotent re-add at the lib level (always runs, no claude needed) +// This guards the addCommandHook idempotency promise without spawning a real +// install — even on machines with no `claude` CLI we want this assertion. +test('lib settings.addCommandHook is idempotent across two synthetic install passes', () => { + const dir = freshTmpDir(); + const settingsPath = path.join(dir, 'settings.json'); + try { + const settings = SETTINGS.readSettings(settingsPath); + SETTINGS.addCommandHook(settings, 'SessionStart', { + command: '"/usr/bin/node" "/abs/hooks/caveman-activate.js"', + marker: 'caveman-activate', + }); + SETTINGS.addCommandHook(settings, 'SessionStart', { + command: '"/usr/bin/node" "/different/hooks/caveman-activate.js"', + marker: 'caveman-activate', + }); + SETTINGS.validateHookFields(settings); + SETTINGS.writeSettings(settingsPath, settings); + + const round = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.equal(round.hooks.SessionStart.length, 1, 'addCommandHook duplicated entry'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: --force migrates a mixed legacy AGENTS.md instead of wiping it (#594) +// The old code replaced the whole file with the fenced block whenever the +// legacy un-fenced sentinel was present — and the installer's own hint told +// users with mixed files to run exactly that. User content must survive. +test('opencode: --force on legacy AGENTS.md preserves user content and takes a backup', () => { + const dir = freshTmpDir(); + const xdg = path.join(dir, 'xdg'); + const ocDir = path.join(xdg, 'opencode'); + fs.mkdirSync(ocDir, { recursive: true }); + const agentsMd = path.join(ocDir, 'AGENTS.md'); + const legacyBody = fs.readFileSync( + path.join(REPO_ROOT, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n'; + const userRules = '# My precious user rules\n\nAlways use tabs.\n'; + fs.writeFileSync(agentsMd, userRules + '\n' + legacyBody); + try { + const r = spawnSync('node', [INSTALLER, '--only', 'opencode', '--force', '--non-interactive', '--no-mcp-shrink', '--config-dir', path.join(dir, 'claude')], { + env: { ...process.env, XDG_CONFIG_HOME: xdg, NO_COLOR: '1' }, + encoding: 'utf8', + }); + assert.notEqual(r.status, 2, `installer argv error: ${r.stderr}`); + + const after = fs.readFileSync(agentsMd, 'utf8'); + assert.match(after, /My precious user rules/, 'user heading wiped by --force migration'); + assert.match(after, /Always use tabs\./, 'user rule wiped by --force migration'); + assert.match(after, //, 'fenced block missing after migration'); + assert.match(after, //, 'fence end missing after migration'); + // Legacy un-fenced copy must be gone: sentinel appears only inside the fence. + const beforeFence = after.slice(0, after.indexOf('')); + assert.doesNotMatch(beforeFence, /Respond terse like smart caveman/, + 'legacy un-fenced block still present above the fence'); + assert.ok(fs.existsSync(agentsMd + '.bak'), 'backup missing after --force migration'); + assert.match(fs.readFileSync(agentsMd + '.bak', 'utf8'), /My precious user rules/, + 'backup does not contain the original content'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Tests: SOUL.md marker damage tolerance (#596) ────────────────────────── +// A stray/truncated marker used to chain into data loss: append added a +// second block, then strip cut from the FIRST begin to the FIRST end — +// spanning all user content in between. These drive the helper directly. +test('openclaw: truncated begin marker does not eat user content (issue #596 chain)', () => { + const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); + const dir = freshTmpDir(); + const soul = path.join(dir, 'SOUL.md'); + try { + // Begin marker with no end (interrupted write), then user content. + fs.writeFileSync(soul, helper.MARK_BEGIN + '\n\nUSER IMPORTANT CONTENT\n'); + const snippet = helper.loadBootstrapSnippet(REPO_ROOT); + + const a = helper.appendBootstrapToSoul(soul, snippet); + assert.equal(a.changed, true); + const afterAppend = fs.readFileSync(soul, 'utf8'); + assert.match(afterAppend, /USER IMPORTANT CONTENT/, 'user content lost during repair-append'); + assert.equal(afterAppend.split(helper.MARK_BEGIN).length - 1, 1, 'repair must leave exactly one begin marker'); + + const s = helper.stripBootstrapFromSoul(soul); + assert.equal(s.changed, true); + assert.equal(s.removed, undefined, 'file with user content must not be deleted'); + const afterStrip = fs.readFileSync(soul, 'utf8'); + assert.match(afterStrip, /USER IMPORTANT CONTENT/, 'user content deleted by strip — the #596 data loss'); + assert.doesNotMatch(afterStrip, /caveman-begin/, 'marker survived strip'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw: strip removes multiple blocks pairwise, keeping user content between them', () => { + const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); + const dir = freshTmpDir(); + const soul = path.join(dir, 'SOUL.md'); + try { + const block = helper.MARK_BEGIN + '\nrules v1\n' + helper.MARK_END; + fs.writeFileSync(soul, block + '\n\nUSER KEEP ME\n\n' + block + '\n'); + const s = helper.stripBootstrapFromSoul(soul); + assert.equal(s.changed, true); + const after = fs.readFileSync(soul, 'utf8'); + assert.match(after, /USER KEEP ME/, 'user content between blocks deleted'); + assert.doesNotMatch(after, /caveman-(begin|end)/, 'markers survived'); + assert.doesNotMatch(after, /rules v1/, 'block bodies survived'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw: orphan end marker stripped without touching content', () => { + const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); + const dir = freshTmpDir(); + const soul = path.join(dir, 'SOUL.md'); + try { + fs.writeFileSync(soul, 'before\n' + helper.MARK_END + '\nafter\n'); + const s = helper.stripBootstrapFromSoul(soul); + assert.equal(s.changed, true); + const after = fs.readFileSync(soul, 'utf8'); + assert.match(after, /before/); + assert.match(after, /after/); + assert.doesNotMatch(after, /caveman-end/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('openclaw: append on a well-formed block stays a no-op', () => { + const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js')); + const dir = freshTmpDir(); + const soul = path.join(dir, 'SOUL.md'); + try { + const snippet = helper.loadBootstrapSnippet(REPO_ROOT); + helper.appendBootstrapToSoul(soul, snippet); + const first = fs.readFileSync(soul, 'utf8'); + const again = helper.appendBootstrapToSoul(soul, snippet); + assert.equal(again.changed, false); + assert.equal(fs.readFileSync(soul, 'utf8'), first, 'no-op append must not modify the file'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Test: missing `claude` CLI must be a FAILURE, not silent success (#592) +// spawnSync reports ENOENT as { status: null, error }; the old +// `(r.status || 0) === 0` coerced that to success, so the installer printed +// "installed: claude", skipped the standalone-hook fallback, and left the +// machine with nothing installed. Always runs: an empty PATH guarantees the +// claude lookup fails even on machines that do have the CLI. +test('missing claude CLI: reports failure and falls back to standalone hook wiring', () => { + const dir = freshTmpDir(); + const emptyBin = path.join(dir, 'empty-bin'); + fs.mkdirSync(emptyBin); + const configDir = path.join(dir, 'claude-config'); + try { + // process.execPath instead of 'node': the stripped PATH must not break + // the test's own ability to launch the installer. + const r = spawnSync(process.execPath, [ + INSTALLER, '--only', 'claude', '--skip-skills', + '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink', + ], { + env: { ...process.env, PATH: emptyBin, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1' }, + encoding: 'utf8', + }); + const out = (r.stdout || '') + (r.stderr || ''); + assert.match(out, /plugin install did not succeed; falling back to standalone wiring/, + `fallback to standalone hooks did not trigger:\n${out}`); + assert.match(out, /claude plugin install failed/, 'claude was not reported as failed'); + assert.ok(!/• claude\n/.test(out), 'claude must not be listed as installed'); + assert.ok(fs.existsSync(path.join(configDir, 'hooks', 'caveman-activate.js')), + 'standalone hooks were not written'); + const settings = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8')); + assert.ok(settings.hooks && settings.hooks.SessionStart, 'SessionStart hook not wired'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/installer/hermes.test.mjs b/tests/installer/hermes.test.mjs new file mode 100644 index 0000000..88005a8 --- /dev/null +++ b/tests/installer/hermes.test.mjs @@ -0,0 +1,97 @@ +// Hermes Agent native install — fresh install lands skills, uninstall removes them. +// +// Hermes loads skills from /skills///SKILL.md +// (verified against a live `hermes skills list`). The installer copies the 7 +// caveman skill dirs into the `productivity/` category. `--only hermes` makes +// the provider explicit, so no `hermes` binary needs to be on PATH for the +// dispatch to run — we drive it purely through a throwaway HERMES_HOME. +// +// The uninstall test is the important one: PR #524 shipped installHermes with +// NO matching uninstall block, so `--uninstall` silently orphaned all 7 skill +// folders forever. This pins the symmetry so it cannot regress. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js'); + +const SKILLS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']; + +function freshHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-hermes-')); +} + +function runInstaller(args, hermesHome) { + return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], { + env: { ...process.env, HERMES_HOME: hermesHome, NO_COLOR: '1' }, + encoding: 'utf8', + }); +} + +function productivityDir(hermesHome) { + return path.join(hermesHome, 'skills', 'productivity'); +} + +// ── 1. Fresh install drops all 7 skills with SKILL.md in the productivity category ── +test('hermes fresh install lands 7 skill dirs with SKILL.md under skills/productivity/', () => { + const home = freshHome(); + try { + const r = runInstaller(['--only', 'hermes'], home); + assert.notEqual(r.status, 2, `argv error: ${r.stderr}`); + + const prod = productivityDir(home); + for (const name of SKILLS) { + assert.ok(fs.existsSync(path.join(prod, name, 'SKILL.md')), `skill ${name}/SKILL.md missing`); + } + // caveman-compress ships executable scripts — ensure the recursive copy kept them. + assert.ok(fs.existsSync(path.join(prod, 'caveman-compress', 'scripts')), 'caveman-compress/scripts/ not copied'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// ── 2. Uninstall removes every skill we installed (regression guard for #524) ── +test('hermes uninstall removes all installed caveman skills (no orphans)', () => { + const home = freshHome(); + try { + const r1 = runInstaller(['--only', 'hermes'], home); + assert.notEqual(r1.status, 2); + const prod = productivityDir(home); + for (const name of SKILLS) { + assert.ok(fs.existsSync(path.join(prod, name)), `precondition: ${name} should be installed`); + } + + const r2 = runInstaller(['--uninstall'], home); + assert.notEqual(r2.status, 2); + + for (const name of SKILLS) { + assert.equal(fs.existsSync(path.join(prod, name)), false, `${name} survived uninstall (orphaned skill)`); + } + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// ── 3. Dry-run uninstall must NOT delete anything ── +test('hermes dry-run uninstall leaves skills in place', () => { + const home = freshHome(); + try { + runInstaller(['--only', 'hermes'], home); + const r = runInstaller(['--uninstall', '--dry-run'], home); + assert.notEqual(r.status, 2); + + const prod = productivityDir(home); + for (const name of SKILLS) { + assert.ok(fs.existsSync(path.join(prod, name)), `${name} was deleted by a dry-run uninstall`); + } + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/installer/opencode-agent.test.mjs b/tests/installer/opencode-agent.test.mjs new file mode 100644 index 0000000..2215dc0 --- /dev/null +++ b/tests/installer/opencode-agent.test.mjs @@ -0,0 +1,168 @@ +// Subagent frontmatter sanitizer for opencode (issue #386). +// +// opencode rejects the YAML array form `tools: [Read, Grep, Bash]` that +// Claude Code accepts. Copying agents/cavecrew-*.md verbatim into +// ~/.config/opencode/agents/ broke opencode startup with: +// Configuration is invalid at .../cavecrew-reviewer.md +// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools +// +// Fix: strip the `tools:` field on copy. These tests prove the helper +// strips the field, preserves every other frontmatter key and the body, +// and handles both the inline array form and the multi-line YAML list form. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const requireCjs = createRequire(import.meta.url); +const { stripOpencodeAgentTools } = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'opencode-agent.js')); + +const SHIPPED_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']; + +function frontmatter(content) { + const m = content.match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(m, 'frontmatter present'); + return m[1]; +} + +// ── Inline array form (the exact bug reported in issue 386) ────────────── +test('strips inline `tools: [...]` array from frontmatter', () => { + const src = `--- +name: test-agent +description: short description +tools: [Read, Grep, Bash] +model: haiku +--- +body line one +body line two +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent'); + assert.match(fm, /^name: test-agent$/m, '`name` preserved'); + assert.match(fm, /^description: short description$/m, '`description` preserved'); + assert.match(fm, /^model: haiku$/m, '`model` preserved'); + assert.match(out, /^body line one$/m, 'body preserved'); + assert.match(out, /^body line two$/m, 'body preserved'); +}); + +// ── Multi-line YAML list form (defensive — future-proof for refactors) ─── +test('strips multi-line `tools:` list with indented continuation', () => { + const src = `--- +name: test-agent +tools: + - Read + - Grep + - Bash +model: haiku +--- +body +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent'); + assert.doesNotMatch(fm, /^\s+- Read$/m, '`tools` list items must be absent'); + assert.match(fm, /^name: test-agent$/m, '`name` preserved'); + assert.match(fm, /^model: haiku$/m, '`model` preserved'); +}); + +// ── Folded `description: >` block must NOT be eaten ────────────────────── +test('preserves folded `description: >` continuation lines when `tools:` follows', () => { + const src = `--- +name: cavecrew-reviewer +description: > + Diff/branch/file reviewer. One line per finding, severity-tagged, no praise, + no scope creep. Output format \`path:line: : . .\` +tools: [Read, Grep, Bash] +model: haiku +--- +body +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m); + assert.match(fm, /^description: >$/m, 'folded scalar header preserved'); + assert.match(fm, /Diff\/branch\/file reviewer/, 'folded scalar body preserved'); + assert.match(fm, /no scope creep/, 'second folded line preserved'); + assert.match(fm, /^model: haiku$/m); +}); + +// ── No frontmatter: pass content through untouched ─────────────────────── +test('returns input unchanged when no frontmatter fence', () => { + const src = 'just body, no frontmatter\ntools: [Read]\n'; + assert.equal(stripOpencodeAgentTools(src), src); +}); + +// ── No `tools:` field: pass content through untouched ──────────────────── +test('returns input unchanged when frontmatter has no `tools:` field', () => { + const src = `--- +name: x +model: haiku +--- +body +`; + assert.equal(stripOpencodeAgentTools(src), src); +}); + +// ── Non-string input: pass through (defensive) ─────────────────────────── +test('non-string input returns unchanged', () => { + assert.equal(stripOpencodeAgentTools(null), null); + assert.equal(stripOpencodeAgentTools(undefined), undefined); + assert.deepEqual(stripOpencodeAgentTools({ x: 1 }), { x: 1 }); +}); + +// ── Real shipped agent files: every one must transform to opencode-safe ── +// This is the RED-state proof: each `agents/cavecrew-*.md` in the repo today +// contains the offending `tools: [...]` form, which is what broke opencode +// startup in the reported bug. After transform, the field is gone. +test('all shipped cavecrew agent files contain offending tools array (RED proof)', () => { + for (const f of SHIPPED_AGENT_FILES) { + const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8'); + const fm = frontmatter(src); + assert.match(fm, /^tools:\s*\[/m, `source ${f} should contain inline array form (this is the bug)`); + } +}); + +test('all shipped cavecrew agent files become opencode-safe after transform (GREEN proof)', () => { + for (const f of SHIPPED_AGENT_FILES) { + const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8'); + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + + assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field still present after transform`); + assert.match(fm, /^name: cavecrew-/m, `${f}: name field preserved`); + assert.match(fm, /^description:/m, `${f}: description field preserved`); + + const bodyOut = out.replace(/^---\n[\s\S]*?\n---\n/, ''); + const bodyIn = src.replace(/^---\n[\s\S]*?\n---\n/, ''); + assert.equal(bodyOut, bodyIn, `${f}: body must be byte-identical`); + } +}); + +// ── End-to-end: installer's agent-copy step writes a sanitized file ────── +// We re-enact section 3 of installOpencode() directly to avoid coupling to +// the rest of the install pipeline (which depends on optional files outside +// this fix's scope). The assertion is the same one opencode applies on +// startup: `tools` must be absent (or an object), never an array. +test('installer-equivalent copy writes opencode-safe agent file (issue 386 end-to-end)', () => { + const tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, 'tests', '.tmp-opencode-agent-')); + try { + for (const f of SHIPPED_AGENT_FILES) { + const src = path.join(REPO_ROOT, 'agents', f); + const dest = path.join(tmpDir, f); + fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8'))); + + const installed = fs.readFileSync(dest, 'utf8'); + const fm = frontmatter(installed); + assert.doesNotMatch(fm, /^tools:\s*\[/m, `${f}: array form survived in installed file`); + assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field survived in installed file`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/installer/opencode.test.mjs b/tests/installer/opencode.test.mjs new file mode 100644 index 0000000..e808b4b --- /dev/null +++ b/tests/installer/opencode.test.mjs @@ -0,0 +1,338 @@ +// opencode native install — fresh install, idempotency, uninstall, plugin smoke. +// +// Detection of opencode is gated behind `command -v opencode`, so to run on a +// CI box without opencode installed we prepend a tmpdir with a no-op `opencode` +// shim to PATH. The installer's per-provider dispatch only checks PATH; it +// never invokes the binary itself. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js'); +const requireCjs = createRequire(import.meta.url); +const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js')); + +const IS_WIN = process.platform === 'win32'; + +function freshTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-opencode-')); +} + +// Make a throwaway `opencode` binary on PATH so detectMatch('command:opencode') +// returns true. The shim never executes — installer only checks PATH presence. +function shimOpencode() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-shim-')); + if (IS_WIN) { + fs.writeFileSync(path.join(dir, 'opencode.cmd'), '@echo off\r\n'); + } else { + const f = path.join(dir, 'opencode'); + fs.writeFileSync(f, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(f, 0o755); + } + return dir; +} + +function runInstaller(args, env) { + return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], { + env, encoding: 'utf8', + }); +} + +function pathWith(prependDir) { + const sep = IS_WIN ? ';' : ':'; + return prependDir + sep + (process.env.PATH || ''); +} + +// ── 1. Fresh install populates expected files ──────────────────────────── +test('opencode fresh install drops plugin, commands, agents, skills, AGENTS.md, opencode.json', () => { + const xdg = freshTmpDir(); + const shimDir = shimOpencode(); + try { + const r = runInstaller(['--only', 'opencode'], { + ...process.env, + XDG_CONFIG_HOME: xdg, + PATH: pathWith(shimDir), + NO_COLOR: '1', + }); + assert.notEqual(r.status, 2, `argv error: ${r.stderr}`); + + const ocDir = path.join(xdg, 'opencode'); + assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'plugin.js')), 'plugin.js missing'); + assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'package.json')), 'plugin package.json missing'); + assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'caveman-config.cjs')), 'caveman-config.cjs sibling missing'); + + for (const f of ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md']) { + assert.ok(fs.existsSync(path.join(ocDir, 'commands', f)), `command ${f} missing`); + } + for (const f of ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']) { + assert.ok(fs.existsSync(path.join(ocDir, 'agents', f)), `agent ${f} missing`); + } + for (const name of ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']) { + 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'); + 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'); + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + assert.ok(Array.isArray(cfg.plugin), 'opencode.json missing plugin array'); + assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing'); + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + } +}); + +// ── 2. Idempotency: install twice, plugin array stays length 1 ─────────── +test('opencode idempotent install does not duplicate plugin entries', () => { + 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 r2 = runInstaller(['--only', 'opencode'], env); + assert.notEqual(r2.status, 2); + + const cfg = JSON.parse(fs.readFileSync(path.join(xdg, 'opencode', 'opencode.json'), 'utf8')); + const matches = cfg.plugin.filter(p => p === './plugins/caveman/plugin.js'); + assert.equal(matches.length, 1, `expected 1 plugin entry, got ${matches.length}`); + + // AGENTS.md should not have the ruleset duplicated either. + const agentsMd = fs.readFileSync(path.join(xdg, 'opencode', 'AGENTS.md'), 'utf8'); + const sentinelCount = (agentsMd.match(/Respond terse like smart caveman/g) || []).length; + assert.equal(sentinelCount, 1, `expected 1 sentinel, got ${sentinelCount}`); + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + } +}); + +// ── 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(); + const shimDir = shimOpencode(); + try { + const ocDir = path.join(xdg, 'opencode'); + fs.mkdirSync(ocDir, { recursive: true }); + fs.writeFileSync(path.join(ocDir, 'opencode.json'), + `// hand-written +{ + /* user prefs */ + "model": "anthropic/claude-sonnet-4-5", + "theme": "dark", +} +`); + + const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' }; + const r = runInstaller(['--only', 'opencode'], env); + assert.notEqual(r.status, 2); + + const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8')); + assert.equal(cfg.model, 'anthropic/claude-sonnet-4-5', 'user model setting wiped'); + assert.equal(cfg.theme, 'dark', 'user theme setting wiped'); + assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing'); + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + } +}); + +// ── 4. Uninstall removes opencode artifacts and prunes config ──────────── +test('opencode uninstall removes plugin dir, command/agent/skill files, prunes opencode.json', () => { + 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 r2 = runInstaller(['--uninstall'], env); + assert.notEqual(r2.status, 2); + + const ocDir = path.join(xdg, 'opencode'); + assert.equal(fs.existsSync(path.join(ocDir, 'plugins', 'caveman')), false, 'plugin dir survived'); + assert.equal(fs.existsSync(path.join(ocDir, 'commands', 'caveman.md')), false, 'caveman.md command survived'); + assert.equal(fs.existsSync(path.join(ocDir, 'agents', 'cavecrew-builder.md')), false, 'cavecrew agent survived'); + assert.equal(fs.existsSync(path.join(ocDir, 'skills', 'caveman')), false, 'caveman skill dir survived'); + assert.equal(fs.existsSync(path.join(ocDir, 'AGENTS.md')), false, 'AGENTS.md (we wrote it) survived'); + + if (fs.existsSync(path.join(ocDir, 'opencode.json'))) { + const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8')); + const stillHasPlugin = Array.isArray(cfg.plugin) && cfg.plugin.includes('./plugins/caveman/plugin.js'); + assert.equal(stillHasPlugin, false, 'plugin entry survived in opencode.json'); + } + } finally { + fs.rmSync(xdg, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + } +}); + +// ── 5. Plugin smoke: load installed plugin.js, fire the real opencode hooks ── +// opencode (>= 1.15) has no `tui.prompt.append` or top-level `session.created` +// plugin-hook keys (#418/#421). The plugin now uses `chat.message` for mode +// parsing, `experimental.chat.system.transform` for reinforcement, and the +// `event` dispatcher (filtering event.type === 'session.created') for session +// init. This test drives those real hooks. +test('opencode plugin handles /caveman ultra, stop caveman, and session init via real hooks', async () => { + const xdg = freshTmpDir(); + const shimDir = shimOpencode(); + const origDefault = process.env.CAVEMAN_DEFAULT_MODE; + try { + const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' }; + const r = runInstaller(['--only', 'opencode'], env); + assert.notEqual(r.status, 2); + + const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js'); + const flagPath = path.join(xdg, 'opencode', '.caveman-active'); + + // Set XDG_CONFIG_HOME so the plugin's flagPath resolves to our temp dir, + // and pin the default mode so session-init is deterministic regardless of + // any ambient user/repo-local caveman config. + process.env.XDG_CONFIG_HOME = xdg; + process.env.CAVEMAN_DEFAULT_MODE = 'full'; + + const mod = await import(pathToFileURL(pluginPath).href); + const factory = mod.default || mod.CavemanPlugin; + const handlers = await factory({}); + + // The dead direct-key hooks must NOT be registered. + assert.equal(handlers['tui.prompt.append'], undefined, 'tui.prompt.append should not exist'); + assert.equal(handlers['session.created'], undefined, 'session.created direct key should not exist'); + assert.equal(typeof handlers.event, 'function', 'event dispatcher should be a function'); + assert.equal(typeof handlers['chat.message'], 'function', 'chat.message should be a function'); + assert.equal(typeof handlers['experimental.chat.system.transform'], 'function', + 'system.transform should be a function'); + + // Slash command in a chat.message text part activates ultra. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra'); + + // opencode expands "/caveman " into the command template before + // chat.message fires — the level must be recovered from the expanded text. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: + 'Activate caveman mode: wenyan-lite\n\nIf no level given, use full. If "off", deactivate.' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'wenyan-lite'); + await handlers['chat.message']({}, { parts: [{ type: 'text', text: + 'Activate caveman mode: off\n\nIf no level given, use full. If "off", deactivate.' }] }); + assert.equal(fs.existsSync(flagPath), false, 'expanded template with off should delete the flag'); + await handlers['chat.message']({}, { parts: [{ type: 'text', text: + 'Activate caveman mode: \n\nIf no level given, use full. If "off", deactivate.' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full', 'expanded template without level uses default'); + await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra'); + + // opencode's non-interactive `run` path wraps the message in literal + // quotes ("/caveman lite"\n) — the parser must unwrap them. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: '"/caveman lite"\n' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'lite'); + await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra'); + + // system.transform injects the reinforcement line while active. + const sys1 = { system: [] }; + await handlers['experimental.chat.system.transform']({}, sys1); + assert.equal(sys1.system.length, 1, 'expected one reinforcement line'); + assert.match(sys1.system[0], /CAVEMAN MODE ACTIVE \(ultra\)/); + + // Natural-language deactivation removes the flag. + await handlers['chat.message']({}, { parts: [{ type: 'text', text: 'stop caveman please' }] }); + assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation'); + + // No reinforcement injected when inactive. + const sys2 = { system: [] }; + await handlers['experimental.chat.system.transform']({}, sys2); + assert.equal(sys2.system.length, 0, 'no reinforcement when flag absent'); + + // The `event` dispatcher writes the default mode on session.created, and + // ignores unrelated event types. + await handlers.event({ event: { type: 'session.idle' } }); + assert.equal(fs.existsSync(flagPath), false, 'non-session.created event must not write the flag'); + await handlers.event({ event: { type: 'session.created' } }); + assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full'); + } finally { + if (origDefault === undefined) delete process.env.CAVEMAN_DEFAULT_MODE; + else process.env.CAVEMAN_DEFAULT_MODE = origDefault; + fs.rmSync(xdg, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + } +}); diff --git a/tests/installer/ps1-pipe.test.mjs b/tests/installer/ps1-pipe.test.mjs new file mode 100644 index 0000000..b6511b3 --- /dev/null +++ b/tests/installer/ps1-pipe.test.mjs @@ -0,0 +1,58 @@ +// Regression for #565: `irm .../install.ps1 | iex` crashed with +// "Cannot bind argument to parameter 'Path' because it is null." +// +// Two pipe-execution rules for install.ps1 (static checks — CI has no pwsh): +// 1. No top-level param() block. iex executes the file as a string, so a +// top-level param can never receive arguments and (depending on host) +// trips parsing. All logic lives in a function invoked at the bottom. +// 2. Script-path variables ($PSCommandPath / $MyInvocation.MyCommand.Path) +// are $null under iex — any use must be guarded, never passed straight +// into Split-Path (that was the #565 crash). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const PS1 = fs.readFileSync(path.join(REPO_ROOT, 'install.ps1'), 'utf8'); + +// Strip comment lines so doc mentions of param()/path vars don't false-positive. +const code = PS1.split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); + +test('#565 install.ps1 has no top-level param block (everything inside a function)', () => { + const beforeFunction = code.slice(0, code.indexOf('function ')); + assert.ok(code.includes('function '), 'install.ps1 must wrap its logic in a function for iex piping'); + assert.ok( + !/param\s*\(/i.test(beforeFunction), + 'install.ps1 must not declare a top-level param() — it cannot receive args under `irm | iex` (issue #565)', + ); +}); + +test('#565 install.ps1 never uses $MyInvocation.MyCommand.Path (null under iex)', () => { + assert.ok( + !/\$MyInvocation\.MyCommand\.Path/i.test(code), + 'install.ps1 must not rely on $MyInvocation.MyCommand.Path — it is $null when piped to iex (issue #565)', + ); +}); + +test('#565 install.ps1 guards $PSCommandPath before Split-Path', () => { + if (/\$PSCommandPath/i.test(code)) { + assert.match( + code, + /if\s*\(\s*\$PSCommandPath\s*\)/i, + '$PSCommandPath is $null under `irm | iex` — it must be truthiness-guarded before use (issue #565)', + ); + } +}); + +test('#565 install.ps1 invokes its function at the bottom (script still does something)', () => { + const lastLines = code.trim().split('\n').slice(-3).join('\n'); + assert.match( + lastLines, + /Install-Caveman/, + 'install.ps1 must actually invoke Install-Caveman after defining it', + ); +}); diff --git a/tests/installer/slash-commands.test.mjs b/tests/installer/slash-commands.test.mjs new file mode 100644 index 0000000..0715d4d --- /dev/null +++ b/tests/installer/slash-commands.test.mjs @@ -0,0 +1,129 @@ +// Regression for #470 + #571: /caveman-* reports 'Unknown command' in Claude Code. +// +// Claude Code resolves a slash command by scanning commands/*.md (YAML +// frontmatter) BEFORE the UserPromptSubmit hook ever sees the prompt — it +// ignores commands/*.toml entirely (#571; TOML is the Gemini extension +// format). With no commands/.md on disk, the chat input is rejected as +// "Unknown command" — the mode tracker's handlers in +// src/hooks/caveman-mode-tracker.js never get a chance to intercept. +// +// README.md and INSTALL.md advertise the /caveman-* slash commands, so every +// documented command MUST ship BOTH formats: +// commands/.md — Claude Code plugin commands (#571) +// commands/.toml — Gemini CLI extension commands (#470) +// This test pins that contract, plus checks the caveman-stats bodies actually +// trigger the hook regex (a description-only stub would still leave the +// feature broken). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const COMMANDS_DIR = path.join(REPO_ROOT, 'commands'); +const STATS_TOML = path.join(COMMANDS_DIR, 'caveman-stats.toml'); + +// Mirrors the live regex in src/hooks/caveman-mode-tracker.js (the +// `statsMatch` line). Anything that fails this here would also fail in +// production, so the test stays representative if the hook regex shifts. +const HOOK_STATS_REGEX = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/m; + +test('#470 commands/caveman-stats.toml exists so Claude Code registers /caveman-stats', () => { + assert.ok( + fs.existsSync(STATS_TOML), + `Missing ${path.relative(REPO_ROOT, STATS_TOML)} — Claude Code rejects /caveman-stats as "Unknown command" before the UserPromptSubmit hook can intercept (issue #470).`, + ); +}); + +test('#470 caveman-stats.toml declares a non-empty description for the slash-command picker', () => { + const body = fs.readFileSync(STATS_TOML, 'utf8'); + const descMatch = body.match(/^\s*description\s*=\s*"([^"\n]+)"/m); + assert.ok(descMatch, 'caveman-stats.toml must declare a description = "..." line'); + assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); +}); + +test('#470 caveman-stats.toml prompt is intercepted by the mode-tracker regex', () => { + const body = fs.readFileSync(STATS_TOML, 'utf8'); + const promptMatch = body.match(/^\s*prompt\s*=\s*"([^"\n]+)"/m); + assert.ok(promptMatch, 'caveman-stats.toml must declare a prompt = "..." line'); + const prompt = promptMatch[1].replace(/\{\{args\}\}/g, '').trim(); + assert.match( + prompt, + HOOK_STATS_REGEX, + `Resolved prompt ${JSON.stringify(prompt)} must match the UserPromptSubmit handler regex in src/hooks/caveman-mode-tracker.js; otherwise the stats output is never injected.`, + ); +}); + +// ── #571: Claude Code only discovers commands/*.md ───────────────────────── + +// Every command documented for Claude Code. Each needs a .md (Claude Code) +// AND a .toml (Gemini extension) sibling — the formats coexist in commands/. +const DOCUMENTED_COMMANDS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-stats', 'caveman-init']; + +for (const name of DOCUMENTED_COMMANDS) { + test(`#571 commands/${name}.md exists so Claude Code registers /${name}`, () => { + const mdPath = path.join(COMMANDS_DIR, `${name}.md`); + assert.ok( + fs.existsSync(mdPath), + `Missing ${path.relative(REPO_ROOT, mdPath)} — Claude Code only scans commands/*.md, so /${name} is "Unknown command" without it (issue #571).`, + ); + }); + + test(`#571 commands/${name}.md has YAML frontmatter with a non-empty description`, () => { + const body = fs.readFileSync(path.join(COMMANDS_DIR, `${name}.md`), 'utf8'); + assert.ok(body.startsWith('---\n'), `${name}.md must start with YAML frontmatter (---)`); + const fm = body.match(/^---\n([\s\S]*?)\n---/); + assert.ok(fm, `${name}.md frontmatter must be closed with ---`); + const desc = fm[1].match(/^description:\s*(.+)$/m); + assert.ok(desc && desc[1].trim().length > 0, `${name}.md must declare a non-empty description`); + }); + + test(`#571 commands/${name}.toml still ships for Gemini`, () => { + assert.ok( + fs.existsSync(path.join(COMMANDS_DIR, `${name}.toml`)), + `commands/${name}.toml missing — Gemini CLI extensions only read TOML commands.`, + ); + }); +} + +test('#571 caveman-stats.md body is intercepted by the mode-tracker regex', () => { + const body = fs.readFileSync(path.join(COMMANDS_DIR, 'caveman-stats.md'), 'utf8'); + const prompt = body.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/\$ARGUMENTS/g, '').trim(); + assert.match( + prompt, + HOOK_STATS_REGEX, + `Resolved body ${JSON.stringify(prompt)} must match the UserPromptSubmit handler regex in src/hooks/caveman-mode-tracker.js; otherwise the stats output is never injected.`, + ); +}); + +test('#571 command .md bodies use $ARGUMENTS, never the TOML {{args}} placeholder', () => { + for (const name of DOCUMENTED_COMMANDS) { + const body = fs.readFileSync(path.join(COMMANDS_DIR, `${name}.md`), 'utf8'); + assert.ok( + !body.includes('{{args}}'), + `commands/${name}.md contains {{args}} — Claude Code substitutes $ARGUMENTS, {{args}} would reach the model verbatim.`, + ); + } +}); + +// #603: the init command must not depend on a repo-relative path — installed +// users run it from their own project, where src/tools/ does not exist. +test('#603 caveman-init command bodies do not run src/tools blindly', () => { + for (const ext of ['md', 'toml']) { + const body = fs.readFileSync(path.join(COMMANDS_DIR, `caveman-init.${ext}`), 'utf8'); + if (body.includes('src/tools/caveman-init.js')) { + assert.ok( + /raw\.githubusercontent\.com.*caveman-init\.js/.test(body), + `commands/caveman-init.${ext} references the repo-relative src/tools path without a standalone fallback (curl | node) — fails for every installed user (issue #603).`, + ); + assert.match( + body, + /if .*exists|exists in the current repo/i, + `commands/caveman-init.${ext} must gate the repo-relative path on the file actually existing (issue #603).`, + ); + } + } +}); diff --git a/tests/installer/unit.argv.test.mjs b/tests/installer/unit.argv.test.mjs new file mode 100644 index 0000000..3b28d69 --- /dev/null +++ b/tests/installer/unit.argv.test.mjs @@ -0,0 +1,176 @@ +// Unit tests for the argv parser embedded in bin/install.js. +// We don't import parseArgs (it's not exported) — instead we shell out to the +// installer with --help / --list / unknown flags and assert the framing. +// For deeper coverage of flag-resolution semantics, exec --dry-run --list and +// check the rendered defaults. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const INSTALLER = path.resolve(HERE, '..', '..', 'bin', 'install.js'); + +function run(...args) { + return spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8' }); +} + +test('--help prints usage and exits 0', () => { + const r = run('--help'); + assert.equal(r.status, 0); + assert.match(r.stdout, /USAGE/); + assert.match(r.stdout, /--with-hooks/); +}); + +test('--list prints provider matrix', () => { + const r = run('--list'); + assert.equal(r.status, 0); + assert.match(r.stdout, /caveman provider matrix/); + assert.match(r.stdout, /claude\b/); + assert.match(r.stdout, /gemini\b/); + assert.match(r.stdout, /antigravity\b.*\(soft\)/); +}); + +test('unknown flag exits 2 with error', () => { + const r = run('--bogus'); + assert.equal(r.status, 2); + assert.match(r.stderr, /unknown flag/); +}); + +test('--all + --minimal mutually exclusive', () => { + const r = run('--all', '--minimal'); + assert.equal(r.status, 2); + assert.match(r.stderr, /mutually exclusive/); +}); + +test('--only without arg fails', () => { + const r = run('--only'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--only requires an argument/); +}); + +test('--config-dir without arg fails', () => { + const r = run('--config-dir'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--config-dir requires a path/); +}); + +test('--config-dir followed by another flag fails', () => { + const r = run('--config-dir', '--all'); + assert.equal(r.status, 2); + assert.match(r.stderr, /--config-dir requires a path/); +}); + +test('aider alias rewrites to aider-desk in dry-run output', () => { + const r = run('--dry-run', '--only', 'aider', '--non-interactive', '--config-dir', '/tmp/__cm_alias_test'); + // 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}`; + // --with-hooks: since #392/#393 the hooks plan (which echoes the resolved + // config-dir) is only emitted when the plugin install fails OR hooks are + // forced. Force it so the path-expansion assertion below has something to + // match even when the caveman plugin is already installed. + const r = run('--dry-run', '--only', 'claude', '--with-hooks', '--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('bare -- (POSIX end-of-options) is accepted and ignored', () => { + // Regression: npx forwarded `--` from `curl|bash -- --only openclaw` to the + // package, and parseArgs rejected it as an unknown flag. Now we accept it. + const r = run('--', '--only', 'claude', '--non-interactive', '--dry-run', '--config-dir', '/tmp/__cm_dashdash'); + assert.equal(r.status, 0); +}); + +test('bare --with-mcp-shrink (no upstream) exits 2 with hint', () => { + // Regression for issue where --with-mcp-shrink registered a stub MCP entry + // that crashed on every Claude Code startup. caveman-shrink is a proxy and + // requires an upstream command — we now refuse the bare flag (#474). + const r = run('--with-mcp-shrink', '--non-interactive', '--dry-run'); + assert.equal(r.status, 2); + assert.match(r.stderr, /requires an upstream command/); + assert.match(r.stderr, /server-filesystem/); +}); + +test('--with-mcp-shrink followed by another flag (no value) exits 2', () => { + // The next-token form must distinguish "no value" from "value happens to + // start with --". A user typing `--with-mcp-shrink --dry-run` clearly + // forgot the upstream; refuse. + const r = run('--with-mcp-shrink', '--dry-run', '--non-interactive'); + assert.equal(r.status, 2); + assert.match(r.stderr, /requires an upstream command/); +}); + +test('--with-mcp-shrink="" registers wrapping that upstream', () => { + const r = run( + '--with-mcp-shrink=npx @modelcontextprotocol/server-filesystem /tmp', + '--only', 'claude', '--dry-run', '--non-interactive', + '--config-dir', '/tmp/__cm_shrink_test' + ); + assert.equal(r.status, 0); + // Dry-run only emits the planned `claude mcp add` line when claude is on + // PATH (installMcpShrink probes `claude mcp --help` first). Assert the + // wrapping content only when that line is actually present. + if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) { + assert.match(r.stdout, /claude mcp add caveman-shrink .* npx -y caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/); + } +}); + +test('--with-mcp-shrink "" (space-separated) also accepted', () => { + const r = run( + '--with-mcp-shrink', 'npx @modelcontextprotocol/server-filesystem /tmp', + '--only', 'claude', '--dry-run', '--non-interactive', + '--config-dir', '/tmp/__cm_shrink_space' + ); + assert.equal(r.status, 0); + if (/would run: claude mcp add caveman-shrink/.test(r.stdout)) { + assert.match(r.stdout, /caveman-shrink npx @modelcontextprotocol\/server-filesystem \/tmp/); + } +}); + +test('--all does NOT auto-enable mcp-shrink (no sensible default upstream)', () => { + const r = run('--all', '--only', 'claude', '--dry-run', '--non-interactive', '--config-dir', '/tmp/__cm_all_no_shrink'); + assert.equal(r.status, 0); + // Whether or not claude is on PATH, the wiring banner should not appear + // because withMcpShrink stays false under --all alone. + assert.doesNotMatch(r.stdout, /wiring caveman-shrink MCP proxy/); +}); + +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 new file mode 100644 index 0000000..99b03b3 --- /dev/null +++ b/tests/installer/unit.settings.test.mjs @@ -0,0 +1,334 @@ +// Unit tests for bin/lib/settings.js — the JSONC-tolerant settings helper. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const SETTINGS = require('../../bin/lib/settings.js'); + +function tmpFile(name, contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-settings-')); + const p = path.join(dir, name); + fs.writeFileSync(p, contents); + return p; +} + +test('stripJsonComments strips // line comments', () => { + const out = SETTINGS.stripJsonComments('{"a":1}// trail'); + assert.equal(out.trim(), '{"a":1}'); +}); + +test('stripJsonComments preserves ,} and ,] inside string values (issue #595)', () => { + // Trailing-comma removal must be string-aware: a hook command like + // `echo ,}` or shell brace expansion `cp file{,.bak}` must survive. + const src = '{"cmd": "echo ,}", // comment\n"glob": "cp file{,]x", }'; + const parsed = JSON.parse(SETTINGS.stripJsonComments(src)); + assert.equal(parsed.cmd, 'echo ,}'); + assert.equal(parsed.glob, 'cp file{,]x'); +}); + +test('stripJsonComments still removes real trailing commas after strings', () => { + const src = '{"a": [1, 2, 3,], "b": {"c": 1,},}'; + const parsed = JSON.parse(SETTINGS.stripJsonComments(src)); + assert.deepEqual(parsed, { a: [1, 2, 3], b: { c: 1 } }); +}); + +test('stripJsonComments handles escaped quotes before ,} in strings', () => { + const src = '{"cmd": "say \\",}\\" done", }'; + const parsed = JSON.parse(SETTINGS.stripJsonComments(src)); + assert.equal(parsed.cmd, 'say ",}" done'); +}); + +test('stripJsonComments strips /* block */ comments', () => { + const out = SETTINGS.stripJsonComments('{/* leading */"a":1/* mid */, "b":2}'); + assert.match(out, /"a":1/); + assert.match(out, /"b":2/); + assert.doesNotMatch(out, /leading/); +}); + +test('stripJsonComments leaves comment-looking sequences inside strings alone', () => { + const out = SETTINGS.stripJsonComments('{"url":"http://example.com//path"}'); + assert.equal(out, '{"url":"http://example.com//path"}'); +}); + +test('stripJsonComments strips trailing commas', () => { + const out = SETTINGS.stripJsonComments('{"a":[1,2,3,],}'); + assert.doesNotThrow(() => JSON.parse(out)); +}); + +test('readSettings handles plain JSON', () => { + const p = tmpFile('s.json', '{"theme":"dark"}'); + assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark' }); +}); + +test('readSettings handles JSONC (comments + trailing commas)', () => { + const p = tmpFile('s.json', `// my settings +{ + "theme": "dark", /* mode */ + "hooks": {}, +}`); + assert.deepEqual(SETTINGS.readSettings(p), { theme: 'dark', hooks: {} }); +}); + +test('readSettings returns {} for missing file', () => { + assert.deepEqual(SETTINGS.readSettings('/nonexistent/path/xyz.json'), {}); +}); + +test('readSettings returns null for unrecoverable garbage', () => { + const p = tmpFile('s.json', 'this is not json at all {{{'); + assert.equal(SETTINGS.readSettings(p), null); +}); + +test('writeSettings round-trips with newline', () => { + const p = tmpFile('s.json', ''); + SETTINGS.writeSettings(p, { a: 1 }); + const raw = fs.readFileSync(p, 'utf8'); + assert.equal(raw.endsWith('\n'), true); + assert.deepEqual(JSON.parse(raw), { a: 1 }); +}); + +test('validateHookFields drops malformed command hook (missing command)', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [{ type: 'command' }, { type: 'command', command: 'good' }] }], + }, + }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks.SessionStart[0].hooks.length, 1); + assert.equal(s.hooks.SessionStart[0].hooks[0].command, 'good'); +}); + +test('validateHookFields drops malformed agent hook (missing prompt)', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [{ type: 'agent' }] }], + }, + }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks, undefined); +}); + +test('validateHookFields drops empty events and empty hooks parent', () => { + const s = { hooks: { SessionStart: [], UserPromptSubmit: [{ hooks: [] }] } }; + SETTINGS.validateHookFields(s); + assert.equal(s.hooks, undefined); +}); + +test('addCommandHook is idempotent on substring marker', () => { + const s = {}; + const a = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/abs/path/caveman-activate.js', marker: 'caveman-activate' }); + const b = SETTINGS.addCommandHook(s, 'SessionStart', { command: '/different/abs/path/caveman-activate.js', marker: 'caveman-activate' }); + assert.equal(a, true); + assert.equal(b, false); + assert.equal(s.hooks.SessionStart.length, 1); +}); + +test('hasCavemanHook detects via substring', () => { + const s = { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node /x/caveman-activate.js' }] }] } }; + assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'caveman-activate'), true); + assert.equal(SETTINGS.hasCavemanHook(s, 'SessionStart', 'gsd'), false); + 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); }); + assert.equal(removed, 0); + assert.equal(s.hooks, undefined); +}); + +test('removeCavemanHooks strips managed scripts and cleans empties', () => { + const s = { + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'node /x/hooks/caveman-activate.js' }] }, + { hooks: [{ type: 'command', command: 'other' }] }, + ], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-mode-tracker.js"' }] }], + }, + }; + const removed = SETTINGS.removeCavemanHooks(s); + assert.equal(removed, 2); + assert.equal(s.hooks.SessionStart.length, 1); + assert.equal(s.hooks.UserPromptSubmit, undefined); +}); + +test('removeCavemanHooks leaves user hooks that merely mention caveman (issue #593)', () => { + const s = { + hooks: { + PreToolUse: [ + // Path contains the word "caveman" but targets a user-authored script. + { hooks: [{ type: 'command', command: 'node /Users/me/Projects/caveman-notes/my-hook.js' }] }, + // Basename is a superstring of a managed name — still not ours. + { hooks: [{ type: 'command', command: 'node /x/mycaveman-activate.js' }] }, + ], + SessionStart: [ + { hooks: [{ type: 'command', command: '"/usr/bin/node" "/x/hooks/caveman-activate.js"' }] }, + ], + }, + }; + const removed = SETTINGS.removeCavemanHooks(s); + assert.equal(removed, 1, 'only the managed SessionStart hook should be removed'); + assert.equal(s.hooks.PreToolUse.length, 2, 'user hooks mentioning caveman must survive uninstall'); + assert.equal(s.hooks.SessionStart, undefined); +}); + +test('removeCavemanHooks removes the Windows statusline-stats wiring (caveman-stats.js / .ps1)', () => { + const s = { + hooks: { + Stop: [{ hooks: [{ type: 'command', command: '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\me\\.claude\\hooks\\caveman-stats.js"' }] }], + }, + }; + const removed = SETTINGS.removeCavemanHooks(s); + assert.equal(removed, 1); + assert.equal(s.hooks, undefined); +}); + +test('rewriteLegacyManagedHookCommands rewrites bare-node managed scripts', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: 'node /abs/hooks/caveman-activate.js' }, + { type: 'command', command: 'node /abs/hooks/some-user-hook.js' }, + ] }], + }, + }; + const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/usr/local/bin/node'); + assert.equal(n, 1); + assert.match(s.hooks.SessionStart[0].hooks[0].command, /"\/usr\/local\/bin\/node" "\/abs\/hooks\/caveman-activate\.js"/); + assert.equal(s.hooks.SessionStart[0].hooks[1].command, 'node /abs/hooks/some-user-hook.js'); +}); + +test('rewriteLegacyManagedHookCommands ignores already-absolute node commands', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: '"/usr/local/bin/node" "/abs/hooks/caveman-activate.js"' }, + ] }], + }, + }; + const n = SETTINGS.rewriteLegacyManagedHookCommands(s, '/somewhere/else/node'); + assert.equal(n, 0); +}); + +test('pruneOrphanedManagedHooks removes managed hook whose target is missing (absolute-node)', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: '"/opt/node/bin/node" "/no/such/dir/caveman-activate.js"' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 1); + assert.equal(s.hooks, undefined); +}); + +test('pruneOrphanedManagedHooks removes orphan bare-node managed hook', () => { + const s = { + hooks: { + UserPromptSubmit: [{ hooks: [ + { type: 'command', command: 'node /no/such/dir/caveman-mode-tracker.js' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 1); + assert.equal(s.hooks, undefined); +}); + +test('pruneOrphanedManagedHooks keeps managed hook whose target exists', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-')); + const script = path.join(dir, 'caveman-activate.js'); + fs.writeFileSync(script, '// real'); + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: `"/opt/node/bin/node" "${script}"` }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir); + assert.equal(removed, 0); + assert.equal(s.hooks.SessionStart.length, 1); +}); + +test('pruneOrphanedManagedHooks leaves non-managed hooks alone even if missing', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: 'node /no/such/dir/some-user-hook.js' }, + { type: 'command', command: '[ -n "$SUPERSET_HOME_DIR" ] && "$SUPERSET_HOME_DIR/hooks/notify.sh" || true' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 0); + assert.equal(s.hooks.SessionStart[0].hooks.length, 2); +}); + +test('pruneOrphanedManagedHooks resolves relative target against configDir', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-prune-rel-')); + // hooks/caveman-activate.js intentionally NOT created → missing + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: 'node hooks/caveman-activate.js' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, dir); + assert.equal(removed, 1); + assert.equal(s.hooks, undefined); +}); + +test('pruneOrphanedManagedHooks does NOT match a user script whose name merely contains a managed basename', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + // basename is "mycaveman-activate.js" — not an exact managed basename + { type: 'command', command: 'node /no/such/dir/mycaveman-activate.js' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 0); + assert.equal(s.hooks.SessionStart[0].hooks.length, 1); +}); + +test('pruneOrphanedManagedHooks handles quoted paths containing spaces', () => { + const s = { + hooks: { + SessionStart: [{ hooks: [ + { type: 'command', command: '"/opt/node/bin/node" "/no such dir/caveman-activate.js"' }, + ] }], + }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 1); + assert.equal(s.hooks, undefined); +}); + +test('pruneOrphanedManagedHooks drops orphaned managed statusLine', () => { + const s = { + statusLine: { type: 'command', command: 'bash /no/such/dir/caveman-statusline.sh' }, + }; + const removed = SETTINGS.pruneOrphanedManagedHooks(s, '/tmp/__cm_cfg_missing'); + assert.equal(removed, 1); + assert.equal(s.statusLine, undefined); +}); + +test('claudeConfigDir honors CLAUDE_CONFIG_DIR env', () => { + const orig = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = '/tmp/__cm_test_cfg'; + try { assert.equal(SETTINGS.claudeConfigDir(), '/tmp/__cm_test_cfg'); } + finally { if (orig === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = orig; } +}); diff --git a/tests/test_cavecrew_model_overrides.js b/tests/test_cavecrew_model_overrides.js new file mode 100644 index 0000000..5087802 --- /dev/null +++ b/tests/test_cavecrew_model_overrides.js @@ -0,0 +1,292 @@ +#!/usr/bin/env node +// Tests for src/hooks/cavecrew-model-overrides.js +// Run: node tests/test_cavecrew_model_overrides.js + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); + +const { patchFrontmatterModel, resolvePluginRoot, applyOverrides, AGENT_ENV_MAP } = + require('../src/hooks/cavecrew-model-overrides'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(' ✓ ' + name); + } catch (e) { + failed++; + console.error(' ✗ ' + name); + console.error(' ' + e.message); + } +} + +// ── patchFrontmatterModel ────────────────────────────────────────────────── + +console.log('\npatchFrontmatterModel\n'); + +const REVIEWER_FM = [ + '---', + 'name: cavecrew-reviewer', + 'description: >', + ' Reviewer subagent.', + 'tools: [Read, Grep, Bash]', + 'model: haiku', + '---', + '', + 'Body text.', +].join('\n'); + +test('replaces existing model: haiku with sonnet in reviewer', () => { + const out = patchFrontmatterModel(REVIEWER_FM, 'sonnet'); + assert.ok(out.includes('model: sonnet'), 'new model line missing'); + assert.ok(!out.includes('model: haiku'), 'old model line still present'); + assert.ok(out.includes('Body text.'), 'body missing'); +}); + +test('preserves all other frontmatter lines', () => { + const out = patchFrontmatterModel(REVIEWER_FM, 'opus'); + assert.ok(out.includes('name: cavecrew-reviewer'), 'name line lost'); + assert.ok(out.includes('tools: [Read, Grep, Bash]'), 'tools line lost'); + assert.ok(out.includes('description: >'), 'description block lost'); +}); + +const INVESTIGATOR_FM = [ + '---', + 'name: cavecrew-investigator', + 'tools: [Read, Grep, Glob, Bash]', + 'model: haiku', + '---', + '', + 'Investigator body.', +].join('\n'); + +test('replaces existing model: haiku with opus in investigator', () => { + const out = patchFrontmatterModel(INVESTIGATOR_FM, 'opus'); + assert.ok(out.includes('model: opus'), 'new model missing'); + assert.ok(!out.includes('model: haiku'), 'old model still present'); + assert.ok(out.includes('Investigator body.'), 'body lost'); +}); + +const BUILDER_FM = [ + '---', + 'name: cavecrew-builder', + 'description: >', + ' Builder subagent.', + 'tools: [Read, Edit, Write, Grep, Glob]', + '---', + '', + 'Builder body.', +].join('\n'); + +test('inserts model: after tools: when no model line exists (builder)', () => { + const out = patchFrontmatterModel(BUILDER_FM, 'sonnet'); + assert.ok(out.includes('model: sonnet'), 'model line not inserted'); + // Must be inside frontmatter (before body) + const fmClose = out.indexOf('\n---', 3); + const modelPos = out.indexOf('model: sonnet'); + assert.ok(modelPos < fmClose, 'model line is outside frontmatter'); + // Inserted right after tools: line + const toolsPos = out.indexOf('tools:'); + const toolsEnd = out.indexOf('\n', toolsPos); + assert.strictEqual(out.slice(toolsEnd + 1, toolsEnd + 1 + 'model: sonnet'.length), 'model: sonnet', + 'model not inserted immediately after tools: line'); + assert.ok(out.includes('Builder body.'), 'body lost'); +}); + +test('no-op when content has no frontmatter', () => { + const plain = 'Just some text\nno frontmatter\n'; + const out = patchFrontmatterModel(plain, 'sonnet'); + assert.strictEqual(out, plain); +}); + +test('empty model value is no-op (defense-in-depth guard)', () => { + const out = patchFrontmatterModel(REVIEWER_FM, ''); + assert.strictEqual(out, REVIEWER_FM, 'empty value should leave file unchanged'); +}); + +test('ignores model value with newline', () => { + const out = patchFrontmatterModel(REVIEWER_FM, 'so\nnnet'); + assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged'); +}); + +test('ignores model value with control character', () => { + const out = patchFrontmatterModel(REVIEWER_FM, 'so\x01nnet'); + assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged'); +}); + +test('model line already identical → content unchanged', () => { + const out = patchFrontmatterModel(REVIEWER_FM, 'haiku'); + assert.strictEqual(out, REVIEWER_FM, 'should be byte-identical when value unchanged'); +}); + +test('no model line and no tools line → inserts before closing ---', () => { + const fm = '---\nname: test\n---\n\nbody\n'; + const out = patchFrontmatterModel(fm, 'sonnet'); + assert.ok(out.includes('model: sonnet'), 'model line missing'); + const fmClose = out.indexOf('\n---', 3); + const modelPos = out.indexOf('model: sonnet'); + assert.ok(modelPos < fmClose, 'model line outside frontmatter'); +}); + +test('CRLF files: inserted model line uses CRLF, no mixed endings', () => { + const crlf = REVIEWER_FM.replace(/\n/g, '\r\n'); + const out = patchFrontmatterModel(crlf, 'sonnet'); + assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF file'); + // No bare LF should appear outside CRLF sequences + const strippedCR = out.replace(/\r\n/g, ''); + assert.ok(!strippedCR.includes('\n'), 'mixed line endings detected after patch'); +}); + +test('CRLF builder (no model line): inserted model line uses CRLF', () => { + const crlf = BUILDER_FM.replace(/\n/g, '\r\n'); + const out = patchFrontmatterModel(crlf, 'sonnet'); + assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF builder file'); + const strippedCR = out.replace(/\r\n/g, ''); + assert.ok(!strippedCR.includes('\n'), 'mixed line endings in CRLF builder patch'); +}); + +// ── resolvePluginRoot ────────────────────────────────────────────────────── + +console.log('\nresolvePluginRoot\n'); + +test('resolves to parent of hooks dir', () => { + const hooksDir = path.join(os.tmpdir(), 'fake-plugin', 'hooks'); + const root = resolvePluginRoot(hooksDir); + assert.strictEqual(path.basename(root), 'fake-plugin'); +}); + +// ── applyOverrides ───────────────────────────────────────────────────────── + +console.log('\napplyOverrides\n'); + +function withTmpPlugin(fn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-override-test-')); + const agentsDir = path.join(tmp, 'agents'); + fs.mkdirSync(agentsDir); + try { + fn(tmp, agentsDir); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +test('replaces reviewer model when CAVECREW_REVIEWER_MODEL set', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.ok(out.includes('model: sonnet'), 'reviewer model not patched'); + }); +}); + +test('replaces investigator model when CAVECREW_INVESTIGATOR_MODEL set', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), INVESTIGATOR_FM, 'utf8'); + applyOverrides(root, { CAVECREW_INVESTIGATOR_MODEL: 'opus' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), 'utf8'); + assert.ok(out.includes('model: opus'), 'investigator model not patched'); + }); +}); + +test('inserts builder model when CAVECREW_BUILDER_MODEL set and no model line', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-builder.md'), BUILDER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_BUILDER_MODEL: 'sonnet' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-builder.md'), 'utf8'); + assert.ok(out.includes('model: sonnet'), 'builder model not inserted'); + }); +}); + +test('blank env var is no-op', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: '' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.strictEqual(out, REVIEWER_FM, 'blank env var should be no-op'); + }); +}); + +test('whitespace-only env var is no-op', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: ' ' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.strictEqual(out, REVIEWER_FM, 'whitespace env var should be no-op'); + }); +}); + +test('env var with newline in value is ignored', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'so\nnnet' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.strictEqual(out, REVIEWER_FM, 'newline in value should be ignored'); + }); +}); + +test('env var with control character in value is ignored', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'son\x00net' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.strictEqual(out, REVIEWER_FM, 'control char in value should be ignored'); + }); +}); + +test('missing agent file is silent no-op', () => { + withTmpPlugin((root) => { + // agents dir exists but reviewer file does not + assert.doesNotThrow(() => { + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' }); + }); + }); +}); + +test('missing agents dir (non-plugin layout) is silent no-op', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-nolayout-')); + try { + assert.doesNotThrow(() => { + applyOverrides(tmp, { CAVECREW_REVIEWER_MODEL: 'sonnet' }); + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('unset env vars → files untouched', () => { + withTmpPlugin((root, agentsDir) => { + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8'); + applyOverrides(root, {}); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.strictEqual(out, REVIEWER_FM, 'file should be unchanged when env unset'); + }); +}); + +test('body content preserved after model patch', () => { + withTmpPlugin((root, agentsDir) => { + const content = REVIEWER_FM + '\n\n## Extra\n\nExtra section body.\n'; + fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), content, 'utf8'); + applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' }); + const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8'); + assert.ok(out.includes('## Extra'), 'extra body section lost'); + assert.ok(out.includes('Extra section body.'), 'body text lost'); + }); +}); + +// ── Summary ──────────────────────────────────────────────────────────────── + +console.log(''); +if (failed === 0) { + console.log('All ' + (passed + failed) + ' tests passed.'); + process.exit(0); +} else { + console.error(failed + ' test(s) failed.'); + process.exit(1); +} diff --git a/tests/test_caveman_init.js b/tests/test_caveman_init.js new file mode 100644 index 0000000..436404d --- /dev/null +++ b/tests/test_caveman_init.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Tests for src/tools/caveman-init.js — fixture-based. +// Run: node tests/test_caveman_init.js + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); +const { execFileSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const INIT = path.join(ROOT, 'src', 'tools', 'caveman-init.js'); + +let passed = 0; +let failed = 0; + +// Point OPENCLAW_WORKSPACE at a nonexistent dir inside the fixture so the +// openclaw target reports skipped-workspace-missing instead of writing to +// the developer's real ~/.openclaw/workspace. +function runInit(tmp, ...args) { + return execFileSync(process.execPath, [INIT, tmp, ...args], { + encoding: 'utf8', + env: { ...process.env, OPENCLAW_WORKSPACE: path.join(tmp, 'no-openclaw') }, + }); +} + +function test(name, fn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-init-test-')); + try { + fn(tmp); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}\n ${e.message}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +console.log('caveman-init tests\n'); + +test('greenfield: creates all rule files with proper frontmatter', (tmp) => { + runInit(tmp); + const cursor = fs.readFileSync(path.join(tmp, '.cursor/rules/caveman.mdc'), 'utf8'); + assert.match(cursor, /alwaysApply: true/); + assert.match(cursor, /Respond terse like smart caveman/); + const windsurf = fs.readFileSync(path.join(tmp, '.windsurf/rules/caveman.md'), 'utf8'); + assert.match(windsurf, /trigger: always_on/); + const cline = fs.readFileSync(path.join(tmp, '.clinerules/caveman.md'), 'utf8'); + assert.match(cline, /^Respond terse/); + const copilot = fs.readFileSync(path.join(tmp, '.github/copilot-instructions.md'), 'utf8'); + assert.match(copilot, /Respond terse/); + const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8'); + assert.match(agents, /Respond terse/); + const opencode = fs.readFileSync(path.join(tmp, '.opencode/AGENTS.md'), 'utf8'); + assert.match(opencode, /Respond terse/); +}); + +test('idempotent: re-running on a clean install skips all', (tmp) => { + runInit(tmp); + const out = runInit(tmp); + // 6 repo rule files skipped-already-installed + openclaw skipped (no workspace) + assert.match(out, /7 skipped/); + assert.doesNotMatch(out, /[1-9]\d* added/); +}); + +test('append mode: existing AGENTS.md gets caveman appended (not replaced)', (tmp) => { + fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# My project\n\nDo not delete me.\n'); + runInit(tmp); + const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8'); + assert.match(agents, /Do not delete me/); + assert.match(agents, /Respond terse like smart caveman/); +}); + +test('skip mode: existing .cursor rule is not overwritten without --force', (tmp) => { + const dir = path.join(tmp, '.cursor/rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\nDo not delete me.\n'); + const out = runInit(tmp); + assert.match(out, /\? .*\.cursor\/rules\/caveman\.mdc/); + const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8'); + assert.strictEqual(after, '# original\nDo not delete me.\n'); +}); + +test('--force overwrites existing rule files', (tmp) => { + const dir = path.join(tmp, '.cursor/rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\n'); + runInit(tmp, '--force'); + const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8'); + assert.match(after, /alwaysApply: true/); + assert.match(after, /Respond terse/); +}); + +test('--dry-run: announces but writes nothing', (tmp) => { + const out = runInit(tmp, '--dry-run'); + assert.match(out, /\(dry run\)/); + assert.match(out, /6 added/); + assert.ok(!fs.existsSync(path.join(tmp, '.cursor'))); + assert.ok(!fs.existsSync(path.join(tmp, '.windsurf'))); + assert.ok(!fs.existsSync(path.join(tmp, '.clinerules'))); + assert.ok(!fs.existsSync(path.join(tmp, '.github/copilot-instructions.md'))); + assert.ok(!fs.existsSync(path.join(tmp, '.opencode'))); + assert.ok(!fs.existsSync(path.join(tmp, 'AGENTS.md'))); +}); + +test('--only filters to one target', (tmp) => { + const out = runInit(tmp, '--only', 'cline'); + assert.match(out, /1 added/); + assert.ok(fs.existsSync(path.join(tmp, '.clinerules/caveman.md'))); + assert.ok(!fs.existsSync(path.join(tmp, '.cursor'))); +}); + +test('detects sentinel and skips files that already have caveman content', (tmp) => { + // Hand-write a file that already contains the rule (simulating prior install). + const dir = path.join(tmp, '.clinerules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.md'), + '# Existing\n\nRespond terse like smart caveman. Hello.\n'); + const out = runInit(tmp, '--only', 'cline'); + assert.match(out, /skipped-already-installed/); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/test_caveman_stats.js b/tests/test_caveman_stats.js new file mode 100644 index 0000000..906bd21 --- /dev/null +++ b/tests/test_caveman_stats.js @@ -0,0 +1,637 @@ +#!/usr/bin/env node +// Tests for /caveman-stats — direct script invocation and via mode tracker. +// Run: node tests/test_caveman_stats.js + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); +const { execFileSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const STATS = path.join(ROOT, 'src', 'hooks', 'caveman-stats.js'); +const TRACKER = path.join(ROOT, 'src', 'hooks', 'caveman-mode-tracker.js'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-stats-test-')); + try { + fn(tmp); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}\n ${e.message}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +function makeSession(dir, lines) { + const projDir = path.join(dir, '.claude', 'projects', 'p'); + fs.mkdirSync(projDir, { recursive: true }); + const sessFile = path.join(projDir, 's.jsonl'); + fs.writeFileSync(sessFile, lines.map(l => JSON.stringify(l)).join('\n')); + return sessFile; +} + +console.log('caveman-stats tests\n'); + +test('reads --session-file directly and sums output tokens', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100, cache_read_input_tokens: 200 } } }, + { type: 'user', message: { content: 'hi' } }, + { type: 'assistant', message: { usage: { output_tokens: 50, cache_read_input_tokens: 50 } } }, + ]); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: path.join(tmp, '.claude') }, + }); + assert.match(out, /Turns:\s+2/); + assert.match(out, /Output tokens:\s+150/); + assert.match(out, /Cache-read tokens:\s+250/); +}); + +test('shows full-mode savings estimate when flag is full', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // 350 / 0.35 = 1000, saved = 650, ~65% + assert.match(out, /Est\. without caveman:\s+1,000/); + assert.match(out, /Est\. tokens saved:\s+650 \(~65% of output\)/); +}); + +test('skips estimate for non-full modes', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'ultra'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /No savings estimate for 'ultra' mode/); +}); + +test('reports no-session when no .jsonl exists', (tmp) => { + fs.mkdirSync(path.join(tmp, '.claude', 'projects'), { recursive: true }); + let err = null; + try { + execFileSync(process.execPath, [STATS], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: path.join(tmp, '.claude') }, + }); + } catch (e) { err = e; } + assert.ok(err, 'should exit non-zero'); + assert.match(err.stderr, /no Claude Code session found/); +}); + +test('mode tracker handles /caveman-stats with decision block', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [TRACKER], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp }, + input: JSON.stringify({ prompt: '/caveman-stats', transcript_path: sess }), + }); + const parsed = JSON.parse(out); + assert.strictEqual(parsed.decision, 'block'); + assert.match(parsed.reason, /Caveman Stats/); + assert.match(parsed.reason, /Output tokens:\s+100/); +}); + +test('mode tracker preserves caveman flag when /caveman-stats fires', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 50 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + execFileSync(process.execPath, [TRACKER], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp }, + input: JSON.stringify({ prompt: '/caveman-stats', transcript_path: sess }), + }); + // The flag must still say 'full' — the stats command must not change mode. + assert.strictEqual(fs.readFileSync(path.join(claudeDir, '.caveman-active'), 'utf8'), 'full'); +}); + +test('shows USD savings when model is a known sonnet variant', (tmp) => { + // 350 / 0.35 = 1000, saved = 650 tokens. At $15/M output → $0.00975. + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-20250514', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // 650/1M * $15 = $0.00975 — JS toFixed(4) rounds the float repr to 0.0097. + assert.match(out, /Est\. saved \(USD\):\s+~\$0\.009[78]/); + assert.match(out, /Pricing for claude-sonnet-4-20250514/); +}); + +test('omits USD line when model is unknown', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'some-future-model-xyz', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // Token estimate still appears, USD line does not. + assert.match(out, /Est\. tokens saved:\s+650 \(~65% of output\)/); + assert.doesNotMatch(out, /Est\. saved \(USD\)/); +}); + +test('priceForModel matches by prefix across point releases', () => { + const { priceForModel } = require(path.join(ROOT, 'src', 'hooks', 'caveman-stats.js')); + assert.strictEqual(priceForModel('claude-opus-4-7'), 25.00); + assert.strictEqual(priceForModel('claude-opus-4-8'), 25.00); + assert.strictEqual(priceForModel('claude-opus-4-20250101'), 75.00); + assert.strictEqual(priceForModel('claude-opus-4-1-20250805'), 75.00); + assert.strictEqual(priceForModel('claude-sonnet-4-7-20260315'), 15.00); + assert.strictEqual(priceForModel('claude-haiku-4-5'), 5.00); + assert.strictEqual(priceForModel('claude-3-5-sonnet-20241022'), 15.00); + assert.strictEqual(priceForModel(null), null); + assert.strictEqual(priceForModel('gpt-4'), null); +}); + +test('formatStats handles empty session gracefully', () => { + const { formatStats } = require(path.join(ROOT, 'src', 'hooks', 'caveman-stats.js')); + const out = formatStats({ outputTokens: 0, cacheReadTokens: 0, turns: 0, mode: 'full', model: null }); + assert.match(out, /No conversation yet/); +}); + +test('--share prints single-line tweetable summary', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess, '--share'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.strictEqual(out.split('\n').filter(Boolean).length, 1); + assert.match(out, /^🪨 Saved 650 output tokens \(~\$0\.009[78]\) across 1 turns this session — caveman\.sh$/m); +}); + +test('--share works with no benchmark ratio (lite mode)', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 200 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'lite'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess, '--share'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /^🪨 1 turns, 200 output tokens this session — caveman\.sh$/m); +}); + +test('appends to lifetime history on each run', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + assert.ok(fs.existsSync(histPath), 'history file should be created'); + const lines = fs.readFileSync(histPath, 'utf8').split('\n').filter(Boolean); + assert.strictEqual(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.strictEqual(entry.session_id, 's'); + assert.strictEqual(entry.output_tokens, 350); + assert.strictEqual(entry.est_saved_tokens, 650); + assert.strictEqual(entry.mode, 'full'); + assert.strictEqual(entry.model, 'claude-sonnet-4-7'); +}); + +test('--all aggregates latest entry per session', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + // Two sessions, second one has two snapshots — only latest counts. + fs.writeFileSync(histPath, [ + { ts: 1000, session_id: 'a', mode: 'full', output_tokens: 100, est_saved_tokens: 185, est_saved_usd: 0.0028 }, + { ts: 2000, session_id: 'b', mode: 'full', output_tokens: 50, est_saved_tokens: 92, est_saved_usd: 0.0014 }, + { ts: 3000, session_id: 'b', mode: 'full', output_tokens: 200, est_saved_tokens: 371, est_saved_usd: 0.0056 }, + ].map(o => JSON.stringify(o)).join('\n') + '\n'); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // a: 185 + b-latest: 371 = 556 + assert.match(out, /Sessions:\s+2/); + assert.match(out, /Est\. tokens saved:\s+556/); + // 0.0028 + 0.0056 = 0.0084 → formatted as $0.0084 + assert.match(out, /\$0\.0084/); +}); + +test('--since filters by time window', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + const now = Date.now(); + const twoDaysAgo = now - 2 * 86_400_000; + const tenMinAgo = now - 10 * 60_000; + fs.writeFileSync(histPath, [ + { ts: twoDaysAgo, session_id: 'old', mode: 'full', output_tokens: 100, est_saved_tokens: 185, est_saved_usd: 0.003 }, + { ts: tenMinAgo, session_id: 'new', mode: 'full', output_tokens: 50, est_saved_tokens: 92, est_saved_usd: 0.001 }, + ].map(o => JSON.stringify(o)).join('\n') + '\n'); + const out = execFileSync(process.execPath, [STATS, '--since', '1d'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // Only the recent session is counted. + assert.match(out, /Sessions:\s+1/); + assert.match(out, /Est\. tokens saved:\s+92/); + assert.match(out, /\(last 1d\)/); +}); + +test('--since rejects malformed durations', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + let err = null; + try { + execFileSync(process.execPath, [STATS, '--since', 'sometime'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + } catch (e) { err = e; } + assert.ok(err, 'should exit non-zero'); + assert.match(err.stderr, /--since takes Nh or Nd/); +}); + +test('--all reports empty when no history', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /No sessions logged yet/); +}); + +test('detects compressed memory pairs and reports approx token savings', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + // Make a fake compressed/original pair: original is 800 bytes, compressed 200 bytes. + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.original.md'), 'x'.repeat(800)); + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'y'.repeat(200)); + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // 600 bytes / 4 chars-per-token ≈ 150 tokens (approx). + assert.match(out, /Memory compressed:\s+1 file, ~150 tokens saved per session start/); +}); + +test('omits memory line when no compressed pairs exist', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.doesNotMatch(out, /Memory compressed/); +}); + +test('skips pairs where compressed is not actually smaller', (tmp) => { + const { findCompressedPairs } = require(path.join(ROOT, 'src', 'hooks', 'caveman-stats.js')); + fs.writeFileSync(path.join(tmp, 'foo.original.md'), 'small'); + fs.writeFileSync(path.join(tmp, 'foo.md'), 'this is actually larger somehow'); + const pairs = findCompressedPairs([tmp]); + assert.strictEqual(pairs.length, 0); +}); + +test('writes statusline suffix file after a stats run', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 1500 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + const suffixPath = path.join(claudeDir, '.caveman-statusline-suffix'); + assert.ok(fs.existsSync(suffixPath)); + // 1500 / 0.35 = 4286, saved = 2786 → "⛏ 2.8k" (two spaces after ⛏, #459) + const suffix = fs.readFileSync(suffixPath, 'utf8'); + assert.match(suffix, /^⛏ 2\.8k$/); +}); + +test('humanizeTokens formats small/medium/large correctly', () => { + const { humanizeTokens } = require(path.join(ROOT, 'src', 'hooks', 'caveman-stats.js')); + assert.strictEqual(humanizeTokens(0), '0'); + assert.strictEqual(humanizeTokens(42), '42'); + assert.strictEqual(humanizeTokens(2786), '2.8k'); + assert.strictEqual(humanizeTokens(1_250_000), '1.3M'); +}); + +test('statusline.sh appends savings when CAVEMAN_STATUSLINE_SAVINGS=1', (tmp) => { + if (process.platform === 'win32') return; // bash test + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '⛏ 2.8k'); + const out = execFileSync('bash', [path.join(ROOT, 'src', 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_STATUSLINE_SAVINGS: '1' }, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.match(out, /⛏ 2\.8k/); +}); + +test('statusline.sh renders savings by default when env var is unset', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '⛏ 2.8k'); + const env = { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }; + delete env.CAVEMAN_STATUSLINE_SAVINGS; + const out = execFileSync('bash', [path.join(ROOT, 'src', 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', env, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.match(out, /⛏ 2\.8k/); +}); + +test('statusline.sh omits savings when CAVEMAN_STATUSLINE_SAVINGS=0', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '⛏ 2.8k'); + const out = execFileSync('bash', [path.join(ROOT, 'src', 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_STATUSLINE_SAVINGS: '0' }, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.doesNotMatch(out, /⛏/); +}); + +test('statusline.sh omits savings when suffix file is missing (fresh install)', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + // No suffix file written — simulates the moment after install but before + // /caveman-stats has run. Default-on must NOT fabricate a number. + const env = { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }; + delete env.CAVEMAN_STATUSLINE_SAVINGS; + const out = execFileSync('bash', [path.join(ROOT, 'src', 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', env, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.doesNotMatch(out, /⛏/); +}); + +test('statusline.sh strips control bytes from suffix', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + // Plant a malicious suffix with ANSI escape (control byte \x1b). + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '\x1b[31mEVIL'); + const out = execFileSync('bash', [path.join(ROOT, 'src', 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_STATUSLINE_SAVINGS: '1' }, + }); + // Escape byte stripped; "[31mEVIL" remains, but the leading \x1b is gone so + // the user's terminal won't be hijacked. + assert.doesNotMatch(out, /\x1b\[31m/); +}); + +test('appendFlag is symlink-safe (refuses symlinked target)', (tmp) => { + if (process.platform === 'win32') return; // symlink semantics differ + const { appendFlag } = require(path.join(ROOT, 'src', 'hooks', 'caveman-config.js')); + const target = path.join(tmp, 'real-target'); + fs.writeFileSync(target, 'do-not-clobber\n'); + const linkPath = path.join(tmp, 'history.jsonl'); + fs.symlinkSync(target, linkPath); + appendFlag(linkPath, JSON.stringify({ ts: 1, session_id: 'x' })); + // Original target must be untouched. + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'do-not-clobber\n'); +}); + +test('mode tracker forwards --share to stats script', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [TRACKER], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp }, + input: JSON.stringify({ prompt: '/caveman-stats --share', transcript_path: sess }), + }); + const parsed = JSON.parse(out); + assert.strictEqual(parsed.decision, 'block'); + assert.match(parsed.reason, /^🪨 Saved 650 output tokens/); +}); + +// ── Output-reduction share (never a "usage"/"budget" claim) ──────────────── +// saved/(saved+used) from output tokens is the OUTPUT reduction — input and +// cache tokens dominate real sessions and are untouched, so printing it as a +// share of usage/budget would overstate limit relief (docs/HONEST-NUMBERS.md). + +test('outputReductionPct = saved / (saved + used), null when nothing saved', () => { + const { outputReductionPct } = require(STATS); + assert.strictEqual(outputReductionPct(650, 350), 65); + assert.strictEqual(outputReductionPct(1, 3), 25); + assert.strictEqual(outputReductionPct(0, 350), null); // no measured savings → no claim + assert.strictEqual(outputReductionPct(-5, 350), null); + assert.strictEqual(outputReductionPct(650, -1), null); + assert.strictEqual(outputReductionPct(NaN, 350), null); + assert.strictEqual(outputReductionPct(650, Infinity), null); +}); + +test('session view never claims a % of usage/budget — only output reduction', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // The reduction is labeled as output-only, never a share of session usage. + assert.match(out, /Est\. tokens saved:\s+650 \(~65% of output\)/); + assert.ok(!/budget|of your usage|of tracked usage/i.test(out), + 'must not relabel output reduction as a usage/budget share'); + // Dollars stay for API users. + assert.match(out, /Est\. saved \(USD\):/); + // Footer must state the reduction excludes input/cache usage. + assert.match(out, /output tokens only; input\/cache usage is unchanged/); + assert.ok(!/weekly limit|5-hour limit/i.test(out), 'must not fabricate Anthropic quota sizes'); +}); + +test('--all lifetime output labels the % as output reduction, not usage', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const history = [ + { ts: Date.now(), session_id: 'a', output_tokens: 350, est_saved_tokens: 650, est_saved_usd: 0.01 }, + { ts: Date.now(), session_id: 'b', output_tokens: 650, est_saved_tokens: 350, est_saved_usd: 0.005 }, + ]; + fs.writeFileSync( + path.join(claudeDir, '.caveman-history.jsonl'), + history.map(h => JSON.stringify(h)).join('\n') + '\n', + ); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // saved 1000 / (1000 saved + 1000 used-output) = 50% of would-be output + assert.match(out, /Est\. output reduction:\s+~50% \(output tokens only, est\.\)/); + assert.ok(!/budget|of your usage|of tracked usage/i.test(out), + 'must not relabel output reduction as a usage/budget share'); +}); + +test('--all lifetime output omits reduction line when nothing saved', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, '.caveman-history.jsonl'), + JSON.stringify({ ts: Date.now(), session_id: 'a', output_tokens: 350, est_saved_tokens: 0, est_saved_usd: 0 }) + '\n', + ); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.ok(!/output reduction|budget/i.test(out), 'zero savings → honest zero, no % line'); +}); + +// ── Mid-session mode-change attribution (#601) ───────────────────────────── +// Tokens must be attributed to the mode active WHEN each message happened, +// via the .caveman-mode-log.jsonl transition log — never to whatever mode the +// flag holds at stats time (which inflated savings after a late activation, +// and zeroed them after a late deactivation). + +test('attributes tokens to the mode active when each message happened (#601)', (tmp) => { + const now = Date.now(); + const iso = (minAgo) => new Date(now - minAgo * 60_000).toISOString(); + // 300 verbose tokens BEFORE caveman was activated, 350 after. + const sess = makeSession(tmp, [ + { type: 'assistant', timestamp: iso(60), message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 300 } } }, + { type: 'assistant', timestamp: iso(10), message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-mode-log.jsonl'), + JSON.stringify({ ts: now - 30 * 60_000, mode: 'full', prev: null }) + '\n'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // Only the 350 full-mode tokens earn an estimate: 350/0.35 = 1000 → 650. + // The old whole-session-at-current-mode math would claim 1,207 (inflated). + assert.match(out, /Est\. tokens saved:\s+650\b/); + assert.doesNotMatch(out, /1,207/); + assert.match(out, /Mode changed mid-session/); + assert.match(out, /caveman off:\s+300 tokens \(no benchmark estimate\)/); + assert.match(out, /full:\s+350 tokens \(est\. 650 saved\)/); + // The lifetime history row records the attributed figure, not the inflated one. + const hist = fs.readFileSync(path.join(claudeDir, '.caveman-history.jsonl'), 'utf8') + .split('\n').filter(Boolean).map(l => JSON.parse(l)); + assert.strictEqual(hist[hist.length - 1].est_saved_tokens, 650); +}); + +test('credits caveman spans even after mode is turned off mid-session (#601)', (tmp) => { + const now = Date.now(); + const iso = (minAgo) => new Date(now - minAgo * 60_000).toISOString(); + const sess = makeSession(tmp, [ + { type: 'assistant', timestamp: iso(60), message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + { type: 'assistant', timestamp: iso(10), message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 200 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-mode-log.jsonl'), + JSON.stringify({ ts: now - 90 * 60_000, mode: 'full', prev: null }) + '\n' + + JSON.stringify({ ts: now - 30 * 60_000, mode: null, prev: 'full' }) + '\n'); + // No .caveman-active flag — caveman is off at stats time. The old behavior + // printed "Caveman not active this session." and logged zero savings. + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.doesNotMatch(out, /Caveman not active this session/); + assert.match(out, /full:\s+350 tokens \(est\. 650 saved\)/); + assert.match(out, /Est\. tokens saved:\s+650\b/); +}); + +test('mode tracker logs timestamped transitions, deduping unchanged modes (#601)', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const run = (prompt) => execFileSync(process.execPath, [TRACKER], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp }, + input: JSON.stringify({ prompt }), + }); + const logPath = path.join(claudeDir, '.caveman-mode-log.jsonl'); + const rows = () => fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); + + run('/caveman ultra'); + run('/caveman ultra'); // unchanged — must not append a duplicate row + assert.strictEqual(rows().length, 1); + assert.strictEqual(rows()[0].mode, 'ultra'); + assert.strictEqual(rows()[0].prev, 'full'); + assert.ok(Number.isFinite(rows()[0].ts)); + + run('/caveman off'); // deactivation is a transition too + assert.strictEqual(rows().length, 2); + assert.strictEqual(rows()[1].mode, null); + assert.strictEqual(rows()[1].prev, 'ultra'); +}); + +test('excludes tokens that predate a mid-session flag write with no log (#601)', (tmp) => { + const now = Date.now(); + const sess = makeSession(tmp, [ + { type: 'assistant', timestamp: new Date(now - 60 * 60_000).toISOString(), message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + // Flag written NOW (after the message), no transition log: the mode during + // the message is unknown. The honest number is zero — say so, don't guess. + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /Est\. tokens saved:\s+0\b/); + assert.match(out, /unattributed:\s+350 tokens/); + assert.match(out, /excluded/); + assert.doesNotMatch(out, /Est\. without caveman/); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/test_compress_safety.py b/tests/test_compress_safety.py new file mode 100644 index 0000000..b11cf5c --- /dev/null +++ b/tests/test_compress_safety.py @@ -0,0 +1,93 @@ +"""Tests for the data-loss guards in `compress_file` (issue #237). + +The compress orchestrator used to overwrite the input even when Claude +returned an empty string or a no-op echo, and used to write a backup +without verifying that the bytes survived the round-trip. These tests +pin the new defensive checks: nothing on disk changes when the compressed +output is empty or identical to the input, and a backup-write that drops +bytes is detected before the input is overwritten. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "skills" / "caveman-compress")) + +from scripts import compress as compress_mod # noqa: E402 + + +class CompressSafetyTests(unittest.TestCase): + def _file_with(self, dirpath: Path, text: str) -> Path: + path = dirpath / "task.md" + path.write_text(text) + return path + + def test_empty_input_refused(self): + with tempfile.TemporaryDirectory() as tmp: + path = self._file_with(Path(tmp), "") + with mock.patch.object(compress_mod, "call_claude") as call: + ok = compress_mod.compress_file(path) + self.assertFalse(ok) + call.assert_not_called() + self.assertEqual(path.read_text(), "") + self.assertFalse((Path(tmp) / "task.original.md").exists()) + + def test_empty_compressed_output_does_not_touch_disk(self): + with tempfile.TemporaryDirectory() as tmp: + original = "# Heading\n\nSome long natural language paragraph that should be compressed.\n" + path = self._file_with(Path(tmp), original) + with mock.patch.object(compress_mod, "call_claude", return_value=""): + ok = compress_mod.compress_file(path) + self.assertFalse(ok) + self.assertEqual(path.read_text(), original) + self.assertFalse((Path(tmp) / "task.original.md").exists()) + + def test_whitespace_only_compressed_output_does_not_touch_disk(self): + with tempfile.TemporaryDirectory() as tmp: + original = "# Heading\n\nProse that should change.\n" + path = self._file_with(Path(tmp), original) + with mock.patch.object(compress_mod, "call_claude", return_value=" \n "): + ok = compress_mod.compress_file(path) + self.assertFalse(ok) + self.assertEqual(path.read_text(), original) + self.assertFalse((Path(tmp) / "task.original.md").exists()) + + def test_identical_compressed_output_does_not_touch_disk(self): + with tempfile.TemporaryDirectory() as tmp: + original = "# Heading\n\nProse.\n" + path = self._file_with(Path(tmp), original) + with mock.patch.object(compress_mod, "call_claude", return_value=original): + ok = compress_mod.compress_file(path) + self.assertFalse(ok) + self.assertEqual(path.read_text(), original) + self.assertFalse((Path(tmp) / "task.original.md").exists()) + + def test_real_compression_writes_backup_and_target(self): + # Isolate the backup data dir to a temp location so the out-of-tree + # backup (issue #420) never lands in the developer's real home dir. + with tempfile.TemporaryDirectory() as tmp, \ + tempfile.TemporaryDirectory() as data_home, \ + mock.patch.dict(os.environ, {"XDG_DATA_HOME": data_home, "LOCALAPPDATA": data_home}): + original = "# Heading\n\nThe quick brown fox jumps over the lazy dog.\n" + compressed = "# Heading\n\nFox jump dog.\n" + path = self._file_with(Path(tmp), original) + with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \ + mock.patch.object(compress_mod, "validate") as v: + v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[]) + ok = compress_mod.compress_file(path) + self.assertTrue(ok) + self.assertEqual(path.read_text(), compressed) + # Backups now live OUTSIDE the source dir (issue #420), under a + # platform-aware data dir mirroring the source parent name. + backup = compress_mod.backup_dir_for(path.resolve()) / "task.original.md" + self.assertEqual(backup.read_text(), original) + self.assertFalse((Path(tmp) / "task.original.md").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..a690711 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,100 @@ +"""Tests for detect.py file-type classification (issue #600). + +Extensionless build files (`Dockerfile`, `Makefile`) and shebang scripts +used to fall through to the content heuristic and come back as +compressible natural language — so `/caveman-compress Dockerfile` would +overwrite a Dockerfile with caveman prose. These tests pin the basename +and shebang guards. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "skills" / "caveman-compress")) + +from scripts.detect import detect_file_type, should_compress # noqa: E402 + + +DOCKERFILE_BODY = """FROM node:20-bookworm +WORKDIR /app +COPY package.json . +RUN npm install +CMD ["node", "index.js"] +""" + +MAKEFILE_BODY = """all: build + +build: +\tgo build -o bin/app ./cmd/app + +clean: +\trm -rf bin +""" + +SHEBANG_BODY = """#!/usr/bin/env bash +set -euo pipefail +echo "deploying" +""" + +PROSE_BODY = """This project collects notes about our deployment process. + +The main goal is to keep the steps simple enough that anyone on the +team can run a release without asking for help. Start by reading the +overview, then follow the checklist in order. +""" + + +class DetectFileTypeTests(unittest.TestCase): + def _write(self, dirpath, name, body): + p = Path(dirpath) / name + p.write_text(body, encoding="utf-8") + return p + + def test_dockerfile_is_code(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "Dockerfile", DOCKERFILE_BODY) + self.assertEqual(detect_file_type(p), "code") + self.assertFalse(should_compress(p)) + + def test_makefile_is_code(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "Makefile", MAKEFILE_BODY) + self.assertEqual(detect_file_type(p), "code") + self.assertFalse(should_compress(p)) + + def test_known_names_case_insensitive(self): + with tempfile.TemporaryDirectory() as tmp: + for name in ("dockerfile", "MAKEFILE", "Jenkinsfile", "Vagrantfile"): + p = self._write(tmp, name, "irrelevant body\n") + self.assertEqual(detect_file_type(p), "code", name) + + def test_cmakelists_txt_not_compressible_despite_txt_extension(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "CMakeLists.txt", "add_executable(app main.c)\n") + self.assertEqual(detect_file_type(p), "code") + self.assertFalse(should_compress(p)) + + def test_shebang_script_is_code(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "deploy", SHEBANG_BODY) + self.assertEqual(detect_file_type(p), "code") + self.assertFalse(should_compress(p)) + + def test_extensionless_prose_still_compressible(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "NOTES", PROSE_BODY) + self.assertEqual(detect_file_type(p), "natural_language") + self.assertTrue(should_compress(p)) + + def test_markdown_still_compressible(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "README.md", PROSE_BODY) + self.assertEqual(detect_file_type(p), "natural_language") + self.assertTrue(should_compress(p)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..4fd486d --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,219 @@ +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class HookScriptTests(unittest.TestCase): + def run_cmd(self, cmd, home, extra_env=None): + env = os.environ.copy() + env.pop("CLAUDE_PLUGIN_ROOT", None) + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + if extra_env: + env.update(extra_env) + return subprocess.run( + cmd, + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + + def test_install_upgrades_old_two_file_install(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-upgrade-") as tmp: + home = Path(tmp) + hooks_dir = home / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + (home / ".claude" / "settings.json").write_text("{}\n") + (hooks_dir / "caveman-activate.js").write_text("") + (hooks_dir / "caveman-mode-tracker.js").write_text("") + + self.run_cmd(["bash", "src/hooks/install.sh"], home) + + statusline = hooks_dir / "caveman-statusline.sh" + self.assertTrue(statusline.exists(), "upgrade should install statusline script") + + settings = json.loads((home / ".claude" / "settings.json").read_text()) + self.assertIn("statusLine", settings) + self.assertIn(str(statusline), settings["statusLine"]["command"]) + + def test_install_reconfigures_missing_statusline(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-statusline-") as tmp: + home = Path(tmp) + claude_dir = home / ".claude" + hooks_dir = claude_dir / "hooks" + hooks_dir.mkdir(parents=True) + + for name in ("caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh"): + (hooks_dir / name).write_text("") + + settings = { + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": f'node "{hooks_dir / "caveman-activate.js"}"', + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": f'node "{hooks_dir / "caveman-mode-tracker.js"}"', + } + ] + } + ], + } + } + (claude_dir / "settings.json").write_text(json.dumps(settings, indent=2) + "\n") + + result = self.run_cmd(["bash", "src/hooks/install.sh"], home) + + self.assertNotIn("Nothing to do", result.stdout) + + updated = json.loads((claude_dir / "settings.json").read_text()) + self.assertIn("statusLine", updated) + self.assertIn(str(hooks_dir / "caveman-statusline.sh"), updated["statusLine"]["command"]) + + def test_uninstall_preserves_custom_statusline(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-uninstall-") as tmp: + home = Path(tmp) + claude_dir = home / ".claude" + hooks_dir = claude_dir / "hooks" + hooks_dir.mkdir(parents=True) + + for name in ("caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh"): + (hooks_dir / name).write_text("") + + settings = { + "statusLine": { + "type": "command", + "command": "bash /tmp/custom-status-with-caveman.sh", + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": f'node "{hooks_dir / "caveman-activate.js"}"', + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": f'node "{hooks_dir / "caveman-mode-tracker.js"}"', + } + ] + } + ], + }, + } + (claude_dir / "settings.json").write_text(json.dumps(settings, indent=2) + "\n") + + self.run_cmd(["bash", "src/hooks/uninstall.sh"], home) + + updated = json.loads((claude_dir / "settings.json").read_text()) + self.assertEqual( + updated["statusLine"]["command"], + "bash /tmp/custom-status-with-caveman.sh", + ) + self.assertNotIn("hooks", updated) + + def test_activate_does_not_nudge_when_custom_statusline_exists(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-activate-") as tmp: + home = Path(tmp) + claude_dir = home / ".claude" + claude_dir.mkdir(parents=True) + (claude_dir / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": "bash /tmp/my-statusline.sh", + } + } + ) + + "\n" + ) + + result = self.run_cmd(["node", "src/hooks/caveman-activate.js"], home) + + self.assertNotIn("STATUSLINE SETUP NEEDED", result.stdout) + self.assertEqual((claude_dir / ".caveman-active").read_text(), "full") + + # Regression for #587/#589 — hook at /src/hooks/ must resolve SKILL.md + # at /skills/caveman/, not the nonexistent /src/skills/. + def test_activate_emits_skill_md_not_fallback_from_repo_layout(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-skillpath-") as tmp: + home = Path(tmp) + (home / ".claude").mkdir(parents=True) + + result = self.run_cmd(["node", "src/hooks/caveman-activate.js"], home) + + # Intensity table exists only in SKILL.md, never in the fallback + self.assertIn("## Intensity", result.stdout) + # Default mode is full — table filtered to the active level's row + self.assertIn("| **full** |", result.stdout) + self.assertNotIn("| **lite** |", result.stdout) + + def test_activate_finds_skill_beside_config_dir_hooks(self): + # Standalone layout: hooks at $CLAUDE_CONFIG_DIR/hooks/, skill installed + # at $CLAUDE_CONFIG_DIR/skills/caveman/SKILL.md + with tempfile.TemporaryDirectory(prefix="caveman-hooks-standalone-") as tmp: + home = Path(tmp) + claude_dir = home / ".claude" + hooks_dir = claude_dir / "hooks" + hooks_dir.mkdir(parents=True) + for name in ("caveman-activate.js", "caveman-config.js", "package.json"): + shutil.copy(REPO_ROOT / "src" / "hooks" / name, hooks_dir / name) + skill_dir = claude_dir / "skills" / "caveman" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: caveman\n---\nSTANDALONE MARKER RULESET\n" + ) + + result = self.run_cmd(["node", str(hooks_dir / "caveman-activate.js")], home) + + self.assertIn("STANDALONE MARKER RULESET", result.stdout) + + def test_activate_prefers_claude_plugin_root(self): + with tempfile.TemporaryDirectory(prefix="caveman-hooks-pluginroot-") as tmp: + home = Path(tmp) + (home / ".claude").mkdir(parents=True) + plugin_root = home / "plugin-cache" + skill_dir = plugin_root / "skills" / "caveman" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: caveman\n---\nPLUGIN ROOT MARKER RULESET\n" + ) + + result = self.run_cmd( + ["node", "src/hooks/caveman-activate.js"], + home, + extra_env={"CLAUDE_PLUGIN_ROOT": str(plugin_root)}, + ) + + self.assertIn("PLUGIN ROOT MARKER RULESET", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_shrink.js b/tests/test_mcp_shrink.js new file mode 100644 index 0000000..cbaadfa --- /dev/null +++ b/tests/test_mcp_shrink.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// Tests for src/mcp-servers/caveman-shrink/compress.js — pure-Node prose compressor. +// Run: node tests/test_mcp_shrink.js + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.resolve(__dirname, '..'); +const { compress, compressDescriptionsInPlace } = require( + path.join(ROOT, 'src', 'mcp-servers', 'caveman-shrink', 'compress.js') +); +const { getSpawnOptions } = require( + path.join(ROOT, 'src', 'mcp-servers', 'caveman-shrink', 'spawn-options.js') +); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}\n ${e.message}`); + } +} + +console.log('mcp-shrink compress tests\n'); + +test('drops articles', () => { + const { compressed } = compress('The user is the owner of an account'); + assert.match(compressed, /User is owner of account/i); + // No leftover lone "the" / "an" / "a" + assert.doesNotMatch(compressed, /\bthe\b/i); + assert.doesNotMatch(compressed, /\ban\b/i); +}); + +test('drops filler and pleasantries', () => { + const { compressed } = compress('Sure, this just basically returns the value'); + assert.doesNotMatch(compressed, /sure/i); + assert.doesNotMatch(compressed, /just/i); + assert.doesNotMatch(compressed, /basically/i); +}); + +test('drops hedging and "I will" leaders', () => { + const { compressed } = compress('I will perhaps connect to the database'); + assert.doesNotMatch(compressed, /perhaps/i); + assert.doesNotMatch(compressed, /^I will/i); + assert.match(compressed, /database/i); +}); + +test('preserves fenced code blocks verbatim', () => { + const input = 'Run the example: ```\nthe just sure return 1;\n``` and also more text'; + const { compressed } = compress(input); + // Inside the fence, "the just sure" must survive untouched. + assert.match(compressed, /```\nthe just sure return 1;\n```/); +}); + +test('preserves inline code verbatim', () => { + const input = 'Use `the just basically API` for fetching'; + const { compressed } = compress(input); + assert.match(compressed, /`the just basically API`/); +}); + +test('preserves URLs verbatim', () => { + const input = 'See the docs at https://example.com/the/just/api'; + const { compressed } = compress(input); + assert.match(compressed, /https:\/\/example\.com\/the\/just\/api/); +}); + +test('preserves filesystem paths verbatim', () => { + const input = 'Read just the file at /tmp/the/just/file.txt'; + const { compressed } = compress(input); + assert.match(compressed, /\/tmp\/the\/just\/file\.txt/); +}); + +test('preserves identifiers in CONST_CASE / dotted form', () => { + const input = 'Set the API_KEY_VALUE on the just config.api.endpoint()'; + const { compressed } = compress(input); + assert.match(compressed, /API_KEY_VALUE/); + assert.match(compressed, /config\.api\.endpoint\(\)/); +}); + +test('compresses real MCP-style description', () => { + const input = 'Get the current weather for a given location. ' + + 'Returns the temperature in Fahrenheit. ' + + 'Please make sure to provide the location as a city name.'; + const { compressed, before, after } = compress(input); + assert.ok(after < before, `expected size reduction, got ${before}→${after}`); + // ~30% reduction is the floor; descriptions like this should compress well. + assert.ok((before - after) / before > 0.15, `wanted >15% savings, got ${(before - after) / before}`); + // Substance preserved + assert.match(compressed, /weather/i); + assert.match(compressed, /Fahrenheit/i); + assert.match(compressed, /city name/i); +}); + +test('handles empty / null input gracefully', () => { + assert.deepStrictEqual(compress(''), { compressed: '', before: 0, after: 0 }); + const r = compress(null); + assert.strictEqual(r.compressed, null); +}); + +test('compressDescriptionsInPlace walks nested tools/list response', () => { + const payload = { + result: { + tools: [ + { name: 'get_weather', description: 'The function returns the current weather for a city.' }, + { name: 'send_email', description: 'Sends an email to a given recipient.' }, + ] + } + }; + compressDescriptionsInPlace(payload.result, ['description']); + assert.ok(!payload.result.tools[0].description.match(/\bthe\b/i), + `expected 'the' stripped, got: ${payload.result.tools[0].description}`); + assert.match(payload.result.tools[0].description, /weather/i); + assert.match(payload.result.tools[1].description, /email/i); +}); + +test('compressDescriptionsInPlace skips non-string description fields', () => { + const obj = { description: { not: 'a string' }, name: 'x' }; + // Should not throw. + compressDescriptionsInPlace(obj, ['description']); + assert.deepStrictEqual(obj.description, { not: 'a string' }); +}); + +// spawn-options: upstream MCP child process spawn flags. +// Confirms shell:true on Windows (so npx and other .cmd shims resolve) and +// shell:false on POSIX. + +test('win32 enables shell so npx and .cmd shims resolve', () => { + const opts = getSpawnOptions('win32'); + assert.equal(opts.shell, true); + assert.equal(opts.windowsHide, true); + assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']); +}); + +test('linux keeps shell off to avoid argv quoting surprises', () => { + const opts = getSpawnOptions('linux'); + assert.equal(opts.shell, false); + assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']); +}); + +test('darwin keeps shell off', () => { + const opts = getSpawnOptions('darwin'); + assert.equal(opts.shell, false); +}); + +test('defaults to current platform when no arg passed', () => { + const opts = getSpawnOptions(); + assert.equal(opts.shell, process.platform === 'win32'); + assert.equal(opts.windowsHide, true); + assert.deepEqual(opts.stdio, ['pipe', 'pipe', 'inherit']); +}); + +test('preserves enum values inside parens (nested sentinel restoration — #444)', () => { + // Path pattern matches "STARTER/BUSINESS" first (sentinel 0). + // Function-call pattern then matches "type ( 0 )" (sentinel 1). + // Single-pass restore replaces " 1 " with "type ( 0 )" but leaves the + // inner " 0 " unrestored — model sees "( 0 )" instead of the enum. + const cases = [ + { in: 'plan type (STARTER/BUSINESS)', needle: 'STARTER/BUSINESS' }, + { in: 'user role (ADMIN/MEMBER/GUEST)', needle: 'ADMIN/MEMBER/GUEST' }, + { in: 'user plan (Free/Pro/Business)', needle: 'Free/Pro/Business' }, + ]; + for (const c of cases) { + const { compressed } = compress(c.in); + assert.ok( + compressed.includes(c.needle), + `nested sentinel leaked: expected "${c.needle}" in output, got "${compressed}"` + ); + assert.doesNotMatch( + compressed, + / \d+ /, + `unrestored sentinel " N " left in output: "${compressed}"` + ); + } +}); + +// ── Packaging (#597) ──────────────────────────────────────────────────────── +// npm publish ships only what "files" lists (plus package.json/README/LICENSE, +// which npm always includes). A local module required by a shipped entry point +// but missing from "files" makes the published package crash with +// MODULE_NOT_FOUND at startup — exactly what happened with spawn-options.js. +// This static check walks every relative require reachable from the package's +// entry points (bin + main) and fails if any resolved file is not listed. +// The package is flat, so "files" entries are exact filenames, not globs. + +test('package.json "files" ships every module the entry points require (#597)', () => { + const pkgDir = path.join(ROOT, 'src', 'mcp-servers', 'caveman-shrink'); + const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); + const shipped = new Set((pkg.files || []).map(f => path.normalize(f))); + + const entries = [...Object.values(pkg.bin || {}), pkg.main] + .filter(Boolean) + .map(f => path.normalize(f)); + assert.ok(entries.length > 0, 'package has no entry points to check'); + + const seen = new Set(); + const queue = [...entries]; + while (queue.length > 0) { + const rel = queue.pop(); + if (seen.has(rel)) continue; + seen.add(rel); + assert.ok( + shipped.has(rel), + `"${rel}" is required by a shipped entry point but missing from package.json "files" — npm publish would ship a broken package` + ); + const src = fs.readFileSync(path.join(pkgDir, rel), 'utf8'); + for (const m of src.matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) { + let dep = path.normalize(path.join(path.dirname(rel), m[1])); + if (!dep.endsWith('.js') && !dep.endsWith('.json')) dep += '.js'; + queue.push(dep); + } + } +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/test_mode_tracker.py b/tests/test_mode_tracker.py new file mode 100644 index 0000000..9eb63ea --- /dev/null +++ b/tests/test_mode_tracker.py @@ -0,0 +1,196 @@ +"""Tests for caveman-mode-tracker.js prompt parsing (issues #598, #599). + +Drives the UserPromptSubmit hook with real prompts over stdin against an +isolated CLAUDE_CONFIG_DIR and asserts the flag-file state afterwards. + +#598: natural-language triggers misfired — "turn caveman mode off" +ACTIVATED caveman (and clobbered the level to default), "turn caveman off" +was a no-op, questions about caveman armed it, and vim's "normal mode" +deactivated it. + +#599: one-shot independent modes (/caveman-commit etc.) permanently +overwrote the active prose level, and the plugin-namespaced +/caveman:caveman-commit|-review variants were not recognized at all. +""" + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TRACKER = REPO_ROOT / "src" / "hooks" / "caveman-mode-tracker.js" + + +class ModeTrackerTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="caveman-tracker-") + self.claude_dir = Path(self._tmp.name) / ".claude" + self.claude_dir.mkdir(parents=True) + self.flag = self.claude_dir / ".caveman-active" + self.prev = self.claude_dir / ".caveman-active.prev" + + def tearDown(self): + self._tmp.cleanup() + + def send(self, prompt): + env = os.environ.copy() + env.pop("CAVEMAN_DEFAULT_MODE", None) + env["HOME"] = self._tmp.name + env["USERPROFILE"] = self._tmp.name + env["CLAUDE_CONFIG_DIR"] = str(self.claude_dir) + return subprocess.run( + ["node", str(TRACKER)], + cwd=REPO_ROOT, + env=env, + input=json.dumps({"prompt": prompt}), + text=True, + capture_output=True, + check=True, + ) + + def flag_value(self): + return self.flag.read_text() if self.flag.exists() else None + + # ── #598: deactivation word orders ────────────────────────────────── + + def test_turn_caveman_mode_off_deactivates(self): + # Pre-fix: this ACTIVATED caveman and downgraded ultra -> full. + self.flag.write_text("ultra") + self.send("turn caveman mode off") + self.assertIsNone(self.flag_value()) + + def test_turn_caveman_off_deactivates(self): + self.flag.write_text("full") + self.send("turn caveman off") + self.assertIsNone(self.flag_value()) + + def test_turn_off_caveman_deactivates(self): + self.flag.write_text("full") + self.send("turn off caveman") + self.assertIsNone(self.flag_value()) + + def test_stop_caveman_multiline_deactivates(self): + # Pre-fix: `.*` without the s flag never matched across lines. + self.flag.write_text("ultra") + self.send("stop\ncaveman") + self.assertIsNone(self.flag_value()) + + def test_normal_mode_command_deactivates(self): + self.flag.write_text("full") + self.send("normal mode") + self.assertIsNone(self.flag_value()) + + def test_back_to_normal_mode_deactivates(self): + self.flag.write_text("full") + self.send("back to normal mode please") + self.assertIsNone(self.flag_value()) + + def test_vim_normal_mode_does_not_deactivate(self): + self.flag.write_text("full") + self.send("how do I exit vim normal mode") + self.assertEqual(self.flag_value(), "full") + + # ── #598: activation guards ───────────────────────────────────────── + + def test_enable_caveman_with_stop_elsewhere_activates(self): + # Pre-fix: "stop" anywhere suppressed activation, then the + # deactivation regex matched "caveman and stop" and deleted the flag. + self.flag.write_text("full") + self.send("enable caveman and stop apologizing") + self.assertEqual(self.flag_value(), "full") + + def test_question_does_not_activate(self): + self.send("what is caveman mode?") + self.assertIsNone(self.flag_value()) + self.send("does caveman lite mode drop articles?") + self.assertIsNone(self.flag_value()) + + def test_scoped_brevity_does_not_activate(self): + self.send("be brief in the summary section") + self.assertIsNone(self.flag_value()) + + def test_unscoped_brevity_activates(self): + self.send("be brief") + self.assertEqual(self.flag_value(), "full") + + def test_activate_caveman_still_works(self): + self.send("activate caveman") + self.assertEqual(self.flag_value(), "full") + + def test_turn_on_caveman_mode_still_works(self): + self.send("turn on caveman mode") + self.assertEqual(self.flag_value(), "full") + + def test_talk_like_caveman_still_works(self): + self.send("talk like a caveman") + self.assertEqual(self.flag_value(), "full") + + def test_bare_caveman_mode_still_works(self): + self.send("caveman mode") + self.assertEqual(self.flag_value(), "full") + + # ── slash commands ────────────────────────────────────────────────── + + def test_slash_caveman_level_switch(self): + self.send("/caveman ultra") + self.assertEqual(self.flag_value(), "ultra") + + def test_slash_caveman_off(self): + self.flag.write_text("full") + self.send("/caveman off") + self.assertIsNone(self.flag_value()) + + # ── #599: one-shot independent modes ──────────────────────────────── + + def test_commit_restores_prior_level_on_next_prompt(self): + self.flag.write_text("ultra") + self.send("/caveman-commit") + self.assertEqual(self.flag_value(), "commit") + r = self.send("ordinary follow-up question") + self.assertEqual(self.flag_value(), "ultra") + self.assertIn("CAVEMAN MODE ACTIVE (ultra)", r.stdout) + + def test_commit_with_no_prior_mode_deactivates_after(self): + self.send("/caveman-commit") + self.assertEqual(self.flag_value(), "commit") + r = self.send("ordinary follow-up question") + self.assertIsNone(self.flag_value()) + self.assertNotIn("CAVEMAN MODE ACTIVE", r.stdout) + + def test_chained_independent_modes_keep_original_prev(self): + self.flag.write_text("wenyan-ultra") + self.send("/caveman-commit") + self.send("/caveman-review") + self.assertEqual(self.flag_value(), "review") + self.send("ordinary follow-up question") + self.assertEqual(self.flag_value(), "wenyan-ultra") + + def test_namespaced_commit_and_review_recognized(self): + # Pre-fix: only compress and stats had the /caveman:caveman- variant. + self.flag.write_text("full") + self.send("/caveman:caveman-commit") + self.assertEqual(self.flag_value(), "commit") + self.send("next prompt") # restore + self.send("/caveman:caveman-review") + self.assertEqual(self.flag_value(), "review") + + def test_no_reinforcement_during_independent_turn(self): + self.flag.write_text("full") + r = self.send("/caveman-commit") + self.assertNotIn("CAVEMAN MODE ACTIVE", r.stdout) + + def test_deactivation_clears_saved_prev(self): + self.flag.write_text("ultra") + self.send("/caveman-commit") + self.send("stop caveman") + self.assertIsNone(self.flag_value()) + self.assertFalse(self.prev.exists(), "prev file must not survive deactivation") + self.send("ordinary prompt") + self.assertIsNone(self.flag_value(), "nothing should resurrect the mode") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mode_tracker_stdin.js b/tests/test_mode_tracker_stdin.js new file mode 100644 index 0000000..49d7c6e --- /dev/null +++ b/tests/test_mode_tracker_stdin.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Tests for the stdin 'error' handler in caveman-mode-tracker.js. +// Covers issue #538: an abnormal stdin close (broken pipe, parent crash) emits +// an 'error' event on process.stdin; without a listener Node throws it as an +// uncaught exception and the hook exits non-zero — a spurious hook failure. +// +// Run: node tests/test_mode_tracker_stdin.js + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const HOOK_PATH = path.resolve(__dirname, '..', 'src', 'hooks', 'caveman-mode-tracker.js'); +const CLEAN_EXIT = 0; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}`); + console.error(` ${e.message}`); + } +} + +console.log('caveman-mode-tracker stdin error handling\n'); + +// Load the REAL hook in a child, then emit an 'error' on process.stdin to +// simulate an abnormal close. stdin is left open (never closed) so the only +// event that fires is the injected 'error' — isolating the handler under test. +function runWithStdinError() { + const harness = + `require(${JSON.stringify(HOOK_PATH)});` + + `setImmediate(() => process.stdin.emit('error', new Error('EPIPE (simulated)')));`; + return spawnSync(process.execPath, ['-e', harness], { + stdio: ['pipe', 'ignore', 'pipe'], + encoding: 'utf8', + }); +} + +test('stdin "error" event does not crash the hook (exit 0)', () => { + const res = runWithStdinError(); + assert.strictEqual( + res.status, + CLEAN_EXIT, + `expected clean exit on stdin error, got status=${res.status} signal=${res.signal}\n` + + `stderr: ${(res.stderr || '').trim()}` + ); + assert.ok( + !/Unhandled 'error' event/.test(res.stderr || ''), + `hook leaked an uncaught stdin error:\n${(res.stderr || '').trim()}` + ); +}); + +// Regression guard: the new listener must not disturb the normal path — a valid +// prompt piped on stdin, then a clean EOF, still exits 0. +test('normal stdin (valid JSON + clean EOF) still exits 0', () => { + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-stdin-')); + try { + const res = spawnSync(process.execPath, [HOOK_PATH], { + input: JSON.stringify({ prompt: 'hello there' }), + env: { ...process.env, CLAUDE_CONFIG_DIR: tmpConfig }, + stdio: ['pipe', 'ignore', 'pipe'], + encoding: 'utf8', + }); + assert.strictEqual( + res.status, + CLEAN_EXIT, + `expected clean exit on normal input, got status=${res.status}\n` + + `stderr: ${(res.stderr || '').trim()}` + ); + } finally { + fs.rmSync(tmpConfig, { recursive: true, force: true }); + } +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/test_repo_local_config.js b/tests/test_repo_local_config.js new file mode 100644 index 0000000..211053e --- /dev/null +++ b/tests/test_repo_local_config.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Tests for repo-local config resolution in getDefaultMode(). +// Covers the resolution-order contract: +// env CAVEMAN_DEFAULT_MODE → repo-local (.caveman/config.json or .caveman.json, +// walking up to filesystem root) → user config → 'full'. +// +// Run: node tests/test_repo_local_config.js + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); + +// Isolate from the host's real user config: point XDG_CONFIG_HOME at a tmp dir +// before requiring the module so getConfigPath() never reads the developer's +// own ~/.config/caveman/config.json. +const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-userhome-')); +process.env.XDG_CONFIG_HOME = tmpHome; +delete process.env.CAVEMAN_DEFAULT_MODE; + +const { getDefaultMode, findRepoConfigPath } = require('../src/hooks/caveman-config'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-repocfg-')); + const origCwd = process.cwd(); + const origEnv = process.env.CAVEMAN_DEFAULT_MODE; + try { + fn(tmpBase); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}`); + console.error(` ${e.message}`); + } finally { + process.chdir(origCwd); + if (origEnv === undefined) delete process.env.CAVEMAN_DEFAULT_MODE; + else process.env.CAVEMAN_DEFAULT_MODE = origEnv; + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +} + +console.log('repo-local config resolution tests\n'); + +test('returns "full" when no env, no repo config, no user config', (tmp) => { + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'full'); +}); + +test('reads .caveman/config.json in cwd', (tmp) => { + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), + JSON.stringify({ defaultMode: 'lite' })); + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'lite'); +}); + +test('reads .caveman.json in cwd', (tmp) => { + fs.writeFileSync(path.join(tmp, '.caveman.json'), + JSON.stringify({ defaultMode: 'ultra' })); + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'ultra'); +}); + +test('.caveman/config.json wins over .caveman.json at same level', (tmp) => { + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), + JSON.stringify({ defaultMode: 'lite' })); + fs.writeFileSync(path.join(tmp, '.caveman.json'), + JSON.stringify({ defaultMode: 'ultra' })); + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'lite'); +}); + +test('walks up from nested cwd to find repo config', (tmp) => { + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), + JSON.stringify({ defaultMode: 'wenyan-lite' })); + const nested = path.join(tmp, 'a', 'b', 'c'); + fs.mkdirSync(nested, { recursive: true }); + process.chdir(nested); + assert.strictEqual(getDefaultMode(), 'wenyan-lite'); +}); + +test('env var beats repo-local config', (tmp) => { + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), + JSON.stringify({ defaultMode: 'lite' })); + process.chdir(tmp); + process.env.CAVEMAN_DEFAULT_MODE = 'ultra'; + assert.strictEqual(getDefaultMode(), 'ultra'); +}); + +test('repo-local config beats user config', (tmp) => { + // user config at XDG_CONFIG_HOME points to 'commit' + fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'), + JSON.stringify({ defaultMode: 'commit' })); + // repo-local points to 'lite' + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), + JSON.stringify({ defaultMode: 'lite' })); + process.chdir(tmp); + try { + assert.strictEqual(getDefaultMode(), 'lite'); + } finally { + fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true }); + } +}); + +test('falls through to user config when repo config absent', (tmp) => { + fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'), + JSON.stringify({ defaultMode: 'review' })); + process.chdir(tmp); + try { + assert.strictEqual(getDefaultMode(), 'review'); + } finally { + fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true }); + } +}); + +test('invalid mode in repo config falls through to default', (tmp) => { + fs.writeFileSync(path.join(tmp, '.caveman.json'), + JSON.stringify({ defaultMode: 'definitely-not-a-mode' })); + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'full'); +}); + +test('malformed JSON in repo config falls through to default', (tmp) => { + fs.mkdirSync(path.join(tmp, '.caveman')); + fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), '{ not json'); + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'full'); +}); + +test('refuses symlinked .caveman.json (symmetric with readFlag policy)', (tmp) => { + const real = path.join(tmp, 'real-config.json'); + fs.writeFileSync(real, JSON.stringify({ defaultMode: 'ultra' })); + try { + fs.symlinkSync(real, path.join(tmp, '.caveman.json')); + } catch (e) { + // Skip on platforms without symlink perms + console.log(' (skipped: symlink not permitted)'); + return; + } + process.chdir(tmp); + assert.strictEqual(getDefaultMode(), 'full'); +}); + +test('findRepoConfigPath returns null outside any repo', (tmp) => { + process.chdir(tmp); + assert.strictEqual(findRepoConfigPath(tmp), null); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +fs.rmSync(tmpHome, { recursive: true, force: true }); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/test_symlink_flag.js b/tests/test_symlink_flag.js new file mode 100644 index 0000000..5194c81 --- /dev/null +++ b/tests/test_symlink_flag.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// Tests for safeWriteFlag / readFlag behavior with symlinked parent directories. +// Covers fix for issue #207: safeWriteFlag refuses flag writes when ~/.claude +// is a symlink. +// +// Run: node tests/test_symlink_flag.js + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); + +const { safeWriteFlag, readFlag, VALID_MODES } = require('../src/hooks/caveman-config'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-symlink-test-')); + try { + fn(tmpBase); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}`); + console.error(` ${e.message}`); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +} + +console.log('safeWriteFlag + readFlag symlink tests\n'); + +// ---------- safeWriteFlag ---------- + +test('writes flag in normal (non-symlinked) directory', (tmp) => { + const flagDir = path.join(tmp, 'claude-config'); + fs.mkdirSync(flagDir, { recursive: true }); + const flagPath = path.join(flagDir, '.caveman-active'); + + safeWriteFlag(flagPath, 'full'); + + assert.strictEqual(fs.readFileSync(flagPath, 'utf8'), 'full'); +}); + +test('writes flag when parent directory is a symlink owned by current user', (tmp) => { + // Create real directory and symlink to it (simulating ~/.claude -> /real/path) + const realDir = path.join(tmp, 'real-claude-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'claude-symlink'); + fs.symlinkSync(realDir, symlinkDir); + + const flagPath = path.join(symlinkDir, '.caveman-active'); + safeWriteFlag(flagPath, 'ultra'); + + // Flag should exist in the real directory + const realFlagPath = path.join(realDir, '.caveman-active'); + assert.strictEqual(fs.existsSync(realFlagPath), true, 'flag file should exist in resolved dir'); + assert.strictEqual(fs.readFileSync(realFlagPath, 'utf8'), 'ultra'); +}); + +test('readFlag works through symlinked parent directory', (tmp) => { + const realDir = path.join(tmp, 'real-claude-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'claude-symlink'); + fs.symlinkSync(realDir, symlinkDir); + + // Write directly to real path, then read through symlink path + const realFlagPath = path.join(realDir, '.caveman-active'); + fs.writeFileSync(realFlagPath, 'lite', { mode: 0o600 }); + + const result = readFlag(path.join(symlinkDir, '.caveman-active')); + assert.strictEqual(result, 'lite'); +}); + +test('safeWriteFlag then readFlag round-trip through symlink', (tmp) => { + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'link-config'); + fs.symlinkSync(realDir, symlinkDir); + + const flagPath = path.join(symlinkDir, '.caveman-active'); + safeWriteFlag(flagPath, 'wenyan-ultra'); + + // Read back through the same symlink path + const result = readFlag(flagPath); + assert.strictEqual(result, 'wenyan-ultra'); +}); + +test('refuses flag file that is itself a symlink (even through symlinked parent)', (tmp) => { + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'link-config'); + fs.symlinkSync(realDir, symlinkDir); + + // Create a symlink at the flag file location pointing to some other file + const decoyFile = path.join(tmp, 'decoy.txt'); + fs.writeFileSync(decoyFile, 'ATTACK'); + const realFlagPath = path.join(realDir, '.caveman-active'); + fs.symlinkSync(decoyFile, realFlagPath); + + // safeWriteFlag should refuse (flag file is a symlink) + safeWriteFlag(path.join(symlinkDir, '.caveman-active'), 'full'); + // The decoy should NOT have been overwritten + assert.strictEqual(fs.readFileSync(decoyFile, 'utf8'), 'ATTACK'); +}); + +test('readFlag refuses flag file that is a symlink', (tmp) => { + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + + const secretFile = path.join(tmp, 'secret.txt'); + fs.writeFileSync(secretFile, 'SSH_PRIVATE_KEY_CONTENT'); + fs.symlinkSync(secretFile, path.join(realDir, '.caveman-active')); + + const result = readFlag(path.join(realDir, '.caveman-active')); + assert.strictEqual(result, null, 'should refuse symlinked flag file'); +}); + +test('flag file permissions are 0600 when written through symlink', (tmp) => { + if (process.platform === 'win32') return; // skip on Windows + + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'link-config'); + fs.symlinkSync(realDir, symlinkDir); + + safeWriteFlag(path.join(symlinkDir, '.caveman-active'), 'full'); + + const realFlagPath = path.join(realDir, '.caveman-active'); + const stat = fs.statSync(realFlagPath); + const mode = stat.mode & 0o777; + assert.strictEqual(mode, 0o600, `expected 0600, got 0${mode.toString(8)}`); +}); + +test('overwrites existing flag through symlinked parent', (tmp) => { + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'link-config'); + fs.symlinkSync(realDir, symlinkDir); + + const flagPath = path.join(symlinkDir, '.caveman-active'); + + safeWriteFlag(flagPath, 'lite'); + assert.strictEqual(readFlag(flagPath), 'lite'); + + safeWriteFlag(flagPath, 'ultra'); + assert.strictEqual(readFlag(flagPath), 'ultra'); +}); + +test('creates parent directory via mkdirSync even when it does not exist yet', (tmp) => { + const flagDir = path.join(tmp, 'nonexistent', 'nested'); + const flagPath = path.join(flagDir, '.caveman-active'); + + safeWriteFlag(flagPath, 'full'); + + assert.strictEqual(fs.existsSync(flagPath), true); + assert.strictEqual(fs.readFileSync(flagPath, 'utf8'), 'full'); +}); + +test('symlink to nonexistent target silently fails', (tmp) => { + const symlinkDir = path.join(tmp, 'broken-link'); + try { + fs.symlinkSync('/nonexistent/path/that/does/not/exist', symlinkDir); + } catch (e) { + // Can't create symlink — skip + return; + } + + const flagPath = path.join(symlinkDir, '.caveman-active'); + // Should not throw + safeWriteFlag(flagPath, 'full'); + // Flag should not exist (target doesn't exist) + assert.strictEqual(fs.existsSync(path.join(symlinkDir, '.caveman-active')), false); +}); + +test('all valid modes round-trip through symlinked parent', (tmp) => { + const realDir = path.join(tmp, 'real-config'); + fs.mkdirSync(realDir, { recursive: true }); + const symlinkDir = path.join(tmp, 'link-config'); + fs.symlinkSync(realDir, symlinkDir); + + const flagPath = path.join(symlinkDir, '.caveman-active'); + + for (const mode of VALID_MODES) { + safeWriteFlag(flagPath, mode); + const read = readFlag(flagPath); + assert.strictEqual(read, mode, `mode '${mode}' did not round-trip`); + } +}); + +// ---------- Source code audit ---------- + +test('safeWriteFlag no longer has blanket symlink parent refusal', (tmp) => { + // Verify the old pattern "if (fs.lstatSync(flagDir).isSymbolicLink()) return;" + // without ownership check is no longer present + const source = fs.readFileSync( + path.join(__dirname, '..', 'src', 'hooks', 'caveman-config.js'), 'utf8' + ); + + // The old pattern: check isSymbolicLink on flagDir and immediately return + // New pattern: check isSymbolicLink, then realpathSync + ownership verification + const lines = source.split('\n'); + let foundSymlinkCheck = false; + let foundOwnershipCheck = false; + for (const line of lines) { + if (line.includes('isSymbolicLink()') && line.includes('flagDir')) { + // This is the lstat check on the parent dir — should NOT be a blanket return + foundSymlinkCheck = true; + } + if (line.includes('realpathSync') || line.includes('getuid') || line.includes('normalizedHome')) { + foundOwnershipCheck = true; + } + } + + assert.ok(foundOwnershipCheck, 'safeWriteFlag should include ownership/home-dir verification'); +}); + +// ---------- Summary ---------- + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/tests/test_validate_inline.py b/tests/test_validate_inline.py new file mode 100644 index 0000000..4fec1ad --- /dev/null +++ b/tests/test_validate_inline.py @@ -0,0 +1,87 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "skills" / "caveman-compress")) + +from scripts.validate import ( # noqa: E402 + ValidationResult, + extract_inline_codes, + validate, + validate_inline_codes, +) + + +class TestExtractInlineCodes(unittest.TestCase): + def test_fenced_blocks_excluded(self): + text = "```\ncode here\n```\n`inline code`" + result = extract_inline_codes(text) + self.assertEqual(result, ["inline code"]) + + def test_inline_only(self): + text = "Use `rm -rf /` to delete everything" + result = extract_inline_codes(text) + self.assertEqual(result, ["rm -rf /"]) + + def test_mixed_content(self): + text = """ +Some text with `inline1` and `inline2`. + +``` +code block +``` + +More text with `inline3`. +""" + result = extract_inline_codes(text) + self.assertEqual(set(result), {"inline1", "inline2", "inline3"}) + + def test_empty(self): + self.assertEqual(extract_inline_codes("no backticks here"), []) + + +class TestValidateInlineCodes(unittest.TestCase): + def test_match(self): + result = ValidationResult() + validate_inline_codes("use `cmd` here", "use `cmd` here", result) + self.assertTrue(result.is_valid) + + def test_lost(self): + result = ValidationResult() + validate_inline_codes("use `cmd` here", "use here", result) + self.assertFalse(result.is_valid) + self.assertIn("Inline code lost", result.errors[0]) + + def test_added(self): + result = ValidationResult() + validate_inline_codes("use here", "use `new` here", result) + self.assertTrue(result.is_valid) + self.assertIn("Inline code added", result.warnings[0]) + + def test_empty_orig(self): + result = ValidationResult() + validate_inline_codes("no codes", "use `new` here", result) + self.assertTrue(result.is_valid) + + def test_both_empty(self): + result = ValidationResult() + validate_inline_codes("plain text", "also plain", result) + self.assertTrue(result.is_valid) + + +class TestValidateIntegration(unittest.TestCase): + def test_validate_inline_codes_wired(self): + with tempfile.TemporaryDirectory() as tmp: + orig = Path(tmp) / "original.md" + comp = Path(tmp) / "compressed.md" + orig.write_text("Run `rm -rf /` to delete") + comp.write_text("Run to delete") + result = validate(orig, comp) + self.assertFalse(result.is_valid) + self.assertTrue(any("Inline code lost" in e for e in result.errors)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/verify_repo.py b/tests/verify_repo.py new file mode 100644 index 0000000..38f89d2 --- /dev/null +++ b/tests/verify_repo.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Local verification runner for caveman install surfaces.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class CheckFailure(RuntimeError): + pass + + +def section(title: str) -> None: + print(f"\n== {title} ==") + + +def ensure(condition: bool, message: str) -> None: + if not condition: + raise CheckFailure(message) + + +def run( + args: list[str], + *, + cwd: Path = ROOT, + env: dict[str, str] | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + # Keep Python subprocess output decodable on Windows when the CLI prints Unicode. + merged_env.setdefault("PYTHONIOENCODING", "utf-8") + if env: + merged_env.update(env) + result = subprocess.run( + args, + cwd=cwd, + env=merged_env, + text=True, + encoding="utf-8", + capture_output=True, + check=False, + ) + if check and result.returncode != 0: + raise CheckFailure( + f"Command failed ({result.returncode}): {' '.join(args)}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + return result + + +def read_json(path: Path) -> object: + return json.loads(path.read_text(encoding="utf-8")) + + +def shell_path(path: Path) -> str: + return str(path).replace("\\", "/") if os.name == "nt" else str(path) + + +def _frontmatter_description(path: Path) -> str: + lines = path.read_text(encoding="utf-8").splitlines() + ensure(lines and lines[0] == "---", f"{path} missing YAML frontmatter") + + description_lines: list[str] = [] + collecting = False + block_indent: int | None = None + for line in lines[1:]: + if line == "---": + break + if collecting: + stripped = line.strip() + if not stripped: + description_lines.append("") + continue + indent = len(line) - len(line.lstrip(" \t")) + if block_indent is None: + if indent == 0: + break + block_indent = indent + elif indent < block_indent: + break + description_lines.append(stripped) + continue + if line.startswith("description:"): + value = line.split(":", 1)[1].strip() + # Folded (>) and literal (|) block scalars, with optional chomping (-/+). + if value and value[0] in ("|", ">"): + collecting = True + continue + return value.strip("'\"") + return " ".join(part for part in description_lines if part) + + +def verify_skill_frontmatter_upload_compatibility() -> None: + section("Skill Frontmatter Upload Compatibility") + + skill_paths = [ + ROOT / "skills/caveman/SKILL.md", + ROOT / "skills/caveman-commit/SKILL.md", + ROOT / "skills/caveman-help/SKILL.md", + ROOT / "skills/caveman-review/SKILL.md", + ROOT / "skills/caveman-compress/SKILL.md", + ] + for path in skill_paths: + description = _frontmatter_description(path) + ensure( + "<" not in description and ">" not in description, + f"{path} description contains XML-like angle brackets", + ) + + print("Skill frontmatter descriptions avoid XML-like tags") + + +def verify_synced_files() -> None: + section("Synced Files") + skill_source = ROOT / "skills/caveman/SKILL.md" + + skill_copies = [ + ROOT / "plugins/caveman/skills/caveman/SKILL.md", + ] + for copy in skill_copies: + ensure( + copy.read_text(encoding="utf-8") == skill_source.read_text(encoding="utf-8"), + f"Skill copy mismatch: {copy}", + ) + + with zipfile.ZipFile(ROOT / "dist" / "caveman.skill") as archive: + ensure("caveman/SKILL.md" in archive.namelist(), "caveman.skill missing caveman/SKILL.md") + ensure( + archive.read("caveman/SKILL.md").decode("utf-8") + == skill_source.read_text(encoding="utf-8"), + "caveman.skill payload mismatch", + ) + + ensure( + (ROOT / "bin" / "install.js").exists(), + "bin/install.js missing — package.json bin entry would break npx caveman", + ) + ensure( + (ROOT / "bin" / "lib" / "settings.js").exists(), + "bin/lib/settings.js missing — installer would crash on JSONC settings.json", + ) + + print("Synced copies, caveman.skill zip, and installer entrypoints OK") + + +def verify_manifests_and_syntax() -> None: + section("Manifests And Syntax") + + manifest_paths = [ + ROOT / ".claude-plugin/plugin.json", + ROOT / ".claude-plugin/marketplace.json", + ROOT / ".codex/hooks.json", + ROOT / "gemini-extension.json", + ROOT / "plugins/caveman/.codex-plugin/plugin.json", + ] + for path in manifest_paths: + read_json(path) + + run(["node", "--check", "src/hooks/caveman-config.js"]) + run(["node", "--check", "src/hooks/caveman-activate.js"]) + run(["node", "--check", "src/hooks/caveman-mode-tracker.js"]) + run(["node", "--check", "src/hooks/cavecrew-model-overrides.js"]) + run(["node", "--check", "bin/install.js"]) + run(["node", "--check", "bin/lib/settings.js"]) + run(["bash", "-n", "src/hooks/install.sh"]) + run(["bash", "-n", "src/hooks/uninstall.sh"]) + run(["bash", "-n", "src/hooks/caveman-statusline.sh"]) + + # Ensure install/uninstall scripts include caveman-config.js + install_sh = (ROOT / "src/hooks/install.sh").read_text(encoding="utf-8") + uninstall_sh = (ROOT / "src/hooks/uninstall.sh").read_text(encoding="utf-8") + ensure("caveman-config.js" in install_sh, "install.sh missing caveman-config.js") + ensure("caveman-config.js" in uninstall_sh, "uninstall.sh missing caveman-config.js") + + print("JSON manifests and JS/bash syntax OK") + + +def verify_powershell_static() -> None: + section("PowerShell Static Checks") + install_text = (ROOT / "src/hooks/install.ps1").read_text(encoding="utf-8") + uninstall_text = (ROOT / "src/hooks/uninstall.ps1").read_text(encoding="utf-8") + statusline_text = (ROOT / "src/hooks/caveman-statusline.ps1").read_text(encoding="utf-8") + + ensure("caveman-config.js" in install_text, "install.ps1 missing caveman-config.js") + ensure("caveman-config.js" in uninstall_text, "uninstall.ps1 missing caveman-config.js") + ensure("caveman-statusline.ps1" in install_text, "install.ps1 missing statusline.ps1") + ensure("caveman-statusline.ps1" in uninstall_text, "uninstall.ps1 missing statusline.ps1") + ensure("-AsHashtable" not in install_text, "install.ps1 should stay compatible with Windows PowerShell 5.1") + ensure( + "powershell -ExecutionPolicy Bypass -File" in install_text, + "install.ps1 missing PowerShell statusline command", + ) + ensure("[CAVEMAN" in statusline_text, "caveman-statusline.ps1 missing badge output") + + print("Windows install path statically wired") + + +def load_compress_modules(): + sys.path.insert(0, str(ROOT / "skills/caveman-compress")) + import scripts.benchmark # noqa: F401 + import scripts.cli as cli + import scripts.compress # noqa: F401 + import scripts.detect as detect + import scripts.validate as validate + + return cli, detect, validate + + +def verify_compress_fixtures() -> None: + section("Compress Fixtures") + _, detect, validate = load_compress_modules() + + fixtures = sorted((ROOT / "tests/caveman-compress").glob("*.original.md")) + ensure(fixtures, "No caveman-compress fixtures found") + + for original in fixtures: + compressed = original.with_name(original.name.replace(".original.md", ".md")) + ensure(compressed.exists(), f"Missing compressed fixture for {original.name}") + result = validate.validate(original, compressed) + ensure(result.is_valid, f"Fixture validation failed for {compressed.name}: {result.errors}") + ensure(detect.should_compress(compressed), f"Fixture should be compressible: {compressed.name}") + + print(f"Validated {len(fixtures)} caveman-compress fixture pairs") + + +def verify_compress_cli() -> None: + section("Compress CLI") + + skip_result = run( + ["python3", "-m", "scripts", "../../src/hooks/install.sh"], + cwd=ROOT / "skills/caveman-compress", + check=False, + ) + ensure(skip_result.returncode == 0, "compress CLI skip path should exit 0") + ensure("Detected: code" in skip_result.stdout, "compress CLI skip path missing detection output") + ensure( + "Skipping: file is not natural language" in skip_result.stdout, + "compress CLI skip path missing skip output", + ) + + missing_result = run( + ["python3", "-m", "scripts", "../../does-not-exist.md"], + cwd=ROOT / "skills/caveman-compress", + check=False, + ) + ensure(missing_result.returncode == 1, "compress CLI missing-file path should exit 1") + ensure("File not found" in missing_result.stdout, "compress CLI missing-file output mismatch") + + print("Compress CLI skip/error paths OK") + + +def verify_hook_install_flow() -> None: + section("Claude Hook Flow") + + ensure(shutil.which("node") is not None, "node is required for hook verification") + ensure(shutil.which("bash") is not None, "bash is required for hook verification") + + with tempfile.TemporaryDirectory(prefix="caveman-verify-") as temp_root: + temp_root_path = Path(temp_root) + home = temp_root_path / "home" + claude_dir = home / ".claude" + claude_dir.mkdir(parents=True) + + existing_settings = { + "statusLine": {"type": "command", "command": "bash /tmp/existing-statusline.sh"}, + "hooks": {"Notification": [{"hooks": [{"type": "command", "command": "echo keep-me"}]}]}, + } + (claude_dir / "settings.json").write_text(json.dumps(existing_settings, indent=2) + "\n") + hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)} + + run(["bash", "src/hooks/install.sh"], env=hook_env) + + settings = read_json(claude_dir / "settings.json") + hooks = settings["hooks"] + ensure(settings["statusLine"]["command"] == "bash /tmp/existing-statusline.sh", "install.sh clobbered existing statusLine") + ensure("SessionStart" in hooks, "SessionStart hook missing after install") + ensure("UserPromptSubmit" in hooks, "UserPromptSubmit hook missing after install") + + activate = run( + ["node", "src/hooks/caveman-activate.js"], + env=hook_env, + ) + ensure("CAVEMAN MODE ACTIVE" in activate.stdout, "activation output missing caveman banner") + ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "activation should stay quiet when custom statusline exists") + ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "full", "activation flag should default to full") + + # Test configurable default mode via CAVEMAN_DEFAULT_MODE env var + activate_custom = run( + ["node", "src/hooks/caveman-activate.js"], + env={**hook_env, "CAVEMAN_DEFAULT_MODE": "ultra"}, + ) + ensure("CAVEMAN MODE ACTIVE" in activate_custom.stdout, "activation with custom default missing banner") + ensure( + (claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra", + "CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra", + ) + # Test "off" mode — activation skipped, flag removed + activate_off = run( + ["node", "src/hooks/caveman-activate.js"], + env={**hook_env, "CAVEMAN_DEFAULT_MODE": "off"}, + ) + ensure("CAVEMAN MODE ACTIVE" not in activate_off.stdout, "off mode should not emit caveman banner") + ensure(not (claude_dir / ".caveman-active").exists(), "off mode should remove flag file") + + # Test mode tracker with /caveman when default is off — should NOT write flag + subprocess.run( + ["node", "src/hooks/caveman-mode-tracker.js"], + cwd=ROOT, + env={**os.environ, **hook_env, "CAVEMAN_DEFAULT_MODE": "off"}, + text=True, + encoding="utf-8", + input='{"prompt":"/caveman"}', + capture_output=True, + check=True, + ) + ensure(not (claude_dir / ".caveman-active").exists(), "/caveman with off default should not write flag") + + # Reset back to full for subsequent tests + (claude_dir / ".caveman-active").write_text("full") + + run( + ["node", "src/hooks/caveman-mode-tracker.js"], + env=hook_env, + check=True, + ) + + ultra_prompt = subprocess.run( + ["node", "src/hooks/caveman-mode-tracker.js"], + cwd=ROOT, + env={**os.environ, **hook_env}, + text=True, + encoding="utf-8", + input='{"prompt":"/caveman ultra"}', + capture_output=True, + check=True, + ) + ensure( + "CAVEMAN MODE ACTIVE (ultra)" in ultra_prompt.stdout, + "mode tracker should emit active-mode reinforcement", + ) + ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra", "mode tracker did not record ultra") + + subprocess.run( + ["node", "src/hooks/caveman-mode-tracker.js"], + cwd=ROOT, + env={**os.environ, **hook_env}, + text=True, + encoding="utf-8", + input='{"prompt":"normal mode"}', + capture_output=True, + check=True, + ) + ensure(not (claude_dir / ".caveman-active").exists(), "normal mode should remove flag file") + + (claude_dir / ".caveman-active").write_text("wenyan-ultra") + statusline = run( + ["bash", "src/hooks/caveman-statusline.sh"], + env=hook_env, + ) + ensure("[CAVEMAN:WENYAN-ULTRA]" in statusline.stdout, "statusline badge output mismatch") + + reinstall = run(["bash", "src/hooks/install.sh"], env=hook_env) + ensure("Nothing to do" in reinstall.stdout, "install.sh should be idempotent") + + run(["bash", "src/hooks/uninstall.sh"], env=hook_env) + settings_after = read_json(claude_dir / "settings.json") + ensure(settings_after == existing_settings, "uninstall.sh did not restore non-caveman settings") + ensure(not (claude_dir / ".caveman-active").exists(), "uninstall.sh should remove flag file") + + with tempfile.TemporaryDirectory(prefix="caveman-verify-fresh-") as temp_root: + home = Path(temp_root) / "home" + claude_dir = home / ".claude" + hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)} + run(["bash", "src/hooks/install.sh"], env=hook_env) + settings = read_json(claude_dir / "settings.json") + ensure("statusLine" in settings, "fresh install should configure statusline") + activate = run(["node", "src/hooks/caveman-activate.js"], env=hook_env) + ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "fresh install should not nudge for statusline") + run(["bash", "src/hooks/uninstall.sh"], env=hook_env) + ensure(read_json(claude_dir / "settings.json") == {}, "fresh uninstall should leave empty settings") + + print("Claude hook install/uninstall flow OK") + + +def main() -> int: + checks = [ + verify_skill_frontmatter_upload_compatibility, + verify_synced_files, + verify_manifests_and_syntax, + verify_powershell_static, + verify_compress_fixtures, + verify_compress_cli, + verify_hook_install_flow, + ] + + try: + for check in checks: + check() + except CheckFailure as exc: + print(f"\nFAIL: {exc}", file=sys.stderr) + return 1 + + print("\nAll local verification checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())