Files
gh-jbabin91-super-claude-pl…/hooks/utils/config-loader.ts
2025-11-29 18:50:12 +08:00

191 lines
5.4 KiB
TypeScript

/**
* Hook Configuration Loader
*
* Loads hook configuration from multiple sources with priority:
* 1. Environment variables (CLAUDE_HOOK_{NAME}_ENABLED) - session override
* 2. .claude/settings.local.json (gitignored, personal overrides)
* 3. .claude/settings.json (project, committed)
* 4. .claude/super-claude-config.json (unified plugin config)
* 5. ~/.claude/settings.json (global user defaults)
* 6. Plugin defaults (enabled: true)
*
* Supports both native Claude Code settings (customHooks) and unified plugin config (workflow.hooks).
*
* @see {@link https://docs.claude.com/en/docs/claude-code/settings} for settings hierarchy
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
/**
* Hook configuration schema
*/
export type HookConfig = {
enabled: boolean;
[key: string]: unknown; // Allow hook-specific config
};
/**
* Settings file schema (partial - only customHooks)
*/
type SettingsFile = {
customHooks?: Record<string, HookConfig>;
[key: string]: unknown;
};
/**
* Unified plugin config schema (partial - only workflow.hooks)
*/
type UnifiedConfig = {
workflow?: {
hooks?: Record<string, HookConfig>;
[key: string]: unknown;
};
[key: string]: unknown;
};
/**
* Load settings file safely
*
* @param filePath Path to settings.json file
* @returns Parsed settings or null if not found/invalid
*/
function loadSettingsFile(filePath: string): SettingsFile | null {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as SettingsFile;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
return null;
}
}
/**
* Load unified plugin config file safely
*
* @param filePath Path to super-claude-config.json file
* @returns Parsed config or null if not found/invalid
*/
function loadUnifiedConfig(filePath: string): UnifiedConfig | null {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as UnifiedConfig;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
return null;
}
}
/**
* Load hook configuration from settings hierarchy
*
* Configuration priority (highest to lowest):
* 1. Environment variables (CLAUDE_HOOK_{HOOK_NAME}_ENABLED)
* 2. Local overrides (.claude/settings.local.json)
* 3. Project settings (.claude/settings.json)
* 4. Unified plugin config (.claude/super-claude-config.json)
* 5. Global settings (~/.claude/settings.json)
* 6. Default (enabled: true)
*
* @param cwd Current working directory
* @param hookName Hook name (e.g., 'gitCommitGuard')
* @returns Hook configuration
*
* @example
* ```ts
* const config = loadHookConfig(cwd, 'gitCommitGuard');
* if (!config.enabled) {
* console.log('Hook disabled by config');
* process.exit(0);
* }
* ```
*/
export function loadHookConfig(cwd: string, hookName: string): HookConfig {
// Load all config files
const localPath = path.join(cwd, '.claude', 'settings.local.json');
const projectPath = path.join(cwd, '.claude', 'settings.json');
const unifiedPath = path.join(cwd, '.claude', 'super-claude-config.json');
const globalPath = path.join(
process.env.HOME ?? process.env.USERPROFILE ?? '~',
'.claude',
'settings.json',
);
const local = loadSettingsFile(localPath);
const project = loadSettingsFile(projectPath);
const unified = loadUnifiedConfig(unifiedPath);
const global = loadSettingsFile(globalPath);
// Check environment variable override
const envKey = `CLAUDE_HOOK_${hookName.toUpperCase()}_ENABLED`;
const envEnabled = process.env[envKey];
// Merge config in reverse priority order (lowest to highest)
const config: HookConfig = { enabled: true };
// 1. Start with global settings
if (global?.customHooks?.[hookName]) {
Object.assign(config, global.customHooks[hookName]);
}
// 2. Override with unified plugin config
if (unified?.workflow?.hooks?.[hookName]) {
Object.assign(config, unified.workflow.hooks[hookName]);
}
// 3. Override with project settings
if (project?.customHooks?.[hookName]) {
Object.assign(config, project.customHooks[hookName]);
}
// 4. Override with local settings
if (local?.customHooks?.[hookName]) {
Object.assign(config, local.customHooks[hookName]);
}
// 5. Override with environment variable (highest priority)
if (envEnabled !== undefined) {
config.enabled = envEnabled === 'true' || envEnabled === '1';
}
return config;
}
/**
* Check if hook is enabled
*
* Convenience function that exits cleanly if hook is disabled.
* Call this at the start of your hook to respect user configuration.
*
* @param cwd Current working directory
* @param hookName Hook name
* @returns true if enabled, never returns if disabled (exits process)
*
* @example
* ```ts
* const input = await parseStdin();
* checkHookEnabled(input.cwd, 'gitCommitGuard'); // Exits if disabled
* // Continue with hook logic...
* ```
*/
export function checkHookEnabled(cwd: string, hookName: string): boolean {
const config = loadHookConfig(cwd, hookName);
if (!config.enabled) {
// Exit cleanly without output (hook disabled)
process.exit(0);
}
return true;
}