From 7b2bed2d0b7237ebc8f3e980258e5a8bdd821da1 Mon Sep 17 00:00:00 2001 From: Julius Brussee Date: Sun, 10 May 2026 15:18:00 +0200 Subject: [PATCH] feat(openclaw): add OpenClaw as a first-class agent target OpenClaw is a self-hosted gateway that orchestrates multiple agents and loads workspace skills on-demand. To make caveman always-on through it we write a spec-correct skill folder plus a marker-fenced bootstrap block in SOUL.md (which OpenClaw auto-injects every turn). Both writes are idempotent and reachable from `bin/install.js --only openclaw` and `caveman-init.js --only openclaw`. Side-effect: `--only ` now bypasses the detect-match guard so explicit opt-in works for any provider whose preconditions can't be probed (e.g. custom OPENCLAW_WORKSPACE paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 4 + INSTALL.md | 26 +++ README.md | 51 +++-- bin/install.js | 54 ++++- bin/lib/openclaw.js | 254 ++++++++++++++++++++++ src/rules/caveman-openclaw-bootstrap.md | 18 ++ src/tools/caveman-init.js | 54 ++++- tests/installer/e2e.freshinstall.test.mjs | 139 ++++++++++++ 8 files changed, 570 insertions(+), 30 deletions(-) create mode 100644 bin/lib/openclaw.js create mode 100644 src/rules/caveman-openclaw-bootstrap.md diff --git a/CLAUDE.md b/CLAUDE.md index 210799c..720e041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ caveman/ |------|-----------------| | `skills/caveman/SKILL.md` | Caveman behavior: intensity levels, rules, wenyan mode, auto-clarity, persistence. Only file to edit for behavior changes. | | `src/rules/caveman-activate.md` | Always-on auto-activation rule body. Consumed by `src/tools/caveman-init.js` when a user runs `npx caveman --only `. Edit here, not in any per-agent rule copy. | +| `src/rules/caveman-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. | @@ -239,6 +241,7 @@ How caveman reaches each agent type: | 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 | Per-user `.cursor/rules/caveman.mdc` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only cursor`) | | Windsurf | Per-user `.windsurf/rules/caveman.md` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only windsurf`) | | Cline | Per-user `.clinerules/caveman.md` written by `src/tools/caveman-init.js` | Yes — Cline auto-discovers `.clinerules/` | @@ -286,6 +289,7 @@ To reproduce: `uv run python benchmarks/run.py` (needs `ANTHROPIC_API_KEY` in `. - 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. diff --git a/INSTALL.md b/INSTALL.md index 389f757..c5ee6c1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -40,6 +40,7 @@ If you want to install for one agent (or want to know exactly what command runs | **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) | | **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Yes (in this repo via `.codex/`) | | **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Yes (rule file) | | **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Yes (rule file) | @@ -110,6 +111,21 @@ Useful flags: | `--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`, `AGENTS.md`). Single source: [`src/rules/caveman-activate.md`](src/rules/caveman-activate.md). + ## Verify After install, three quick checks: @@ -157,6 +173,16 @@ What it does **not** remove: ## 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. diff --git a/README.md b/README.md index a1a1038..918437d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Pick your level of grunt — `lite` (drop filler), `full` (default caveman), `ul ## Install -**One line. Detect every agent. Install for each.** +One line. Find every agent. Install for each. ```bash # macOS / Linux / WSL / Git Bash @@ -88,27 +88,12 @@ curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install. irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex ``` -Detects 30+ agents, runs each one's native install, skips what you not have. Safe to re-run. +~30 seconds. Skip agent you no have. Safe to re-run. -**Manual install, top agents:** +**Trigger:** type `/caveman` or say "talk like caveman". Stop with "normal mode". -| Agent | Command | -|---|---| -| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` | -| **Codex** | `npx skills add JuliusBrussee/caveman -a codex` | -| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` | -| **opencode** | `npx -y github:JuliusBrussee/caveman -- --only opencode` | -| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | -| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | -| **Cline** | `npx skills add JuliusBrussee/caveman -a cline` | - -See [**INSTALL.md**](./INSTALL.md) for 25+ more agents (Copilot, Continue, Roo, Augment, Goose, Junie, Trae, Warp, Tabnine, Mistral, Qwen, Devin, Droid, Replit, Antigravity, …) and all installer flags. - -**Trigger it:** - -- `/caveman` (or Codex `$caveman`) -- "talk like caveman" / "caveman mode" / "less tokens please" -- Stop with: "stop caveman" / "normal mode" +One agent only, manual command, or any of 30+ other agents → [**INSTALL.md**](./INSTALL.md). +Install break? Open agent, say *"Read CLAUDE.md and INSTALL.md, install caveman for me."* Agent fix own brain. ## What You Get @@ -174,17 +159,31 @@ A March 2026 paper ["Brevity Constraints Reverse Performance Hierarchies in Lang Maintainer detail (hook architecture, file ownership, CI sync) live in [CLAUDE.md](./CLAUDE.md). -## Want It Always On? +## Lobster, Meet Rock 🦞🪨 -For agents without hook system (Cursor, Windsurf, Cline, Copilot, etc.), drop a rule file: +[**OpenClaw**](https://openclaw.ai) the self-host gateway. One box, many agent inside (Claude Code, Codex, Pi, OpenCode), wired to your Slack / Discord / iMessage / Telegram / whatever. Tagline: *"The lobster way."* Lobster strong. Lobster smart. Lobster also talk a lot. + +Caveman teach lobster brevity: ```bash -# In your repo root -curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/src/rules/caveman-activate.md \ - > .cursor/rules/caveman.mdc # or .windsurf/rules/, .clinerules/, .github/copilot-instructions.md +npx -y github:JuliusBrussee/caveman -- --only openclaw ``` -Or run `install.sh --with-init` and we drop them in for you. Full list of rule-file targets in [INSTALL.md](./INSTALL.md#always-on-rules). +Two thing happen, no more: + +1. **Skill drop** at `~/.openclaw/workspace/skills/caveman/SKILL.md` — spec-correct frontmatter (`version`, `always: true`), discoverable by `openclaw skills list`. Skill not auto-inject (OpenClaw load skill on demand) — that why we also do step 2. +2. **SOUL.md nudge.** Tiny marker-fenced block appended to `~/.openclaw/workspace/SOUL.md`. OpenClaw inject SOUL.md into *every* turn under "Project Context" (12K-per-file, 60K total — block well under). Lobster terse from message one. No `/caveman` per session. No nag. + +``` +~/.openclaw/workspace/ +├── skills/caveman/SKILL.md ← full ruleset, on-demand load +└── SOUL.md ← ... + ↑ auto-inject every turn +``` + +Custom workspace path? `OPENCLAW_WORKSPACE=/your/path` before the command. Uninstall: same one-liner with `--uninstall` — skill folder gone, SOUL.md block ripped out cleanly, your other workspace content stay untouched. Idempotent re-runs (frontmatter not double-prepended, marker block not duplicated). + +Lobster claw still sharp. Lobster mouth now small. Brain still big. ## Caveman Ecosystem diff --git a/bin/install.js b/bin/install.js index cdd9dd1..1a4f30b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -22,6 +22,7 @@ const child_process = require('child_process'); const readline = require('readline'); const SETTINGS = require('./lib/settings'); +const OPENCLAW = require('./lib/openclaw'); const REPO = 'JuliusBrussee/caveman'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; @@ -147,6 +148,7 @@ 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 @@ -602,6 +604,37 @@ function installOpencode(ctx) { 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. function installHooks(ctx) { @@ -880,6 +913,19 @@ function uninstall(ctx) { 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'); + } + // Flag file const flag = path.join(configDir, '.caveman-active'); if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} } @@ -1013,10 +1059,16 @@ async function main() { for (const prov of PROVIDERS) { if (!want(prov.id)) continue; if (prov.soft && !explicit(prov.id)) continue; - if (!detectMatch(prov.detect)) 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') { 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.profile) { installViaSkills(ctx, prov); continue; } } diff --git a/bin/lib/openclaw.js b/bin/lib/openclaw.js new file mode 100644 index 0000000..3ee844d --- /dev/null +++ b/bin/lib/openclaw.js @@ -0,0 +1,254 @@ +// 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 ───────────────────────────────────── +function appendBootstrapToSoul(soulPath, snippet) { + const existing = readIfExists(soulPath); + if (existing && existing.includes(MARK_BEGIN) && existing.includes(MARK_END)) { + return { changed: false, reason: 'already present' }; + } + let next; + if (existing && existing.length) { + const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); + next = existing + sep + snippet; + } else { + next = snippet; + } + fs.writeFileSync(soulPath, next, { mode: 0o644 }); + return { changed: true }; +} + +function stripBootstrapFromSoul(soulPath) { + const existing = readIfExists(soulPath); + if (!existing) return { changed: false, reason: 'no SOUL.md' }; + const begin = existing.indexOf(MARK_BEGIN); + const end = existing.indexOf(MARK_END); + if (begin === -1 || end === -1 || end <= begin) return { changed: false, reason: 'no marker block' }; + const before = existing.slice(0, begin); + const after = existing.slice(end + MARK_END.length); + // Collapse adjacent blank lines around the cut so we don't leave a triple + // newline scar from `\n\n...\n\n\n`. + let next = (before.replace(/\n+$/, '\n') + after.replace(/^\n+/, '\n')).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/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 index 49b6dce..1359fb9 100644 --- a/src/tools/caveman-init.js +++ b/src/tools/caveman-init.js @@ -34,6 +34,16 @@ 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, ~75% fewer tokens, full technical accuracy"\nalwaysApply: true\n---\n\n', @@ -53,6 +63,11 @@ const AGENTS = [ { 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() { @@ -65,6 +80,9 @@ function loadRuleBody() { } 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); @@ -99,6 +117,35 @@ function processAgent(agent, targetDir, ruleBody, opts) { 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++) { @@ -120,7 +167,7 @@ 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}`).join('\n')} +${AGENTS.map(a => ` ${a.id.padEnd(10)} ${a.file || a.description || ''}`).join('\n')} Flags: --dry-run show what would change, do not write @@ -141,8 +188,9 @@ function main() { for (const agent of AGENTS) { if (opts.only && opts.only !== agent.id) continue; const result = processAgent(agent, opts.target, ruleBody, opts); - console.log(` ${result.label} ${agent.file} (${result.status})`); - if (result.status === 'added') counts.added++; + 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++; diff --git a/tests/installer/e2e.freshinstall.test.mjs b/tests/installer/e2e.freshinstall.test.mjs index df12acc..cf9028a 100644 --- a/tests/installer/e2e.freshinstall.test.mjs +++ b/tests/installer/e2e.freshinstall.test.mjs @@ -234,6 +234,145 @@ test('install tolerates JSONC settings.json (comments + trailing commas)', { ski } }); +// ── 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.