// 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); } // `opts.version` defaults to SKILL_VERSION (the '1.0.0' fallback) when the // caller doesn't have a better one on hand — cli/install.js threads through // PINNED_REF (its release-tag source of truth) instead so the two never // drift. `opts.always` defaults to true (existing behavior); pass `false` // (from --no-always) to omit the `always: true` key entirely — the skill // then loads on demand instead of always-on. function mergeOpenclawFrontmatter(src, opts = {}) { const version = opts.version || SKILL_VERSION; const always = opts.always !== false; const { frontmatter, body } = splitFrontmatter(src); const additions = []; if (!frontmatterHasKey(frontmatter, 'name')) additions.push(`name: ${SKILL_NAME}`); if (!frontmatterHasKey(frontmatter, 'version')) additions.push(`version: ${version}`); if (always && !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 ──────────────────────────────────────────────────────────── // `version` — bare semver stamped into the skill frontmatter; defaults to // SKILL_VERSION if the caller doesn't pass one (see mergeOpenclawFrontmatter). // `always` — default true (existing behavior). Pass false (--no-always) to // skip the `always: true` frontmatter key AND the SOUL.md bootstrap append, // so the skill installs load-on-demand instead of always-on. function installOpenclaw({ workspace, repoRoot, dryRun = false, force = false, log = noopLog(), version, always = true } = {}) { 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 ? '/always' : ''} frontmatter)`); if (always) { log.note(` would ${fs.existsSync(soulFile) ? 'append to' : 'create'} ${soulFile} (caveman bootstrap block)`); } else { log.note(' --no-always: would skip SOUL.md bootstrap append (skill loads on demand)'); } return { ok: true, dryRun: true }; } fs.mkdirSync(skillDir, { recursive: true }); const merged = mergeOpenclawFrontmatter(skillBody, { version, always }); fs.writeFileSync(skillFile, merged, { mode: 0o644 }); log.write(` installed: ${skillFile}\n`); if (always) { const soul = appendBootstrapToSoul(soulFile, snippet); if (soul.changed) log.write(` wrote bootstrap block to ${soulFile}\n`); else log.note(` ${soulFile} already contains caveman bootstrap`); } else { log.note(' --no-always: skipped SOUL.md bootstrap append (skill loads on demand via `openclaw skills list`)'); } 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, };