mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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 <id>` 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8b8068d8ca
commit
7b2bed2d0b
@@ -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 <agent>`. 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/<name>/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 `<!-- caveman-begin -->` / `<!-- caveman-end -->` 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/<name>/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.
|
||||
|
||||
+26
@@ -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.
|
||||
|
||||
@@ -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 ← <!-- caveman-begin --> ... <!-- caveman-end -->
|
||||
↑ 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
|
||||
|
||||
|
||||
+53
-1
@@ -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 <id> the user
|
||||
// is explicitly opting in, so trust them and let the per-provider installer
|
||||
// bail itself if its preconditions aren't met (e.g. opencode bails when
|
||||
// no repo clone is available; openclaw bails when the workspace dir is
|
||||
// missing without --force).
|
||||
if (!explicit(prov.id) && !detectMatch(prov.detect)) continue;
|
||||
if (prov.id === 'claude') { 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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <workspace>/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 <workspace>/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 = '<!-- caveman-begin -->';
|
||||
const MARK_END = '<!-- caveman-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<begin>...\n<end>\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,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- caveman-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.
|
||||
<!-- caveman-end -->
|
||||
@@ -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 <agent>]
|
||||
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++;
|
||||
|
||||
@@ -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, /<!-- caveman-begin -->/, 'SOUL.md missing begin marker');
|
||||
assert.match(soulRaw, /<!-- caveman-end -->/, '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(/<!-- caveman-begin -->/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-begin -->/, '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-begin -->/, 'caveman block survived uninstall');
|
||||
assert.doesNotMatch(soulAfter, /<!-- caveman-end -->/, '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.
|
||||
|
||||
Reference in New Issue
Block a user