mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
@@ -49,6 +49,7 @@ const HOOK_FILES = [
|
||||
'caveman-stats.js',
|
||||
'caveman-statusline.sh',
|
||||
'caveman-statusline.ps1',
|
||||
'cavecrew-model-overrides.js',
|
||||
];
|
||||
|
||||
// ── Argv ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,26 @@ Locate → fix → verify (most common):
|
||||
|
||||
Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main.
|
||||
|
||||
## Model overrides
|
||||
|
||||
By default, `cavecrew-reviewer` and `cavecrew-investigator` pin `model: haiku` in their frontmatter; `cavecrew-builder` has no `model:` line (uses the API session default). Set env vars in your shell before launching Claude Code to override per-agent:
|
||||
|
||||
| Env var | Agent |
|
||||
|---|---|
|
||||
| `CAVECREW_REVIEWER_MODEL` | `cavecrew-reviewer` |
|
||||
| `CAVECREW_BUILDER_MODEL` | `cavecrew-builder` |
|
||||
| `CAVECREW_INVESTIGATOR_MODEL` | `cavecrew-investigator` |
|
||||
|
||||
Example — run reviewer on sonnet, keep others on default:
|
||||
|
||||
```sh
|
||||
export CAVECREW_REVIEWER_MODEL=sonnet
|
||||
```
|
||||
|
||||
Use the same model name strings you'd use in any Claude Code agent frontmatter (e.g. `haiku`, `sonnet`, `opus`).
|
||||
|
||||
Overrides patch only the `model:` line in the installed agent's frontmatter; the prompt body is untouched and keeps receiving upstream updates. Plugin installs only — standalone hook installs have no local agent files to patch. Unset or blank = no change. The patch persists in the installed file until the plugin is updated or reinstalled.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
// cavecrew model overrides — patch installed agent frontmatter from env vars.
|
||||
//
|
||||
// Called by caveman-activate.js early in SessionStart so users can pin
|
||||
// per-agent models without shadow-copying entire agent files.
|
||||
//
|
||||
// Env vars:
|
||||
// CAVECREW_REVIEWER_MODEL → agents/cavecrew-reviewer.md
|
||||
// CAVECREW_BUILDER_MODEL → agents/cavecrew-builder.md
|
||||
// CAVECREW_INVESTIGATOR_MODEL → agents/cavecrew-investigator.md
|
||||
//
|
||||
// Rules:
|
||||
// - Unset / blank → no-op.
|
||||
// - Values containing newlines or control characters → ignored.
|
||||
// - Existing `model:` line in frontmatter → replaced in-place.
|
||||
// - No `model:` line → inserted after `tools:` (or before closing `---`).
|
||||
// - File missing / outside plugin layout → silent no-op.
|
||||
// - All filesystem errors → silent fail.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENT_ENV_MAP = [
|
||||
{ envVar: 'CAVECREW_REVIEWER_MODEL', file: path.join('agents', 'cavecrew-reviewer.md') },
|
||||
{ envVar: 'CAVECREW_BUILDER_MODEL', file: path.join('agents', 'cavecrew-builder.md') },
|
||||
{ envVar: 'CAVECREW_INVESTIGATOR_MODEL', file: path.join('agents', 'cavecrew-investigator.md') },
|
||||
];
|
||||
|
||||
// Return the plugin root directory given the hooks directory path.
|
||||
// Plugin layout: <plugin_root>/hooks/<this-file> → plugin root = parent of hooks dir.
|
||||
function resolvePluginRoot(hookDir) {
|
||||
return path.resolve(hookDir, '..');
|
||||
}
|
||||
|
||||
// Patch the YAML frontmatter of `content` to set `model: <modelValue>`.
|
||||
// Returns the patched string, or the original if no frontmatter or already identical.
|
||||
// Rejects `modelValue` strings that contain newlines or control characters.
|
||||
function patchFrontmatterModel(content, modelValue) {
|
||||
// Reject blank or unsafe model strings
|
||||
if (!modelValue || /[\x00-\x1f\x7f]/.test(modelValue)) return content;
|
||||
|
||||
// Must begin with YAML frontmatter delimiter
|
||||
if (!content.startsWith('---')) return content;
|
||||
|
||||
// Find the closing ---
|
||||
const closeIdx = content.indexOf('\n---', 3);
|
||||
if (closeIdx === -1) return content;
|
||||
|
||||
const fmRaw = content.slice(0, closeIdx); // opening --- through last fm line
|
||||
const after = content.slice(closeIdx); // \n--- onward (body)
|
||||
|
||||
// Preserve original line ending so we don't create mixed CRLF/LF on Windows
|
||||
const nl = fmRaw.includes('\r\n') ? '\r\n' : '\n';
|
||||
|
||||
const modelLine = 'model: ' + modelValue;
|
||||
const modelRe = /^model:[ \t]*.*$/m;
|
||||
|
||||
if (modelRe.test(fmRaw)) {
|
||||
// Replace existing model: line
|
||||
const patched = fmRaw.replace(modelRe, modelLine);
|
||||
if (patched === fmRaw) return content; // already identical
|
||||
return patched + after;
|
||||
}
|
||||
|
||||
// Insert after tools: line when present; else before closing ---
|
||||
const toolsMatch = fmRaw.match(/^tools:[ \t]*.*$/m);
|
||||
if (toolsMatch) {
|
||||
const toolsEnd = fmRaw.indexOf(toolsMatch[0]) + toolsMatch[0].length;
|
||||
return fmRaw.slice(0, toolsEnd) + nl + modelLine + fmRaw.slice(toolsEnd) + after;
|
||||
}
|
||||
|
||||
// Append before closing delimiter
|
||||
return fmRaw + nl + modelLine + after;
|
||||
}
|
||||
|
||||
// Apply all env-var overrides to agent files under `pluginRoot`.
|
||||
// `env` defaults to process.env; pass an object in tests.
|
||||
function applyOverrides(pluginRoot, env) {
|
||||
const envArg = env || process.env;
|
||||
for (const { envVar, file } of AGENT_ENV_MAP) {
|
||||
const raw = envArg[envVar];
|
||||
if (!raw || !raw.trim()) continue;
|
||||
|
||||
const modelValue = raw.trim();
|
||||
if (/[\x00-\x1f\x7f]/.test(modelValue)) continue;
|
||||
|
||||
const agentPath = path.join(pluginRoot, file);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(agentPath, 'utf8');
|
||||
} catch (e) {
|
||||
continue; // missing file or wrong layout → silent no-op
|
||||
}
|
||||
|
||||
const patched = patchFrontmatterModel(content, modelValue);
|
||||
if (patched === content) continue;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(agentPath, patched, 'utf8');
|
||||
} catch (e) {
|
||||
// Silent fail — never block session start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { resolvePluginRoot, patchFrontmatterModel, applyOverrides, AGENT_ENV_MAP };
|
||||
@@ -15,6 +15,13 @@ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.cla
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
// Apply per-agent model overrides from env vars before emitting rules.
|
||||
// Best-effort: any error is swallowed so SessionStart is never blocked.
|
||||
try {
|
||||
const { applyOverrides, resolvePluginRoot } = require('./cavecrew-model-overrides');
|
||||
applyOverrides(resolvePluginRoot(__dirname));
|
||||
} catch (e) {}
|
||||
|
||||
const mode = getDefaultMode();
|
||||
|
||||
// "off" mode — skip activation entirely, don't write flag or emit rules
|
||||
|
||||
@@ -5,3 +5,4 @@ c0b77891d1f8aaef3a7fbf76533ba63a42eaa96431fb7ece611ccff7474de2ba caveman-config
|
||||
be1129b422b7d3edfa8c97df3d8a81f1d1c94cf4e1fd7d6f3a253afebd93fed7 caveman-stats.js
|
||||
d2deff457d0a5d8e1848193e6af6a68a0ebdba4fbdf250889400d5ea231e088f caveman-statusline.sh
|
||||
1690c639f05940cbff39e0383a27053898b30c224aa651043db29b2842cb524a caveman-statusline.ps1
|
||||
eae3f1c22e41c3460a040d71841e4810cc407bb817768fc267591fbef2538cc0 cavecrew-model-overrides.js
|
||||
|
||||
@@ -24,7 +24,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
|
||||
$Settings = Join-Path $ClaudeDir "settings.json"
|
||||
$RepoUrl = "https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
|
||||
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
|
||||
|
||||
# Resolve source — works from repo clone or remote
|
||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
|
||||
|
||||
@@ -37,7 +37,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
|
||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||
REPO_URL="https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks"
|
||||
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
|
||||
|
||||
# Resolve source — works from repo clone or curl pipe
|
||||
SCRIPT_DIR=""
|
||||
|
||||
@@ -11,7 +11,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
|
||||
$Settings = Join-Path $ClaudeDir "settings.json"
|
||||
$FlagFile = Join-Path $ClaudeDir ".caveman-active"
|
||||
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1")
|
||||
$HookFiles = @("package.json", "caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-stats.js", "caveman-statusline.sh", "caveman-statusline.ps1", "cavecrew-model-overrides.js")
|
||||
|
||||
# Detect if caveman is installed as a plugin
|
||||
$PluginInstalled = $false
|
||||
|
||||
@@ -10,7 +10,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
|
||||
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||
FLAG_FILE="$CLAUDE_DIR/.caveman-active"
|
||||
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh")
|
||||
HOOK_FILES=("package.json" "caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-stats.js" "caveman-statusline.sh" "cavecrew-model-overrides.js")
|
||||
|
||||
# Detect if caveman is installed as a plugin (check plugin cache)
|
||||
PLUGIN_INSTALLED=0
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for src/hooks/cavecrew-model-overrides.js
|
||||
// Run: node tests/test_cavecrew_model_overrides.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const assert = require('assert');
|
||||
|
||||
const { patchFrontmatterModel, resolvePluginRoot, applyOverrides, AGENT_ENV_MAP } =
|
||||
require('../src/hooks/cavecrew-model-overrides');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(' ✓ ' + name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(' ✗ ' + name);
|
||||
console.error(' ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── patchFrontmatterModel ──────────────────────────────────────────────────
|
||||
|
||||
console.log('\npatchFrontmatterModel\n');
|
||||
|
||||
const REVIEWER_FM = [
|
||||
'---',
|
||||
'name: cavecrew-reviewer',
|
||||
'description: >',
|
||||
' Reviewer subagent.',
|
||||
'tools: [Read, Grep, Bash]',
|
||||
'model: haiku',
|
||||
'---',
|
||||
'',
|
||||
'Body text.',
|
||||
].join('\n');
|
||||
|
||||
test('replaces existing model: haiku with sonnet in reviewer', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'new model line missing');
|
||||
assert.ok(!out.includes('model: haiku'), 'old model line still present');
|
||||
assert.ok(out.includes('Body text.'), 'body missing');
|
||||
});
|
||||
|
||||
test('preserves all other frontmatter lines', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'opus');
|
||||
assert.ok(out.includes('name: cavecrew-reviewer'), 'name line lost');
|
||||
assert.ok(out.includes('tools: [Read, Grep, Bash]'), 'tools line lost');
|
||||
assert.ok(out.includes('description: >'), 'description block lost');
|
||||
});
|
||||
|
||||
const INVESTIGATOR_FM = [
|
||||
'---',
|
||||
'name: cavecrew-investigator',
|
||||
'tools: [Read, Grep, Glob, Bash]',
|
||||
'model: haiku',
|
||||
'---',
|
||||
'',
|
||||
'Investigator body.',
|
||||
].join('\n');
|
||||
|
||||
test('replaces existing model: haiku with opus in investigator', () => {
|
||||
const out = patchFrontmatterModel(INVESTIGATOR_FM, 'opus');
|
||||
assert.ok(out.includes('model: opus'), 'new model missing');
|
||||
assert.ok(!out.includes('model: haiku'), 'old model still present');
|
||||
assert.ok(out.includes('Investigator body.'), 'body lost');
|
||||
});
|
||||
|
||||
const BUILDER_FM = [
|
||||
'---',
|
||||
'name: cavecrew-builder',
|
||||
'description: >',
|
||||
' Builder subagent.',
|
||||
'tools: [Read, Edit, Write, Grep, Glob]',
|
||||
'---',
|
||||
'',
|
||||
'Builder body.',
|
||||
].join('\n');
|
||||
|
||||
test('inserts model: after tools: when no model line exists (builder)', () => {
|
||||
const out = patchFrontmatterModel(BUILDER_FM, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line not inserted');
|
||||
// Must be inside frontmatter (before body)
|
||||
const fmClose = out.indexOf('\n---', 3);
|
||||
const modelPos = out.indexOf('model: sonnet');
|
||||
assert.ok(modelPos < fmClose, 'model line is outside frontmatter');
|
||||
// Inserted right after tools: line
|
||||
const toolsPos = out.indexOf('tools:');
|
||||
const toolsEnd = out.indexOf('\n', toolsPos);
|
||||
assert.strictEqual(out.slice(toolsEnd + 1, toolsEnd + 1 + 'model: sonnet'.length), 'model: sonnet',
|
||||
'model not inserted immediately after tools: line');
|
||||
assert.ok(out.includes('Builder body.'), 'body lost');
|
||||
});
|
||||
|
||||
test('no-op when content has no frontmatter', () => {
|
||||
const plain = 'Just some text\nno frontmatter\n';
|
||||
const out = patchFrontmatterModel(plain, 'sonnet');
|
||||
assert.strictEqual(out, plain);
|
||||
});
|
||||
|
||||
test('empty model value is no-op (defense-in-depth guard)', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, '');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'empty value should leave file unchanged');
|
||||
});
|
||||
|
||||
test('ignores model value with newline', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'so\nnnet');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
|
||||
});
|
||||
|
||||
test('ignores model value with control character', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'so\x01nnet');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should return original unchanged');
|
||||
});
|
||||
|
||||
test('model line already identical → content unchanged', () => {
|
||||
const out = patchFrontmatterModel(REVIEWER_FM, 'haiku');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'should be byte-identical when value unchanged');
|
||||
});
|
||||
|
||||
test('no model line and no tools line → inserts before closing ---', () => {
|
||||
const fm = '---\nname: test\n---\n\nbody\n';
|
||||
const out = patchFrontmatterModel(fm, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing');
|
||||
const fmClose = out.indexOf('\n---', 3);
|
||||
const modelPos = out.indexOf('model: sonnet');
|
||||
assert.ok(modelPos < fmClose, 'model line outside frontmatter');
|
||||
});
|
||||
|
||||
test('CRLF files: inserted model line uses CRLF, no mixed endings', () => {
|
||||
const crlf = REVIEWER_FM.replace(/\n/g, '\r\n');
|
||||
const out = patchFrontmatterModel(crlf, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF file');
|
||||
// No bare LF should appear outside CRLF sequences
|
||||
const strippedCR = out.replace(/\r\n/g, '');
|
||||
assert.ok(!strippedCR.includes('\n'), 'mixed line endings detected after patch');
|
||||
});
|
||||
|
||||
test('CRLF builder (no model line): inserted model line uses CRLF', () => {
|
||||
const crlf = BUILDER_FM.replace(/\n/g, '\r\n');
|
||||
const out = patchFrontmatterModel(crlf, 'sonnet');
|
||||
assert.ok(out.includes('model: sonnet'), 'model line missing in CRLF builder file');
|
||||
const strippedCR = out.replace(/\r\n/g, '');
|
||||
assert.ok(!strippedCR.includes('\n'), 'mixed line endings in CRLF builder patch');
|
||||
});
|
||||
|
||||
// ── resolvePluginRoot ──────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nresolvePluginRoot\n');
|
||||
|
||||
test('resolves to parent of hooks dir', () => {
|
||||
const hooksDir = path.join(os.tmpdir(), 'fake-plugin', 'hooks');
|
||||
const root = resolvePluginRoot(hooksDir);
|
||||
assert.strictEqual(path.basename(root), 'fake-plugin');
|
||||
});
|
||||
|
||||
// ── applyOverrides ─────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\napplyOverrides\n');
|
||||
|
||||
function withTmpPlugin(fn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-override-test-'));
|
||||
const agentsDir = path.join(tmp, 'agents');
|
||||
fs.mkdirSync(agentsDir);
|
||||
try {
|
||||
fn(tmp, agentsDir);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('replaces reviewer model when CAVECREW_REVIEWER_MODEL set', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.ok(out.includes('model: sonnet'), 'reviewer model not patched');
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces investigator model when CAVECREW_INVESTIGATOR_MODEL set', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), INVESTIGATOR_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_INVESTIGATOR_MODEL: 'opus' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-investigator.md'), 'utf8');
|
||||
assert.ok(out.includes('model: opus'), 'investigator model not patched');
|
||||
});
|
||||
});
|
||||
|
||||
test('inserts builder model when CAVECREW_BUILDER_MODEL set and no model line', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-builder.md'), BUILDER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_BUILDER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-builder.md'), 'utf8');
|
||||
assert.ok(out.includes('model: sonnet'), 'builder model not inserted');
|
||||
});
|
||||
});
|
||||
|
||||
test('blank env var is no-op', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: '' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'blank env var should be no-op');
|
||||
});
|
||||
});
|
||||
|
||||
test('whitespace-only env var is no-op', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: ' ' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'whitespace env var should be no-op');
|
||||
});
|
||||
});
|
||||
|
||||
test('env var with newline in value is ignored', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'so\nnnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'newline in value should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
test('env var with control character in value is ignored', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'son\x00net' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'control char in value should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
test('missing agent file is silent no-op', () => {
|
||||
withTmpPlugin((root) => {
|
||||
// agents dir exists but reviewer file does not
|
||||
assert.doesNotThrow(() => {
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('missing agents dir (non-plugin layout) is silent no-op', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-nolayout-'));
|
||||
try {
|
||||
assert.doesNotThrow(() => {
|
||||
applyOverrides(tmp, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unset env vars → files untouched', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), REVIEWER_FM, 'utf8');
|
||||
applyOverrides(root, {});
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.strictEqual(out, REVIEWER_FM, 'file should be unchanged when env unset');
|
||||
});
|
||||
});
|
||||
|
||||
test('body content preserved after model patch', () => {
|
||||
withTmpPlugin((root, agentsDir) => {
|
||||
const content = REVIEWER_FM + '\n\n## Extra\n\nExtra section body.\n';
|
||||
fs.writeFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), content, 'utf8');
|
||||
applyOverrides(root, { CAVECREW_REVIEWER_MODEL: 'sonnet' });
|
||||
const out = fs.readFileSync(path.join(agentsDir, 'cavecrew-reviewer.md'), 'utf8');
|
||||
assert.ok(out.includes('## Extra'), 'extra body section lost');
|
||||
assert.ok(out.includes('Extra section body.'), 'body text lost');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('');
|
||||
if (failed === 0) {
|
||||
console.log('All ' + (passed + failed) + ' tests passed.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(failed + ' test(s) failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -170,6 +170,7 @@ def verify_manifests_and_syntax() -> None:
|
||||
run(["node", "--check", "src/hooks/caveman-config.js"])
|
||||
run(["node", "--check", "src/hooks/caveman-activate.js"])
|
||||
run(["node", "--check", "src/hooks/caveman-mode-tracker.js"])
|
||||
run(["node", "--check", "src/hooks/cavecrew-model-overrides.js"])
|
||||
run(["node", "--check", "bin/install.js"])
|
||||
run(["node", "--check", "bin/lib/settings.js"])
|
||||
run(["bash", "-n", "src/hooks/install.sh"])
|
||||
|
||||
Reference in New Issue
Block a user