diff --git a/CLAUDE.md b/CLAUDE.md index 971f5c7..5ebcde4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (`/.caveman/config.json` or `/.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 diff --git a/src/hooks/caveman-config.js b/src/hooks/caveman-config.js index d573f95..9486654 100644 --- a/src/hooks/caveman-config.js +++ b/src/hooks/caveman-config.js @@ -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): +// - /.caveman/config.json +// - /.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 }; diff --git a/src/hooks/caveman-mode-tracker.js b/src/hooks/caveman-mode-tracker.js index 41bcb04..2d414eb 100644 --- a/src/hooks/caveman-mode-tracker.js +++ b/src/hooks/caveman-mode-tracker.js @@ -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') { diff --git a/tests/test_repo_local_config.js b/tests/test_repo_local_config.js new file mode 100644 index 0000000..211053e --- /dev/null +++ b/tests/test_repo_local_config.js @@ -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);