mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
Marketplace fix (#712, #705): Claude Desktop rejects plugins containing a top-level bin/ directory, and .claude-plugin/marketplace.json packages the repo root, so the installer directory is now cli/. Every reference updated (package.json bin entry + files, shims, docs, tests, caveman-init require path). Supersedes PR #726. Security (PR #717 verified): quoteWinArg only quoted on whitespace/quotes, leaving cmd.exe metacharacters (& | ^ < > % parens) unescaped on the shell:true Windows spawn path. Attacker-influenced arguments (--with-init cwd, --with-mcp-shrink value) could chain commands. Trigger regex now covers the metacharacter set; quoting logic split into a platform- independent, unit-tested helper. Also: - uninstall removes .caveman-active.prev, .caveman-mode-log.jsonl, .caveman-statusline-suffix, .caveman-nudge-shown; keeps .caveman-history.jsonl with a printed note; dry-run now says 'would remove' instead of lying (#635, supersedes PRs #693 #636) - Array.isArray guard in rewriteLegacyManagedHookCommands — malformed hook event no longer crashes the installer mid-run (supersedes PR #646) - gemini extensions install --consent: the security prompt hung every piped/non-interactive install forever (#676, part of PR #664) - OpenClaw skill stamps the real PINNED_REF version instead of hardcoded 1.0.0; new --no-always flag for load-on-demand installs (supersedes PR #720) - shims scope NPM_CONFIG_ALLOW_GIT=all to the npx call — npm >=12 defaults allow-git to none and EALLOWGITs github: installs (#698) - .codex/config.toml ships hooks + codex_hooks keys so auto-activation works on both sides of the codex-cli rename (#617) - caveman-help card shows the Windows config path (%APPDATA%) (#723) - caveman-parse.js added to HOOK_FILES, opencode payload (.cjs), and the regenerated checksums.sha256; manifest now matches shipped hook contents — release must bump PINNED_REF to a tag containing these files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ySX6TBWZuvFze4ajf7Hpf
319 lines
13 KiB
JavaScript
319 lines
13 KiB
JavaScript
// 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);
|
|
}
|
|
|
|
// `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,
|
|
};
|