Initial commit
This commit is contained in:
190
hooks/utils/config-loader.ts
Normal file
190
hooks/utils/config-loader.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
117
hooks/utils/config-types.ts
Normal file
117
hooks/utils/config-types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Type definitions for super-claude-config.json
|
||||
*
|
||||
* Supports both plugin-level defaults and project-level overrides
|
||||
* with deep merge behavior.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Skill auto-activation triggers
|
||||
*/
|
||||
export type SkillTriggers = {
|
||||
/** Literal keywords for case-insensitive matching */
|
||||
keywords?: string[];
|
||||
/** Regex patterns for intent matching */
|
||||
patterns?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete skill configuration (plugin defaults)
|
||||
*/
|
||||
export type SkillConfig = {
|
||||
/** Whether this skill is enabled */
|
||||
enabled?: boolean;
|
||||
/** Auto-activation triggers */
|
||||
triggers?: SkillTriggers;
|
||||
/** Additional skill-specific settings */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partial skill configuration (project overrides)
|
||||
*/
|
||||
export type SkillOverride = Partial<SkillConfig>;
|
||||
|
||||
/**
|
||||
* Hook configuration with plugin-specific settings
|
||||
*/
|
||||
export type HookConfig = {
|
||||
/** Whether this hook is enabled */
|
||||
enabled?: boolean;
|
||||
/** Additional hook-specific settings */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partial hook configuration (project overrides)
|
||||
*/
|
||||
export type HookOverride = Partial<HookConfig>;
|
||||
|
||||
/**
|
||||
* Plugin-level configuration (plugins/{plugin}/super-claude-config.json)
|
||||
*/
|
||||
export type PluginConfig = {
|
||||
/** Plugin identifier matching directory name */
|
||||
plugin: string;
|
||||
/** Skill configurations keyed by skill name */
|
||||
skills?: Record<string, SkillConfig>;
|
||||
/** Hook configurations keyed by hook name */
|
||||
hooks?: Record<string, HookConfig>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Project-level configuration (.claude/super-claude-config.json)
|
||||
*
|
||||
* Structure: { [pluginName]: { skills: {...}, hooks: {...} } }
|
||||
*/
|
||||
export type ProjectConfig = Record<
|
||||
string,
|
||||
{
|
||||
skills?: Record<string, SkillOverride>;
|
||||
hooks?: Record<string, HookOverride>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Resolved configuration after merging defaults and overrides
|
||||
*/
|
||||
export type ResolvedConfig = {
|
||||
skills: Record<string, SkillConfig>;
|
||||
hooks: Record<string, HookConfig>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration loading options
|
||||
*/
|
||||
export type ConfigLoaderOptions = {
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Plugin name to load config for */
|
||||
pluginName: string;
|
||||
/** Whether to cache loaded configuration */
|
||||
cache?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy skill-rules.json format for backwards compatibility
|
||||
*/
|
||||
export type LegacySkillRules = {
|
||||
plugin: {
|
||||
namespace: string;
|
||||
name: string;
|
||||
};
|
||||
skills: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
promptTriggers?: {
|
||||
keywords?: string[];
|
||||
intentPatterns?: string[];
|
||||
};
|
||||
}
|
||||
>;
|
||||
overrides?: {
|
||||
disabled?: string[];
|
||||
};
|
||||
};
|
||||
181
hooks/utils/config-validation.ts
Normal file
181
hooks/utils/config-validation.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Runtime validation schemas for super-claude-config.json
|
||||
*
|
||||
* Uses ArkType for TypeScript-native validation with excellent error messages.
|
||||
*/
|
||||
|
||||
import { type } from 'arktype';
|
||||
|
||||
import type {
|
||||
HookConfig,
|
||||
LegacySkillRules,
|
||||
PluginConfig,
|
||||
ProjectConfig,
|
||||
SkillConfig,
|
||||
} from './config-types.js';
|
||||
|
||||
/**
|
||||
* Skill configuration validation schema
|
||||
*/
|
||||
export const skillConfigSchema = type({
|
||||
'enabled?': 'boolean',
|
||||
'triggers?': 'object',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook configuration validation schema
|
||||
*
|
||||
* Allows additional properties for hook-specific settings
|
||||
*/
|
||||
export const hookConfigSchema = type({
|
||||
'enabled?': 'boolean',
|
||||
});
|
||||
|
||||
/**
|
||||
* Plugin-level configuration validation schema
|
||||
*/
|
||||
export const pluginConfigSchema = type({
|
||||
plugin: 'string',
|
||||
'skills?': 'object',
|
||||
'hooks?': 'object',
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy skill-rules.json validation schema
|
||||
*/
|
||||
export const legacySkillRulesSchema = type({
|
||||
plugin: {
|
||||
namespace: 'string',
|
||||
name: 'string',
|
||||
},
|
||||
skills: 'object',
|
||||
'overrides?': {
|
||||
'disabled?': 'string[]',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation result type
|
||||
*/
|
||||
export type ValidationResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: string };
|
||||
|
||||
/**
|
||||
* Validate plugin configuration
|
||||
*
|
||||
* @param data Configuration data to validate
|
||||
* @returns Validation result with typed data or errors
|
||||
*/
|
||||
export function validatePluginConfig(
|
||||
data: unknown,
|
||||
): ValidationResult<PluginConfig> {
|
||||
const result = pluginConfigSchema(data);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return {
|
||||
success: false,
|
||||
errors: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result as PluginConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate project configuration
|
||||
*
|
||||
* @param data Configuration data to validate
|
||||
* @returns Validation result with typed data or errors
|
||||
*/
|
||||
export function validateProjectConfig(
|
||||
data: unknown,
|
||||
): ValidationResult<ProjectConfig> {
|
||||
// Basic object check
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return {
|
||||
success: false,
|
||||
errors: 'Project configuration must be an object',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as ProjectConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate skill configuration
|
||||
*
|
||||
* @param data Skill config data to validate
|
||||
* @returns Validation result with typed data or errors
|
||||
*/
|
||||
export function validateSkillConfig(
|
||||
data: unknown,
|
||||
): ValidationResult<SkillConfig> {
|
||||
const result = skillConfigSchema(data);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return {
|
||||
success: false,
|
||||
errors: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result as SkillConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hook configuration
|
||||
*
|
||||
* @param data Hook config data to validate
|
||||
* @returns Validation result with typed data or errors
|
||||
*/
|
||||
export function validateHookConfig(
|
||||
data: unknown,
|
||||
): ValidationResult<HookConfig> {
|
||||
const result = hookConfigSchema(data);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return {
|
||||
success: false,
|
||||
errors: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result as HookConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate legacy skill-rules.json format
|
||||
*
|
||||
* @param data Legacy config data to validate
|
||||
* @returns Validation result with typed data or errors
|
||||
*/
|
||||
export function validateLegacySkillRules(
|
||||
data: unknown,
|
||||
): ValidationResult<LegacySkillRules> {
|
||||
const result = legacySkillRulesSchema(data);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return {
|
||||
success: false,
|
||||
errors: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result as LegacySkillRules,
|
||||
};
|
||||
}
|
||||
94
hooks/utils/hook-input.ts
Normal file
94
hooks/utils/hook-input.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Hook Input Parsing Utilities
|
||||
*
|
||||
* Shared utilities for parsing Claude Code hook input from stdin.
|
||||
* All hooks receive JSON via stdin with fields like cwd, tool_name, tool_input, etc.
|
||||
*
|
||||
* @see {@link https://docs.claude.com/en/docs/claude-code/hooks} for hook input spec
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard hook input schema from Claude Code
|
||||
*/
|
||||
export type HookInput = {
|
||||
cwd: string; // Current working directory
|
||||
tool_name?: string; // Tool being invoked (PreToolUse/PostToolUse)
|
||||
tool_input?: Record<string, unknown>; // Tool parameters
|
||||
transcript_path?: string; // Path to conversation transcript JSON
|
||||
[key: string]: unknown; // Allow additional fields
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse hook input from stdin.
|
||||
*
|
||||
* Reads JSON from stdin and validates required fields.
|
||||
* Throws error for invalid input to fail fast.
|
||||
*
|
||||
* @returns Parsed HookInput object
|
||||
* @throws Error if stdin is invalid or missing required fields
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const input = await parseStdin();
|
||||
* console.log('Working directory:', input.cwd);
|
||||
* ```
|
||||
*/
|
||||
export async function parseStdin(): Promise<HookInput> {
|
||||
const stdin = await Bun.stdin.text();
|
||||
|
||||
if (!stdin || stdin.trim() === '') {
|
||||
throw new Error('No input received from stdin');
|
||||
}
|
||||
|
||||
try {
|
||||
const input = JSON.parse(stdin) as HookInput;
|
||||
|
||||
if (!input.cwd || typeof input.cwd !== 'string') {
|
||||
throw new Error('Invalid input: missing or invalid cwd field');
|
||||
}
|
||||
|
||||
return input;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error('Invalid JSON from stdin: ' + error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe error formatting for hook output
|
||||
*
|
||||
* Formats error for display to user without leaking implementation details.
|
||||
*
|
||||
* @param error Error object or unknown
|
||||
* @param prefix Optional prefix for error message
|
||||
* @returns Formatted error string
|
||||
*/
|
||||
export function formatError(error: unknown, prefix = 'Hook error'): string {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return `[ERROR] ${prefix}: ${msg}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring helper
|
||||
*
|
||||
* Logs warning if execution exceeds target duration.
|
||||
* Use to enforce ADR-0010 performance requirements (<50ms command hooks).
|
||||
*
|
||||
* @param startTime Start time from Date.now()
|
||||
* @param targetMs Target duration in milliseconds
|
||||
* @param hookName Name of hook for warning message
|
||||
*/
|
||||
export function checkPerformance(
|
||||
startTime: number,
|
||||
targetMs: number,
|
||||
hookName: string,
|
||||
): void {
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > targetMs) {
|
||||
console.warn(
|
||||
`[WARNING] Slow ${hookName} hook: ${duration}ms (target: ${targetMs}ms)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
hooks/utils/index.ts
Normal file
23
hooks/utils/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Hook Utilities
|
||||
*
|
||||
* Shared utilities for Claude Code command hooks.
|
||||
* Includes input parsing, configuration loading, error handling, and runtime checks.
|
||||
*/
|
||||
|
||||
export {
|
||||
checkHookEnabled,
|
||||
type HookConfig,
|
||||
loadHookConfig,
|
||||
} from './config-loader.js';
|
||||
export {
|
||||
checkPerformance,
|
||||
formatError,
|
||||
type HookInput,
|
||||
parseStdin,
|
||||
} from './hook-input.js';
|
||||
export {
|
||||
checkBunVersion,
|
||||
ensureBunInstalled,
|
||||
ensureToolsInstalled,
|
||||
} from './runtime-check.js';
|
||||
146
hooks/utils/runtime-check.ts
Normal file
146
hooks/utils/runtime-check.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Runtime Check Utilities
|
||||
*
|
||||
* Validates runtime requirements for hooks (e.g., Bun installation).
|
||||
* Provides clear, actionable error messages when requirements aren't met.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Check if Bun is installed and available in PATH
|
||||
*
|
||||
* @throws Error with installation instructions if Bun is not found
|
||||
*/
|
||||
export function ensureBunInstalled(): void {
|
||||
try {
|
||||
// Use 'bun --version' for cross-platform compatibility (works on Windows, macOS, Linux)
|
||||
execSync('bun --version', { stdio: 'pipe' });
|
||||
} catch {
|
||||
const errorMessage = [
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'❌ BUN RUNTIME NOT FOUND',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
'This workflow hook requires Bun to be installed.',
|
||||
'',
|
||||
'📦 Install Bun:',
|
||||
'',
|
||||
' macOS/Linux:',
|
||||
' curl -fsSL https://bun.sh/install | bash',
|
||||
'',
|
||||
' Windows:',
|
||||
' powershell -c "irm bun.sh/install.ps1|iex"',
|
||||
'',
|
||||
' Or via npm:',
|
||||
' npm install -g bun',
|
||||
'',
|
||||
' Or via Homebrew (macOS):',
|
||||
' brew install oven-sh/bun/bun',
|
||||
'',
|
||||
'🔗 More info: https://bun.sh',
|
||||
'',
|
||||
'⚠️ After installing, restart your terminal and try again.',
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
console.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Bun version meets minimum requirement
|
||||
*
|
||||
* @param minVersion Minimum required version (e.g., "1.0.0")
|
||||
* @returns true if version is sufficient, false otherwise
|
||||
*/
|
||||
export function checkBunVersion(minVersion: string): boolean {
|
||||
try {
|
||||
const version = execSync('bun --version', {
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
|
||||
return compareVersions(version, minVersion) >= 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip pre-release and build metadata from semver string
|
||||
*
|
||||
* @param version Version string (e.g., "1.0.0-beta", "1.0.0+build")
|
||||
* @returns Clean version (e.g., "1.0.0")
|
||||
*/
|
||||
function stripSemverMetadata(version: string): string {
|
||||
return version.split(/[-+]/)[0] || version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic versions
|
||||
*
|
||||
* Strips pre-release and build metadata before comparison.
|
||||
* Examples: "1.0.0-beta" → "1.0.0", "1.0.0+build" → "1.0.0"
|
||||
*
|
||||
* @param a Version string (e.g., "1.2.3", "1.2.3-beta")
|
||||
* @param b Version string (e.g., "1.0.0", "1.0.0+build")
|
||||
* @returns -1 if a < b, 0 if equal, 1 if a > b
|
||||
*/
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = stripSemverMetadata(a).split('.').map(Number);
|
||||
const bParts = stripSemverMetadata(b).split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aPart = aParts[i] || 0;
|
||||
const bPart = bParts[i] || 0;
|
||||
|
||||
if (aPart > bPart) return 1;
|
||||
if (aPart < bPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure required command-line tools are installed
|
||||
*
|
||||
* @param tools Array of required tools (e.g., ['git', 'tsc'])
|
||||
* @throws Error with installation instructions if any tool is missing
|
||||
*/
|
||||
export function ensureToolsInstalled(tools: string[]): void {
|
||||
const missing: string[] = [];
|
||||
// Use platform-specific command: 'where' on Windows, 'which' on Unix-like systems
|
||||
const checkCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
execSync(`${checkCommand} ${tool}`, { stdio: 'pipe' });
|
||||
} catch {
|
||||
missing.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
const errorMessage = [
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'❌ MISSING REQUIRED TOOLS',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
`The following tools are required but not found: ${missing.join(', ')}`,
|
||||
'',
|
||||
'Please install the missing tools and try again.',
|
||||
'',
|
||||
'═'.repeat(70),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
console.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
430
hooks/utils/super-claude-config-loader.ts
Normal file
430
hooks/utils/super-claude-config-loader.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Super Claude Configuration Loader
|
||||
*
|
||||
* Loads unified plugin configuration from super-claude-config.json files
|
||||
* with support for:
|
||||
* - Plugin defaults (plugins/{plugin}/super-claude-config.json)
|
||||
* - Project overrides (.claude/super-claude-config.json)
|
||||
* - Legacy skill-rules.json backwards compatibility
|
||||
* - Environment variable overrides (highest priority)
|
||||
* - Deep merge with caching for performance (<50ms)
|
||||
*
|
||||
* Loading priority: env vars > project overrides > plugin defaults
|
||||
*
|
||||
* @see {@link https://github.com/jbabin91/super-claude} for documentation
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
HookConfig,
|
||||
LegacySkillRules,
|
||||
PluginConfig,
|
||||
ProjectConfig,
|
||||
ResolvedConfig,
|
||||
SkillConfig,
|
||||
} from './config-types.js';
|
||||
import {
|
||||
validateLegacySkillRules,
|
||||
validatePluginConfig,
|
||||
validateProjectConfig,
|
||||
} from './config-validation.js';
|
||||
|
||||
/**
|
||||
* Configuration cache for performance
|
||||
* Key: pluginName:cwd
|
||||
*/
|
||||
const configCache = new Map<string, ResolvedConfig>();
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*
|
||||
* Arrays are replaced entirely (not merged item-by-item)
|
||||
* Nested objects are merged recursively
|
||||
*
|
||||
* @param target Target object
|
||||
* @param source Source object with overrides
|
||||
* @returns Merged object
|
||||
*/
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: Partial<T>,
|
||||
): T {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
if (sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Arrays: replace entirely
|
||||
if (Array.isArray(sourceValue)) {
|
||||
result[key] = sourceValue as T[Extract<keyof T, string>];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Objects: merge recursively
|
||||
if (
|
||||
typeof sourceValue === 'object' &&
|
||||
sourceValue !== null &&
|
||||
typeof targetValue === 'object' &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
) as T[Extract<keyof T, string>];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Primitives: replace
|
||||
result[key] = sourceValue as T[Extract<keyof T, string>];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load JSON file safely
|
||||
*
|
||||
* @param filePath Path to JSON file
|
||||
* @returns Parsed JSON or null if not found/invalid
|
||||
*/
|
||||
function loadJsonFile(filePath: string): unknown {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content) as unknown;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`[ERROR] Failed to load ${filePath}: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find plugin root directory
|
||||
*
|
||||
* Searches upward from current file to find plugins directory
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @returns Path to plugin root or null if not found
|
||||
*/
|
||||
function findPluginRoot(cwd: string): string | null {
|
||||
// Try to find plugins directory by searching upward
|
||||
let current = cwd;
|
||||
const root = path.parse(current).root;
|
||||
|
||||
while (current !== root) {
|
||||
const pluginsDir = path.join(current, 'plugins');
|
||||
if (existsSync(pluginsDir)) {
|
||||
return current;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin default configuration
|
||||
*
|
||||
* Tries to load from:
|
||||
* 1. plugins/{plugin}/super-claude-config.json
|
||||
* 2. plugins/{plugin}/skill-rules.json (legacy, with warning)
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param pluginName Plugin name
|
||||
* @returns Plugin configuration or empty config
|
||||
*/
|
||||
function loadPluginDefaults(
|
||||
cwd: string,
|
||||
pluginName: string,
|
||||
): Partial<PluginConfig> {
|
||||
const pluginRoot = findPluginRoot(cwd);
|
||||
if (!pluginRoot) {
|
||||
console.error(
|
||||
`[WARNING] Could not find plugin root from ${cwd}, using empty defaults`,
|
||||
);
|
||||
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||
}
|
||||
|
||||
const pluginDir = path.join(pluginRoot, 'plugins', pluginName);
|
||||
|
||||
// Try new format first
|
||||
const configPath = path.join(pluginDir, 'super-claude-config.json');
|
||||
const configData = loadJsonFile(configPath);
|
||||
|
||||
if (configData) {
|
||||
const result = validatePluginConfig(configData);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[ERROR] Invalid plugin config in ${pluginName}:\n${result.errors}`,
|
||||
);
|
||||
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Try legacy format
|
||||
const legacyPath = path.join(pluginDir, 'skill-rules.json');
|
||||
const legacyData = loadJsonFile(legacyPath);
|
||||
|
||||
if (legacyData) {
|
||||
console.warn(
|
||||
`[DEPRECATION] ${pluginName} using deprecated skill-rules.json. Migrate to super-claude-config.json`,
|
||||
);
|
||||
|
||||
const result = validateLegacySkillRules(legacyData);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[ERROR] Invalid legacy config in ${pluginName}:\n${result.errors}`,
|
||||
);
|
||||
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||
}
|
||||
|
||||
// Convert legacy format to new format
|
||||
return convertLegacyFormat(result.data, pluginName);
|
||||
}
|
||||
|
||||
// No config found, use empty defaults
|
||||
return { plugin: pluginName, skills: {}, hooks: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy skill-rules.json to new format
|
||||
*
|
||||
* @param legacy Legacy skill rules
|
||||
* @param pluginName Plugin name
|
||||
* @returns Plugin configuration
|
||||
*/
|
||||
function convertLegacyFormat(
|
||||
legacy: LegacySkillRules,
|
||||
pluginName: string,
|
||||
): Partial<PluginConfig> {
|
||||
const skills: Record<string, SkillConfig> = {};
|
||||
|
||||
for (const [skillName, skillData] of Object.entries(legacy.skills)) {
|
||||
skills[skillName] = {
|
||||
enabled: !legacy.overrides?.disabled?.includes(
|
||||
`${legacy.plugin.namespace}/${skillName}`,
|
||||
),
|
||||
triggers: {
|
||||
keywords: skillData.promptTriggers?.keywords ?? [],
|
||||
patterns: skillData.promptTriggers?.intentPatterns ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: pluginName,
|
||||
skills,
|
||||
hooks: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project override configuration
|
||||
*
|
||||
* Loads from .claude/super-claude-config.json
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @returns Project configuration or null
|
||||
*/
|
||||
function loadProjectOverrides(cwd: string): ProjectConfig | null {
|
||||
const overridePath = path.join(cwd, '.claude', 'super-claude-config.json');
|
||||
const overrideData = loadJsonFile(overridePath);
|
||||
|
||||
if (!overrideData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = validateProjectConfig(overrideData);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[ERROR] Invalid project config:\n${result.errors}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply environment variable overrides
|
||||
*
|
||||
* Environment variables have highest priority.
|
||||
* Format: CLAUDE_HOOK_{HOOK_NAME}_ENABLED=true|false
|
||||
*
|
||||
* @param config Configuration to modify
|
||||
* @param pluginName Plugin name (for logging)
|
||||
*/
|
||||
function applyEnvironmentOverrides(
|
||||
config: ResolvedConfig,
|
||||
pluginName: string,
|
||||
): void {
|
||||
// Hook enabled overrides
|
||||
for (const hookName of Object.keys(config.hooks)) {
|
||||
const envKey = `CLAUDE_HOOK_${hookName.replaceAll(/[A-Z]/g, (m) => `_${m}`).toUpperCase()}`;
|
||||
const envValue = process.env[envKey];
|
||||
|
||||
if (envValue !== undefined) {
|
||||
config.hooks[hookName].enabled = envValue === 'true' || envValue === '1';
|
||||
console.error(
|
||||
`[DEBUG] ${pluginName}:${hookName} enabled=${config.hooks[hookName].enabled} (from ${envKey})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge configuration for a plugin
|
||||
*
|
||||
* Merge order: plugin defaults → project overrides → env vars
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param pluginName Plugin name
|
||||
* @param useCache Whether to use cached config
|
||||
* @returns Resolved configuration
|
||||
*/
|
||||
export function loadPluginConfig(
|
||||
cwd: string,
|
||||
pluginName: string,
|
||||
useCache = true,
|
||||
): ResolvedConfig {
|
||||
const cacheKey = `${pluginName}:${cwd}`;
|
||||
|
||||
// Check cache
|
||||
if (useCache && configCache.has(cacheKey)) {
|
||||
return configCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// Load plugin defaults
|
||||
const pluginDefaults = loadPluginDefaults(cwd, pluginName);
|
||||
|
||||
// Load project overrides
|
||||
const projectOverrides = loadProjectOverrides(cwd);
|
||||
|
||||
// Start with plugin defaults
|
||||
const resolved: ResolvedConfig = {
|
||||
skills: pluginDefaults.skills ?? {},
|
||||
hooks: pluginDefaults.hooks ?? {},
|
||||
};
|
||||
|
||||
// Apply project overrides for this plugin
|
||||
if (projectOverrides?.[pluginName]) {
|
||||
const pluginOverrides = projectOverrides[pluginName];
|
||||
|
||||
if (pluginOverrides.skills) {
|
||||
for (const [skillName, skillOverride] of Object.entries(
|
||||
pluginOverrides.skills,
|
||||
)) {
|
||||
const defaultSkill = resolved.skills[skillName] ?? {
|
||||
enabled: true,
|
||||
triggers: { keywords: [], patterns: [] },
|
||||
};
|
||||
resolved.skills[skillName] = deepMerge(defaultSkill, skillOverride);
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginOverrides.hooks) {
|
||||
for (const [hookName, hookOverride] of Object.entries(
|
||||
pluginOverrides.hooks,
|
||||
)) {
|
||||
const defaultHook = resolved.hooks[hookName] ?? { enabled: true };
|
||||
resolved.hooks[hookName] = deepMerge(defaultHook, hookOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply environment variable overrides (highest priority)
|
||||
applyEnvironmentOverrides(resolved, pluginName);
|
||||
|
||||
// Cache result
|
||||
configCache.set(cacheKey, resolved);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hook configuration
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param pluginName Plugin name
|
||||
* @param hookName Hook name
|
||||
* @returns Hook configuration
|
||||
*/
|
||||
export function getHookConfig(
|
||||
cwd: string,
|
||||
pluginName: string,
|
||||
hookName: string,
|
||||
): HookConfig {
|
||||
const config = loadPluginConfig(cwd, pluginName);
|
||||
return config.hooks[hookName] ?? { enabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill configuration
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param pluginName Plugin name
|
||||
* @param skillName Skill name
|
||||
* @returns Skill configuration
|
||||
*/
|
||||
export function getSkillConfig(
|
||||
cwd: string,
|
||||
pluginName: string,
|
||||
skillName: string,
|
||||
): SkillConfig {
|
||||
const config = loadPluginConfig(cwd, pluginName);
|
||||
return (
|
||||
config.skills[skillName] ?? {
|
||||
enabled: true,
|
||||
triggers: { keywords: [], patterns: [] },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hook is enabled
|
||||
*
|
||||
* Convenience function that exits cleanly if hook is disabled.
|
||||
*
|
||||
* @param cwd Current working directory
|
||||
* @param pluginName Plugin name
|
||||
* @param hookName Hook name
|
||||
* @returns true if enabled, exits if disabled
|
||||
*/
|
||||
export function checkHookEnabled(
|
||||
cwd: string,
|
||||
pluginName: string,
|
||||
hookName: string,
|
||||
): boolean {
|
||||
const config = getHookConfig(cwd, pluginName, hookName);
|
||||
|
||||
if (!config.enabled) {
|
||||
console.error(`[DEBUG] ${pluginName}:${hookName} disabled by config`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear configuration cache
|
||||
*
|
||||
* Useful for testing or when config files are modified
|
||||
*/
|
||||
export function clearConfigCache(): void {
|
||||
configCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user