feat(config): repo-local .caveman/config.json + NL brevity triggers

Folds in #429 (repo-local <repo>/.caveman/config.json resolution layer between env and user config; symlink-safe, bounded walk) and #248 intent (recognize 'less tokens'/'be brief'/'be terse' as natural-language caveman activation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-06-01 21:04:58 +02:00
co-authored by Claude Opus 4.8
parent 6ce47d4445
commit cd4009effa
4 changed files with 231 additions and 15 deletions
+2 -1
View File
@@ -154,7 +154,8 @@ All hooks honor `CLAUDE_CONFIG_DIR` for non-default Claude Code config locations
### `src/hooks/caveman-config.js` — shared module
Exports:
- `getDefaultMode()` — resolves default mode from `CAVEMAN_DEFAULT_MODE` env var, then `$XDG_CONFIG_HOME/caveman/config.json` / `~/.config/caveman/config.json` / `%APPDATA%\caveman\config.json`, then `'full'`
- `getDefaultMode()` — resolves default mode in order: `CAVEMAN_DEFAULT_MODE` env var → repo-local config (`<cwd>/.caveman/config.json` or `<cwd>/.caveman.json`, walking up to the filesystem root) → user config (`$XDG_CONFIG_HOME/caveman/config.json` / `~/.config/caveman/config.json` / `%APPDATA%\caveman\config.json`) → `'full'`. The env var short-circuits before any cwd walk. Repo-local config lets a team check in a per-project default without polluting every contributor's env or user config.
- `findRepoConfigPath(start)` — walks up from `start` (default `process.cwd()`) looking for the first `.caveman/config.json` or `.caveman.json`. Bounded to 64 ancestors. Refuses symlinked files (symmetric with `safeWriteFlag` / `readFlag`).
- `safeWriteFlag(flagPath, content)` — symlink-safe flag write. Refuses if flag target or its immediate parent is a symlink. Opens with `O_NOFOLLOW` where supported. Atomic temp + rename. Creates with `0600`. Protects against local attackers replacing the predictable flag path with a symlink to clobber files writable by the user. Used by both write hooks. Silent-fails on all filesystem errors.
### `src/hooks/caveman-activate.js` — SessionStart hook
+64 -13
View File
@@ -3,11 +3,17 @@
//
// Resolution order for default mode:
// 1. CAVEMAN_DEFAULT_MODE environment variable
// 2. Config file defaultMode field:
// 2. Repo-local config (checked-in, per-project default):
// - <cwd>/.caveman/config.json
// - <cwd>/.caveman.json
// Walks up from process.cwd() to the nearest ancestor containing one of
// these (stops at filesystem root). Lets a team pin a project's default
// mode without polluting every contributor's user-level config or env.
// 3. User 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'
// 4. 'full'
const fs = require('fs');
const path = require('path');
@@ -36,6 +42,51 @@ function getConfigPath() {
return path.join(getConfigDir(), 'config.json');
}
// Walk up from `start` looking for a repo-local caveman config. Returns the
// absolute path of the first match, or null. Stops at the filesystem root.
// Candidates per dir (first wins): .caveman/config.json, .caveman.json.
//
// Bounded to 64 levels to defend against symlink cycles on pathological mounts.
function findRepoConfigPath(start) {
try {
let dir = path.resolve(start || process.cwd());
const candidates = ['.caveman/config.json', '.caveman.json'];
for (let i = 0; i < 64; i++) {
for (const rel of candidates) {
const p = path.join(dir, rel);
try {
const st = fs.lstatSync(p);
// Refuse symlinks — symmetric with safeWriteFlag/readFlag policy.
if (st.isSymbolicLink() || !st.isFile()) continue;
return p;
} catch (e) {
// not present, try next candidate
}
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
} catch (e) {
// Defensive: any cwd / fs failure → no repo config
}
return null;
}
function readModeFromConfigFile(configPath) {
try {
const raw = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(raw);
if (config && config.defaultMode &&
VALID_MODES.includes(String(config.defaultMode).toLowerCase())) {
return String(config.defaultMode).toLowerCase();
}
} catch (e) {
// Missing / unreadable / invalid JSON → caller falls through
}
return null;
}
function getDefaultMode() {
// 1. Environment variable (highest priority)
const envMode = process.env.CAVEMAN_DEFAULT_MODE;
@@ -43,18 +94,18 @@ function getDefaultMode() {
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
// 2. Repo-local config (checked-in, per-project default)
const repoConfigPath = findRepoConfigPath(process.cwd());
if (repoConfigPath) {
const repoMode = readModeFromConfigFile(repoConfigPath);
if (repoMode) return repoMode;
}
// 3. Default
// 3. User config file
const userMode = readModeFromConfigFile(getConfigPath());
if (userMode) return userMode;
// 4. Default
return 'full';
}
@@ -271,4 +322,4 @@ function readHistory(filePath) {
}
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory };
module.exports = { getDefaultMode, getConfigDir, getConfigPath, findRepoConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory };
+4 -1
View File
@@ -25,8 +25,11 @@ process.stdin.on('end', () => {
// 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.
// Also recognize brevity requests ("less tokens", "be brief/terse", "fewer
// tokens", "shorter answers") — README promises these trigger caveman too.
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)) {
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) ||
/\b(less tokens|fewer tokens|be brief|be terse|shorter answers)\b/i.test(prompt)) {
if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) {
const mode = getDefaultMode();
if (mode !== 'off') {
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
// Tests for repo-local config resolution in getDefaultMode().
// Covers the resolution-order contract:
// env CAVEMAN_DEFAULT_MODE → repo-local (.caveman/config.json or .caveman.json,
// walking up to filesystem root) → user config → 'full'.
//
// Run: node tests/test_repo_local_config.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const assert = require('assert');
// Isolate from the host's real user config: point XDG_CONFIG_HOME at a tmp dir
// before requiring the module so getConfigPath() never reads the developer's
// own ~/.config/caveman/config.json.
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-userhome-'));
process.env.XDG_CONFIG_HOME = tmpHome;
delete process.env.CAVEMAN_DEFAULT_MODE;
const { getDefaultMode, findRepoConfigPath } = require('../src/hooks/caveman-config');
let passed = 0;
let failed = 0;
function test(name, fn) {
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-repocfg-'));
const origCwd = process.cwd();
const origEnv = process.env.CAVEMAN_DEFAULT_MODE;
try {
fn(tmpBase);
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.error(`${name}`);
console.error(` ${e.message}`);
} finally {
process.chdir(origCwd);
if (origEnv === undefined) delete process.env.CAVEMAN_DEFAULT_MODE;
else process.env.CAVEMAN_DEFAULT_MODE = origEnv;
fs.rmSync(tmpBase, { recursive: true, force: true });
}
}
console.log('repo-local config resolution tests\n');
test('returns "full" when no env, no repo config, no user config', (tmp) => {
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('reads .caveman/config.json in cwd', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
});
test('reads .caveman.json in cwd', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'ultra' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'ultra');
});
test('.caveman/config.json wins over .caveman.json at same level', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'ultra' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'lite');
});
test('walks up from nested cwd to find repo config', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'wenyan-lite' }));
const nested = path.join(tmp, 'a', 'b', 'c');
fs.mkdirSync(nested, { recursive: true });
process.chdir(nested);
assert.strictEqual(getDefaultMode(), 'wenyan-lite');
});
test('env var beats repo-local config', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
process.env.CAVEMAN_DEFAULT_MODE = 'ultra';
assert.strictEqual(getDefaultMode(), 'ultra');
});
test('repo-local config beats user config', (tmp) => {
// user config at XDG_CONFIG_HOME points to 'commit'
fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'),
JSON.stringify({ defaultMode: 'commit' }));
// repo-local points to 'lite'
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'),
JSON.stringify({ defaultMode: 'lite' }));
process.chdir(tmp);
try {
assert.strictEqual(getDefaultMode(), 'lite');
} finally {
fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
}
});
test('falls through to user config when repo config absent', (tmp) => {
fs.mkdirSync(path.join(tmpHome, 'caveman'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'caveman', 'config.json'),
JSON.stringify({ defaultMode: 'review' }));
process.chdir(tmp);
try {
assert.strictEqual(getDefaultMode(), 'review');
} finally {
fs.rmSync(path.join(tmpHome, 'caveman'), { recursive: true, force: true });
}
});
test('invalid mode in repo config falls through to default', (tmp) => {
fs.writeFileSync(path.join(tmp, '.caveman.json'),
JSON.stringify({ defaultMode: 'definitely-not-a-mode' }));
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('malformed JSON in repo config falls through to default', (tmp) => {
fs.mkdirSync(path.join(tmp, '.caveman'));
fs.writeFileSync(path.join(tmp, '.caveman', 'config.json'), '{ not json');
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('refuses symlinked .caveman.json (symmetric with readFlag policy)', (tmp) => {
const real = path.join(tmp, 'real-config.json');
fs.writeFileSync(real, JSON.stringify({ defaultMode: 'ultra' }));
try {
fs.symlinkSync(real, path.join(tmp, '.caveman.json'));
} catch (e) {
// Skip on platforms without symlink perms
console.log(' (skipped: symlink not permitted)');
return;
}
process.chdir(tmp);
assert.strictEqual(getDefaultMode(), 'full');
});
test('findRepoConfigPath returns null outside any repo', (tmp) => {
process.chdir(tmp);
assert.strictEqual(findRepoConfigPath(tmp), null);
});
console.log(`\n${passed} passed, ${failed} failed`);
fs.rmSync(tmpHome, { recursive: true, force: true });
process.exit(failed === 0 ? 0 : 1);