mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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:
co-authored by
Claude Opus 4.8
parent
6ce47d4445
commit
cd4009effa
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user