feat: make default caveman mode configurable

Add caveman-config.js shared config resolver. Default mode resolves via:
CAVEMAN_DEFAULT_MODE env var > XDG config file > 'full'.

Updated all install/uninstall scripts (bash + PowerShell) and tests.

Closes #86

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-04-11 17:56:45 +02:00
co-authored by Claude Opus 4.6
parent 24e6ee9ea8
commit ea89000141
8 changed files with 88 additions and 6 deletions
+2 -1
View File
@@ -9,6 +9,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode } = require('./caveman-config');
const claudeDir = path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
@@ -17,7 +18,7 @@ const settingsPath = path.join(claudeDir, 'settings.json');
// 1. Write flag file
try {
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
fs.writeFileSync(flagPath, 'full');
fs.writeFileSync(flagPath, getDefaultMode());
} catch (e) {
// Silent fail -- flag is best-effort, don't block the hook
}
+61
View File
@@ -0,0 +1,61 @@
#!/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 = [
'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';
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES };
+2 -1
View File
@@ -5,6 +5,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode } = require('./caveman-config');
const flagPath = path.join(os.homedir(), '.claude', '.caveman-active');
@@ -35,7 +36,7 @@ process.stdin.on('end', () => {
else if (arg === 'wenyan-lite') mode = 'wenyan-lite';
else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan';
else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra';
else mode = 'full';
else mode = getDefaultMode();
}
if (mode) {
+1 -1
View File
@@ -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 = @("caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
# Resolve source — works from repo clone or remote
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
+1 -1
View File
@@ -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=("caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
HOOK_FILES=("caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
# Resolve source — works from repo clone or curl pipe
SCRIPT_DIR=""
+1 -1
View File
@@ -11,7 +11,7 @@ $HooksDir = Join-Path $ClaudeDir "hooks"
$Settings = Join-Path $ClaudeDir "settings.json"
$FlagFile = Join-Path $ClaudeDir ".caveman-active"
$HookFiles = @("caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
$HookFiles = @("caveman-config.js", "caveman-activate.js", "caveman-mode-tracker.js", "caveman-statusline.sh", "caveman-statusline.ps1")
# Detect if caveman is installed as a plugin
$PluginInstalled = $false
+1 -1
View File
@@ -10,7 +10,7 @@ HOOKS_DIR="$CLAUDE_DIR/hooks"
SETTINGS="$CLAUDE_DIR/settings.json"
FLAG_FILE="$CLAUDE_DIR/.caveman-active"
HOOK_FILES=("caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
HOOK_FILES=("caveman-config.js" "caveman-activate.js" "caveman-mode-tracker.js" "caveman-statusline.sh")
# Detect if caveman is installed as a plugin (check plugin cache)
PLUGIN_INSTALLED=0
+19
View File
@@ -105,12 +105,19 @@ def verify_manifests_and_syntax() -> None:
for path in manifest_paths:
read_json(path)
run(["node", "--check", "hooks/caveman-config.js"])
run(["node", "--check", "hooks/caveman-activate.js"])
run(["node", "--check", "hooks/caveman-mode-tracker.js"])
run(["bash", "-n", "hooks/install.sh"])
run(["bash", "-n", "hooks/uninstall.sh"])
run(["bash", "-n", "hooks/caveman-statusline.sh"])
# Ensure install/uninstall scripts include caveman-config.js
install_sh = (ROOT / "hooks/install.sh").read_text()
uninstall_sh = (ROOT / "hooks/uninstall.sh").read_text()
ensure("caveman-config.js" in install_sh, "install.sh missing caveman-config.js")
ensure("caveman-config.js" in uninstall_sh, "uninstall.sh missing caveman-config.js")
print("JSON manifests and JS/bash syntax OK")
@@ -120,6 +127,8 @@ def verify_powershell_static() -> None:
uninstall_text = (ROOT / "hooks/uninstall.ps1").read_text()
statusline_text = (ROOT / "hooks/caveman-statusline.ps1").read_text()
ensure("caveman-config.js" in install_text, "install.ps1 missing caveman-config.js")
ensure("caveman-config.js" in uninstall_text, "uninstall.ps1 missing caveman-config.js")
ensure("caveman-statusline.ps1" in install_text, "install.ps1 missing statusline.ps1")
ensure("caveman-statusline.ps1" in uninstall_text, "uninstall.ps1 missing statusline.ps1")
ensure("-AsHashtable" not in install_text, "install.ps1 should stay compatible with Windows PowerShell 5.1")
@@ -220,6 +229,16 @@ def verify_hook_install_flow() -> None:
ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "activation should stay quiet when custom statusline exists")
ensure((claude_dir / ".caveman-active").read_text() == "full", "activation flag should default to full")
# Test configurable default mode via CAVEMAN_DEFAULT_MODE env var
activate_custom = run(
["node", "hooks/caveman-activate.js"],
env={"HOME": str(home), "CAVEMAN_DEFAULT_MODE": "ultra"},
)
ensure("CAVEMAN MODE ACTIVE." in activate_custom.stdout, "activation with custom default missing banner")
ensure((claude_dir / ".caveman-active").read_text() == "ultra", "CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra")
# Reset back to full for subsequent tests
(claude_dir / ".caveman-active").write_text("full")
run(
["node", "hooks/caveman-mode-tracker.js"],
env={"HOME": str(home)},