Add sandbox template image with Claude configuration and plugins
build / build (push) Canceled after 0s

Carries CLAUDE.md, AGENTS.md, hooks and skills verbatim from the host, plus a
manifest of the 10 marketplaces and 17 plugins to reinstall at build time. The
plugin directories themselves are not committed: ~/.claude/plugins is 831 MB and
sits alongside credentials and transcripts, so the image is reproduced from the
manifest instead and the build needs no access to the host.

The Gitea registry is behind Cloudflare, which rejects request bodies over 100 MB
against a base image with a 325 MB layer, so the workflow pushes chunked through
regctl rather than docker push.

sbx v0.37.0 and v0.37.1 cannot consume the result: layers stacked on the base are
silently dropped (docker/sbx-releases#366). The image builds and pushes correctly
and is a no-op at runtime until that is fixed, so README points at
'ai:sbx setup' as the mechanism that works today.
This commit is contained in:
2026-07-31 08:18:30 -05:00
commit c89e4f0568
22 changed files with 2726 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
// caveman — Claude Code SessionStart activation hook
//
// Runs on every session start:
// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.caveman-active (statusline reads this)
// 2. Emits caveman ruleset as hidden SessionStart context
// 3. Detects missing statusline config and emits setup nudge
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode, safeWriteFlag } = require('./caveman-config');
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
const settingsPath = path.join(claudeDir, 'settings.json');
const mode = getDefaultMode();
// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {}
process.stdout.write('OK');
process.exit(0);
}
// 1. Write flag file (symlink-safe)
safeWriteFlag(flagPath, mode);
// 2. Emit full caveman ruleset, filtered to the active intensity level.
// The old 2-sentence summary was too weak — models drifted back to verbose
// mid-conversation, especially after context compression pruned it away.
// Full rules with examples anchor behavior much more reliably.
//
// Reads SKILL.md at runtime so edits to the source of truth propagate
// automatically — no hardcoded duplication to go stale.
// Modes that have their own independent skill files — not caveman intensity levels.
// For these, emit a short activation line; the skill itself handles behavior.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
if (INDEPENDENT_MODES.has(mode)) {
process.stdout.write('CAVEMAN MODE ACTIVE — level: ' + mode + '. Behavior defined by /caveman-' + mode + ' skill.');
process.exit(0);
}
// Resolve the canonical label for wenyan alias
const modeLabel = mode === 'wenyan' ? 'wenyan-full' : mode;
// Read SKILL.md — the single source of truth for caveman behavior.
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/caveman/SKILL.md
// Standalone installs: __dirname = $CLAUDE_CONFIG_DIR/hooks/, SKILL.md won't exist — falls back to hardcoded rules.
let skillContent = '';
try {
skillContent = fs.readFileSync(
path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md'), 'utf8'
);
} catch (e) { /* standalone install — will use fallback below */ }
let output;
if (skillContent) {
// Strip YAML frontmatter
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
// Filter intensity table: keep header rows + only the active level's row
const filtered = body.split('\n').reduce((acc, line) => {
// Intensity table rows start with | **level** |
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
if (tableRowMatch) {
// Keep only the active level's row (and always keep header/separator)
if (tableRowMatch[1] === modeLabel) {
acc.push(line);
}
return acc;
}
// Example lines start with "- level:" — keep only lines matching active level
const exampleMatch = line.match(/^- (\S+?):\s/);
if (exampleMatch) {
if (exampleMatch[1] === modeLabel) {
acc.push(line);
}
return acc;
}
acc.push(line);
return acc;
}, []);
output = 'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' + filtered.join('\n');
} else {
// Fallback when SKILL.md is not found (standalone hook install without skills dir).
// This is the minimum viable ruleset — better than nothing.
output =
'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' +
'Respond terse like smart caveman. All technical substance stay. Only fluff die.\n\n' +
'## Persistence\n\n' +
'ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".\n\n' +
'Current level: **' + modeLabel + '**. Switch: `/caveman lite|full|ultra`.\n\n' +
'## Rules\n\n' +
'Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. ' +
'Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.\n\n' +
'Pattern: `[thing] [action] [reason]. [next step].`\n\n' +
'Not: "Sure! I\'d be happy to help you with that. The issue you\'re experiencing is likely caused by..."\n' +
'Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"\n\n' +
'## Auto-Clarity\n\n' +
'Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.\n\n' +
'## Boundaries\n\n' +
'Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.';
}
// 3. Detect missing statusline config — nudge Claude to help set it up
try {
let hasStatusline = false;
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
if (settings.statusLine) {
hasStatusline = true;
}
}
if (!hasStatusline) {
const isWindows = process.platform === 'win32';
const scriptName = isWindows ? 'caveman-statusline.ps1' : 'caveman-statusline.sh';
const scriptPath = path.join(__dirname, scriptName);
const command = isWindows
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
: `bash "${scriptPath}"`;
const statusLineSnippet =
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
output += "\n\n" +
"STATUSLINE SETUP NEEDED: The caveman plugin includes a statusline badge showing active mode " +
"(e.g. [CAVEMAN], [CAVEMAN:ULTRA]). It is not configured yet. " +
"To enable, add this to " + path.join(claudeDir, 'settings.json') + ": " +
statusLineSnippet + " " +
"Proactively offer to set this up for the user on first interaction.";
}
} catch (e) {
// Silent fail — don't block session start over statusline detection
}
process.stdout.write(output);
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
// caveman — shared configuration resolver
//
// Resolution order for default mode:
// 1. CAVEMAN_DEFAULT_MODE environment variable
// 2. Config file defaultMode field:
// - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set)
// - ~/.config/caveman/config.json (macOS / Linux fallback)
// - %APPDATA%\caveman\config.json (Windows fallback)
// 3. 'full'
const fs = require('fs');
const path = require('path');
const os = require('os');
const VALID_MODES = [
'off', 'lite', 'full', 'ultra',
'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra',
'commit', 'review', 'compress'
];
function getConfigDir() {
if (process.env.XDG_CONFIG_HOME) {
return path.join(process.env.XDG_CONFIG_HOME, 'caveman');
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'caveman'
);
}
return path.join(os.homedir(), '.config', 'caveman');
}
function getConfigPath() {
return path.join(getConfigDir(), 'config.json');
}
function getDefaultMode() {
// 1. Environment variable (highest priority)
const envMode = process.env.CAVEMAN_DEFAULT_MODE;
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
return envMode.toLowerCase();
}
// 2. Config file
try {
const configPath = getConfigPath();
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
return config.defaultMode.toLowerCase();
}
} catch (e) {
// Config file doesn't exist or is invalid — fall through
}
// 3. Default
return 'full';
}
// Symlink-safe flag file write.
// Uses O_NOFOLLOW where available, writes atomically via temp + rename with
// 0600 permissions. Protects against local attackers replacing the predictable
// flag path (~/.claude/.caveman-active) with a symlink to clobber other files.
//
// When the parent directory is itself a symlink (legitimate pattern: ~/.claude
// symlinked to another drive or shared config dir), resolves through to the
// real path and verifies ownership on Unix (uid match). This allows e.g.
// ln -s /opt/shared-claude-config ~/.claude
// while still refusing attacker-planted symlinks pointing to dirs owned by
// another user.
//
// On Windows, uid checks are unavailable — falls back to verifying the resolved
// path lives under the user's home directory.
//
// The flag file itself must never be a symlink (that's the actual clobber vector).
//
// Set CAVEMAN_DEBUG=1 to emit stderr diagnostics when flag writes are refused.
//
// Silent-fails on any filesystem error — the flag is best-effort.
function safeWriteFlag(flagPath, content) {
const debug = process.env.CAVEMAN_DEBUG === '1';
try {
const flagDir = path.dirname(flagPath);
fs.mkdirSync(flagDir, { recursive: true });
// When the parent directory is a symlink, resolve it and verify ownership.
// This allows legitimate symlinked ~/.claude dirs while still refusing
// attacker-planted symlinks pointing at dirs owned by another user.
let realFlagDir;
try {
const lstat = fs.lstatSync(flagDir);
if (lstat.isSymbolicLink()) {
realFlagDir = fs.realpathSync(flagDir);
const realStat = fs.statSync(realFlagDir);
if (!realStat.isDirectory()) {
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not a directory\n`);
return;
}
if (typeof process.getuid === 'function') {
if (realStat.uid !== process.getuid()) {
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`);
return;
}
} else {
const home = os.homedir();
const normalizedReal = path.resolve(realFlagDir);
const normalizedHome = path.resolve(home);
if (!normalizedReal.toLowerCase().startsWith(normalizedHome.toLowerCase() + path.sep) &&
normalizedReal.toLowerCase() !== normalizedHome.toLowerCase()) {
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${normalizedReal} is outside home directory ${normalizedHome}\n`);
return;
}
}
} else {
realFlagDir = flagDir;
}
} catch (e) {
return;
}
// The flag file itself must never be a symlink (that's the actual clobber vector).
const realFlagPath = path.join(realFlagDir, path.basename(flagPath));
try {
if (fs.lstatSync(realFlagPath).isSymbolicLink()) return;
} catch (e) {
if (e.code !== 'ENOENT') return;
}
const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
let fd;
try {
fd = fs.openSync(tempPath, flags, 0o600);
fs.writeSync(fd, String(content));
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
fs.renameSync(tempPath, realFlagPath);
} catch (e) {
// Silent fail — flag is best-effort
}
}
// Symlink-safe, size-capped, whitelist-validated flag file read.
// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read,
// and rejects anything that isn't a known mode. Returns null on any anomaly.
//
// Without this, a local attacker with write access to ~/.claude/ could replace
// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every
// reader — statusline, per-turn reinforcement — would slurp that content and
// either echo it to the terminal or inject it into model context.
//
// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is "wenyan-ultra"
// (12 bytes); 64 leaves slack without enabling exfil.
const MAX_FLAG_BYTES = 64;
function readFlag(flagPath) {
try {
let st;
try {
st = fs.lstatSync(flagPath);
} catch (e) {
return null;
}
if (st.isSymbolicLink() || !st.isFile()) return null;
if (st.size > MAX_FLAG_BYTES) return null;
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
let fd;
let out;
try {
fd = fs.openSync(flagPath, flags);
const buf = Buffer.alloc(MAX_FLAG_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
out = buf.slice(0, n).toString('utf8');
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
const raw = out.trim().toLowerCase();
if (!VALID_MODES.includes(raw)) return null;
return raw;
} catch (e) {
return null;
}
}
// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag,
// but opens with O_APPEND so concurrent writers from different sessions don't
// clobber each other. Used for the lifetime stats log
// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl).
//
// Silent-fails on any filesystem error.
function appendFlag(filePath, line) {
const debug = process.env.CAVEMAN_DEBUG === '1';
try {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
let realDir;
try {
const lstat = fs.lstatSync(dir);
if (lstat.isSymbolicLink()) {
realDir = fs.realpathSync(dir);
const realStat = fs.statSync(realDir);
if (!realStat.isDirectory()) return;
if (typeof process.getuid === 'function') {
if (realStat.uid !== process.getuid()) {
if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`);
return;
}
} else {
const home = os.homedir();
const normalized = path.resolve(realDir).toLowerCase();
const normalizedHome = path.resolve(home).toLowerCase();
if (!normalized.startsWith(normalizedHome + path.sep) && normalized !== normalizedHome) return;
}
} else {
realDir = dir;
}
} catch (e) {
return;
}
const realPath = path.join(realDir, path.basename(filePath));
try {
if (fs.lstatSync(realPath).isSymbolicLink()) return;
} catch (e) {
if (e.code !== 'ENOENT') return;
}
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW;
let fd;
try {
fd = fs.openSync(realPath, flags, 0o600);
fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n');
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
} catch (e) {
// Silent fail — history is best-effort
}
}
// Symlink-safe history read. Returns lines (untrimmed) or empty array on any
// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap
// the way readFlag does — history is expected to grow with use.
function readHistory(filePath) {
try {
const st = fs.lstatSync(filePath);
if (st.isSymbolicLink() || !st.isFile()) return [];
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
let fd;
let raw;
try {
fd = fs.openSync(filePath, flags);
raw = fs.readFileSync(fd, 'utf8');
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
return raw.split('\n').filter(line => line.trim());
} catch (e) {
return [];
}
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory };
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// caveman — UserPromptSubmit hook to track which caveman mode is active
// Inspects user input for /caveman commands and writes mode to flag file
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = require('./caveman-config');
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
// selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const prompt = (data.prompt || '').trim().toLowerCase();
// Natural language activation (e.g. "activate caveman", "turn on caveman mode",
// "talk like caveman"). README tells users they can say these, but the hook
// only matched /caveman commands — flag file and statusline stayed out of sync.
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) {
const mode = getDefaultMode();
if (mode !== 'off') {
safeWriteFlag(flagPath, mode);
}
}
}
// /caveman-stats [--share] — block the prompt and inject stats output as
// the hook's reason. The script reads the active session log, so we pass
// transcript_path through when Claude Code provides it.
const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt);
if (statsMatch) {
const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean);
try {
const statsPath = path.join(__dirname, 'caveman-stats.js');
const argv = [statsPath];
if (data.transcript_path) argv.push('--session-file', data.transcript_path);
if (tailArgs.includes('--share')) argv.push('--share');
if (tailArgs.includes('--all')) argv.push('--all');
const sinceIdx = tailArgs.indexOf('--since');
if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) {
argv.push('--since', tailArgs[sinceIdx + 1]);
}
const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 });
process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() }));
} catch (e) {
process.stdout.write(JSON.stringify({
decision: 'block',
reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js'
}));
}
return;
}
// Match /caveman commands
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
const arg = parts[1] || '';
let mode = null;
if (cmd === '/caveman-commit') {
mode = 'commit';
} else if (cmd === '/caveman-review') {
mode = 'review';
} else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
mode = 'compress';
} else if (cmd === '/caveman' || cmd === '/caveman:caveman') {
// Bare /caveman → activate at configured default
if (!arg) {
mode = getDefaultMode();
} else if (arg === 'off' || arg === 'stop' || arg === 'disable') {
mode = 'off';
} else if (arg === 'wenyan-full') {
// Canonical alias — config stores as 'wenyan'
mode = 'wenyan';
} else if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) {
mode = arg;
}
// Unknown arg → mode stays null, flag untouched (no silent overwrite)
}
if (mode && mode !== 'off') {
safeWriteFlag(flagPath, mode);
} else if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {}
}
}
// Detect deactivation — natural language and slash commands
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
/\bnormal mode\b/i.test(prompt)) {
try { fs.unlinkSync(flagPath); } catch (e) {}
}
// Per-turn reinforcement: emit a structured reminder when caveman is active.
// The SessionStart hook injects the full ruleset once, but models lose it
// when other plugins inject competing style instructions every turn.
// This keeps caveman visible in the model's attention on every user message.
//
// Skip independent modes (commit, review, compress) — they have their own
// skill behavior and the base caveman rules would conflict.
// readFlag enforces symlink-safe read + size cap + VALID_MODES whitelist.
// If the flag is missing, corrupted, oversized, or a symlink pointing at
// something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing
// — never inject untrusted bytes into model context.
const activeMode = readFlag(flagPath);
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal."
}
}));
}
} catch (e) {
// Silent fail
}
});
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
// caveman-stats — read the active Claude Code session log, print real token
// usage plus an estimated savings figure from the benchmark in benchmarks/.
//
// Run directly: node hooks/caveman-stats.js
// Inside Claude: /caveman-stats triggers this via the UserPromptSubmit hook.
// Hook integration passes --session-file <transcript_path> so we always read
// the active session, not whichever JSONL was modified most recently.
const fs = require('fs');
const path = require('path');
const os = require('os');
const { readFlag, appendFlag, readHistory, safeWriteFlag } = require('./caveman-config');
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
// wenyan modes show no estimate until benchmarked. Add an entry here when a new
// run is committed.
const COMPRESSION = { 'full': 0.65 };
// Approximate Anthropic public output-token pricing, USD per million.
// Match by model id prefix so this stays correct across point releases
// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from
// https://www.anthropic.com/pricing if a release changes the tier.
const MODEL_OUTPUT_PRICE_PER_M = [
['claude-opus-4', 75.00],
['claude-sonnet-4', 15.00],
['claude-haiku-4', 4.00],
['claude-3-5-sonnet', 15.00],
['claude-3-5-haiku', 4.00],
['claude-3-opus', 75.00],
];
function priceForModel(model) {
if (!model) return null;
for (const [prefix, price] of MODEL_OUTPUT_PRICE_PER_M) {
if (model.startsWith(prefix)) return price;
}
return null;
}
function formatUsd(amount) {
if (amount >= 1) return `$${amount.toFixed(2)}`;
if (amount >= 0.01) return `$${amount.toFixed(3)}`;
return `$${amount.toFixed(4)}`;
}
function findRecentSession(claudeDir) {
const projectsDir = path.join(claudeDir, 'projects');
let entries;
try { entries = fs.readdirSync(projectsDir, { withFileTypes: true }); }
catch { return null; }
let best = null;
const stack = entries.map(e => path.join(projectsDir, e.name));
while (stack.length) {
const p = stack.pop();
let st;
try { st = fs.statSync(p); } catch { continue; }
if (st.isDirectory()) {
try {
for (const child of fs.readdirSync(p)) stack.push(path.join(p, child));
} catch {}
} else if (p.endsWith('.jsonl') && (!best || st.mtimeMs > best.mtime)) {
best = { file: p, mtime: st.mtimeMs };
}
}
return best ? best.file : null;
}
function parseSession(filePath) {
let raw;
try { raw = fs.readFileSync(filePath, 'utf8'); }
catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null }; }
let outputTokens = 0;
let cacheReadTokens = 0;
let turns = 0;
let model = null;
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.type !== 'assistant' || !entry.message) continue;
const usage = entry.message.usage;
if (!usage) continue;
outputTokens += usage.output_tokens || 0;
cacheReadTokens += usage.cache_read_input_tokens || 0;
turns++;
if (!model && entry.message.model) model = entry.message.model;
}
return { outputTokens, cacheReadTokens, turns, model };
}
// Detect *.original.md / *.md pairs left behind by caveman-compress. The
// presence of a *.original.md backup means the *.md sibling is a compressed
// memory file — every session start reads the compressed version, so the
// delta is per-session input-token savings (passive). Returns a summary or
// null if nothing was found in the given dirs.
function findCompressedPairs(dirs) {
const pairs = [];
for (const dir of dirs) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.original.md')) continue;
const base = entry.name.slice(0, -'.original.md'.length);
const originalPath = path.join(dir, entry.name);
const compressedPath = path.join(dir, `${base}.md`);
let oSize, cSize;
try {
oSize = fs.statSync(originalPath).size;
cSize = fs.statSync(compressedPath).size;
} catch { continue; }
if (oSize <= cSize) continue;
pairs.push({ name: base, dir, originalSize: oSize, compressedSize: cSize });
}
}
return pairs;
}
function summarizeCompressed(pairs) {
if (!pairs || pairs.length === 0) return null;
const totalOriginal = pairs.reduce((s, p) => s + p.originalSize, 0);
const totalCompressed = pairs.reduce((s, p) => s + p.compressedSize, 0);
const bytesSaved = totalOriginal - totalCompressed;
// English prose runs ~4 chars per token. Label result as approximate so we
// don't make claims tighter than the method warrants.
const tokensSaved = Math.round(bytesSaved / 4);
return { count: pairs.length, bytesSaved, tokensSaved };
}
// Compute the savings figures we want to log/share for one session snapshot.
function deriveSavings({ outputTokens, mode, model }) {
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
const price = priceForModel(model);
if (ratio === null) return { estSavedTokens: 0, estSavedUsd: 0 };
const estNormal = Math.round(outputTokens / (1 - ratio));
const estSavedTokens = estNormal - outputTokens;
const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0;
return { estSavedTokens, estSavedUsd };
}
// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input.
function parseDuration(spec) {
if (!spec) return null;
const m = /^(\d+)([dh])$/.exec(spec.trim());
if (!m) return null;
const n = parseInt(m[1], 10);
return m[2] === 'd' ? n * 86_400_000 : n * 3_600_000;
}
// Aggregate history into latest-per-session totals, optionally filtered to a
// time window. Returns { sessions, outputTokens, estSavedTokens, estSavedUsd }.
function aggregateHistory(historyPath, sinceMs) {
const lines = readHistory(historyPath);
const cutoff = sinceMs ? Date.now() - sinceMs : null;
const latestPerSession = new Map();
for (const line of lines) {
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (!entry || typeof entry !== 'object') continue;
if (cutoff !== null && (entry.ts || 0) < cutoff) continue;
const id = entry.session_id || '_';
const prev = latestPerSession.get(id);
if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry);
}
let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0;
for (const e of latestPerSession.values()) {
outputTokens += e.output_tokens || 0;
estSavedTokens += e.est_saved_tokens || 0;
estSavedUsd += e.est_saved_usd || 0;
}
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd };
}
function humanizeTokens(n) {
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(Math.round(n));
}
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) {
const sep = '──────────────────────────────────';
const window = since ? ` (last ${since})` : '';
if (sessions === 0) {
return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`;
}
const usdLine = estSavedUsd > 0 ? `Est. saved (USD): ~${formatUsd(estSavedUsd)}\n` : '';
return `\nCaveman Stats — Lifetime${window}\n${sep}\n` +
`Sessions: ${sessions.toLocaleString()}\n${sep}\n` +
`Output tokens: ${outputTokens.toLocaleString()}\n` +
`Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` +
usdLine + sep + '\n';
}
// Single-line tweetable summary. Stays human-friendly when no ratio is known.
function formatShare({ outputTokens, turns, mode, model }) {
if (turns === 0) {
return '🪨 caveman armed but no turns yet — caveman.sh';
}
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
const price = priceForModel(model);
if (ratio !== null) {
const estSaved = Math.round(outputTokens / (1 - ratio)) - outputTokens;
let usd = '';
if (price !== null) {
const amt = (estSaved / 1_000_000) * price;
usd = ` (~${formatUsd(amt)})`;
}
return `🪨 Saved ${estSaved.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`;
}
return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`;
}
// Pure formatter — separated from main() so tests can pass synthetic inputs.
function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed }) {
const sep = '──────────────────────────────────';
const shortPath = sessionPath && sessionPath.length > 45
? '...' + sessionPath.slice(-45)
: (sessionPath || '');
if (turns === 0) {
return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`;
}
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
const price = priceForModel(model);
let savings;
let footer = '';
if (ratio !== null) {
const estNormal = Math.round(outputTokens / (1 - ratio));
const estSaved = estNormal - outputTokens;
let usdLine = '';
if (price !== null) {
const usd = (estSaved / 1_000_000) * price;
usdLine = `Est. saved (USD): ~${formatUsd(usd)}\n`;
footer = `Savings est. from benchmarks/ (mean per-task). Pricing for ${model}. Actual varies by task.`;
} else {
footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.';
}
savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` +
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)\n` +
usdLine.replace(/\n$/, '');
} else if (mode && mode !== 'off') {
savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
} else {
savings = 'Caveman not active this session.';
}
let memoryLine = '';
if (compressed && compressed.count > 0) {
const tokensApprox = compressed.tokensSaved.toLocaleString();
memoryLine = `${sep}\nMemory compressed: ${compressed.count} file${compressed.count === 1 ? '' : 's'}, ` +
`~${tokensApprox} tokens saved per session start (approx)\n`;
}
return `\nCaveman Stats\n${sep}\n` +
(shortPath ? `Session: ${shortPath}\n` : '') +
`Turns: ${turns}\n${sep}\n` +
`Output tokens: ${outputTokens.toLocaleString()}\n` +
`Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` +
`${savings}\n` +
memoryLine +
(footer ? footer + '\n' : '');
}
function main() {
const args = process.argv.slice(2);
const i = args.indexOf('--session-file');
const sessionFileArg = i !== -1 ? args[i + 1] : null;
const share = args.includes('--share');
const all = args.includes('--all');
const sinceIdx = args.indexOf('--since');
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const historyPath = path.join(claudeDir, '.caveman-history.jsonl');
// Lifetime aggregation paths short-circuit before we need a live session.
if (all || sinceArg) {
const sinceMs = parseDuration(sinceArg);
if (sinceArg && sinceMs === null) {
process.stderr.write(`caveman-stats: --since takes Nh or Nd (e.g. 7d, 24h), got: ${sinceArg}\n`);
process.exit(2);
}
const agg = aggregateHistory(historyPath, sinceMs);
process.stdout.write(formatHistory({ ...agg, since: sinceArg || null }));
return;
}
const sessionFile = sessionFileArg || findRecentSession(claudeDir);
if (!sessionFile) {
process.stderr.write('caveman-stats: no Claude Code session found.\n');
process.exit(1);
}
const parsed = parseSession(sessionFile);
const mode = readFlag(path.join(claudeDir, '.caveman-active'));
// Append a snapshot of this session's totals to the lifetime log. Multiple
// /caveman-stats calls in one session emit multiple lines for the same
// session_id; aggregateHistory keeps only the latest per session_id.
if (parsed.turns > 0) {
const { estSavedTokens, estSavedUsd } = deriveSavings({ ...parsed, mode });
const sessionId = path.basename(sessionFile, '.jsonl');
appendFlag(historyPath, JSON.stringify({
ts: Date.now(),
session_id: sessionId,
mode: mode || null,
model: parsed.model || null,
output_tokens: parsed.outputTokens,
est_saved_tokens: estSavedTokens,
est_saved_usd: estSavedUsd,
}));
// Statusline suffix: tiny pre-rendered string the shell statusline can
// cat without parsing JSONL. Updated on every /caveman-stats run.
// Routed through safeWriteFlag — the suffix path is predictable and
// user-owned, same symlink-clobber surface as the .caveman-active flag.
const agg = aggregateHistory(historyPath, null);
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
}
if (share) {
process.stdout.write(formatShare({ ...parsed, mode }) + '\n');
} else {
const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i);
const compressed = summarizeCompressed(findCompressedPairs(scanDirs));
process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed }));
}
}
if (require.main === module) main();
module.exports = {
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M,
findCompressedPairs, summarizeCompressed, humanizeTokens,
};
+61
View File
@@ -0,0 +1,61 @@
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
$Flag = Join-Path $ClaudeDir ".caveman-active"
if (-not (Test-Path $Flag)) { exit 0 }
# Refuse reparse points (symlinks / junctions) and oversized files. Without
# this, a local attacker could point the flag at a secret file and have the
# statusline render its bytes (including ANSI escape sequences) to the terminal
# every keystroke.
try {
$Item = Get-Item -LiteralPath $Flag -Force -ErrorAction Stop
if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { exit 0 }
if ($Item.Length -gt 64) { exit 0 }
} catch {
exit 0
}
$Mode = ""
try {
$Raw = Get-Content -LiteralPath $Flag -TotalCount 1 -ErrorAction Stop
if ($null -ne $Raw) { $Mode = ([string]$Raw).Trim() }
} catch {
exit 0
}
# Strip anything outside [a-z0-9-] — blocks terminal-escape and OSC hyperlink
# injection via the flag contents. Then whitelist-validate.
$Mode = $Mode.ToLowerInvariant()
$Mode = ($Mode -replace '[^a-z0-9-]', '')
$Valid = @('off','lite','full','ultra','wenyan-lite','wenyan','wenyan-full','wenyan-ultra','commit','review','compress')
if (-not ($Valid -contains $Mode)) { exit 0 }
$Esc = [char]27
if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") {
[Console]::Write("${Esc}[38;5;172m[CAVEMAN]${Esc}[0m")
} else {
$Suffix = $Mode.ToUpperInvariant()
[Console]::Write("${Esc}[38;5;172m[CAVEMAN:$Suffix]${Esc}[0m")
}
# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0.
# Reads a pre-rendered string written by caveman-stats.js. Refuses reparse
# points and strips control bytes (matches statusline.sh hardening). Until
# /caveman-stats has run at least once, the suffix file is absent and nothing
# is rendered — safe default for fresh installs.
if ($env:CAVEMAN_STATUSLINE_SAVINGS -ne "0") {
$SavingsFile = Join-Path $ClaudeDir ".caveman-statusline-suffix"
if (Test-Path $SavingsFile) {
try {
$SavingsItem = Get-Item -LiteralPath $SavingsFile -Force -ErrorAction Stop
if (-not ($SavingsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and
$SavingsItem.Length -le 64) {
$Savings = (Get-Content -LiteralPath $SavingsFile -Raw -ErrorAction Stop).TrimEnd()
$Savings = ($Savings -replace '[\x00-\x1F]', '')
if ($Savings.Length -gt 0) {
[Console]::Write(" ${Esc}[38;5;172m$Savings${Esc}[0m")
}
}
} catch {}
}
}
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# caveman — statusline badge script for Claude Code
# Reads the caveman mode flag file and outputs a colored badge.
#
# Usage in ~/.claude/settings.json:
# "statusLine": { "type": "command", "command": "bash /path/to/caveman-statusline.sh" }
#
# Plugin users: Claude will offer to set this up on first session.
# Standalone users: install.sh wires this automatically.
FLAG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active"
# Refuse symlinks — a local attacker could point the flag at ~/.ssh/id_rsa and
# have the statusline render its bytes (including ANSI escape sequences) to
# the terminal every keystroke.
[ -L "$FLAG" ] && exit 0
[ ! -f "$FLAG" ] && exit 0
# Hard-cap the read at 64 bytes and strip anything outside [a-z0-9-] — blocks
# terminal-escape injection and OSC hyperlink spoofing via the flag contents.
MODE=$(head -c 64 "$FLAG" 2>/dev/null | tr -d '\n\r' | tr '[:upper:]' '[:lower:]')
MODE=$(printf '%s' "$MODE" | tr -cd 'a-z0-9-')
# Whitelist. Anything else → render nothing rather than echo attacker bytes.
case "$MODE" in
off|lite|full|ultra|wenyan-lite|wenyan|wenyan-full|wenyan-ultra|commit|review|compress) ;;
*) exit 0 ;;
esac
if [ -z "$MODE" ] || [ "$MODE" = "full" ]; then
printf '\033[38;5;172m[CAVEMAN]\033[0m'
else
SUFFIX=$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]')
printf '\033[38;5;172m[CAVEMAN:%s]\033[0m' "$SUFFIX"
fi
# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0.
# Reads a pre-rendered string written by caveman-stats.js so we don't shell out
# to node on every keystroke. Refuses symlinks and strips control bytes —
# same hardening as the flag file (a local attacker could plant a file with
# ANSI escape codes otherwise). Until /caveman-stats has run at least once,
# the suffix file is absent and nothing is rendered — so the default is safe
# for fresh installs (no fake number, no crash).
if [ "${CAVEMAN_STATUSLINE_SAVINGS:-1}" != "0" ]; then
SAVINGS_FILE="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-statusline-suffix"
if [ -f "$SAVINGS_FILE" ] && [ ! -L "$SAVINGS_FILE" ]; then
SAVINGS=$(head -c 64 "$SAVINGS_FILE" 2>/dev/null | tr -d '\000-\037')
[ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS"
fi
fi
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// context-mode plugin cache self-heal (auto-deployed)
// Fixes anthropics/claude-code#46915: auto-update breaks CLAUDE_PLUGIN_ROOT
// Issue #727: also normalizes stale version paths in existing installPaths
// Honors CLAUDE_CONFIG_DIR (#577) — checked at this script's runtime so users
// who set CLAUDE_CONFIG_DIR after install still get healed correctly.
// Pure Node.js — no bash/shell dependency.
import{existsSync,readdirSync,statSync,symlinkSync,lstatSync,unlinkSync,readFileSync}from"node:fs";
import{dirname,join,resolve,sep}from"node:path";
import{homedir}from"node:os";
function cfgDir(){const e=process.env.CLAUDE_CONFIG_DIR;if(e&&e.trim()!==""){return e.startsWith("~")?resolve(homedir(),e.replace(/^~[/\\]?/,"")):resolve(e)}return resolve(homedir(),".claude")}
try{
const f=resolve(cfgDir(),"plugins","installed_plugins.json");
if(!existsSync(f))process.exit(0);
const cacheRoot=resolve(cfgDir(),"plugins","cache");
const ip=JSON.parse(readFileSync(f,"utf-8"));
for(const[k,es]of Object.entries(ip.plugins||{})){
if(k!=="context-mode@context-mode")continue;
for(const e of es){
const p=e.installPath;
if(!p)continue;
if(!resolve(p).startsWith(cacheRoot+sep))continue;
if(existsSync(p)){
// Issue #727: normalize stale version paths in existing installPaths.
// CC's auto-update can carry forward hooks.json/plugin.json with paths
// baked to a previous version dir. Import normalize-hooks from the
// installPath itself and let it detect + rewrite stale segments.
try{
// #713: narrow helper only — installPath belongs to a different
// version's cache dir; writing plugin.json there is the #711 vector.
const nhPath=join(p,"hooks","normalize-hooks.mjs");
if(existsSync(nhPath)){
const mod=await import(nhPath);
const fn=mod.normalizeHooksJsonOnly||mod.normalizeHooksOnStartup;
if(fn)fn({pluginRoot:p,nodePath:process.execPath,platform:process.platform});
}
}catch{}
continue;
}
const parent=dirname(p);
if(!existsSync(parent))continue;
try{if(lstatSync(p).isSymbolicLink())unlinkSync(p)}catch{}
const dirs=readdirSync(parent).filter(d=>/^\d+\.\d+/.test(d)&&statSync(join(parent,d)).isDirectory());
if(!dirs.length)continue;
dirs.sort((a,b)=>{const pa=a.split(".").map(Number),pb=b.split(".").map(Number);for(let i=0;i<3;i++){if((pa[i]||0)!==(pb[i]||0))return(pa[i]||0)-(pb[i]||0)}return 0});
try{symlinkSync(join(parent,dirs[dirs.length-1]),p,process.platform==="win32"?"junction":undefined)}catch{}
}
}
}catch{}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}