UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

1,612 lines (1,312 loc) 60 kB
# Embedded Hooks System **Status**: Draft **Authors**: Claude, 2025-07-29 **Type**: Feature **POC**: ✅ [Completed](feat-embedded-hooks-system-poc.md) ## Overview Migrate existing shell script hooks to TypeScript and package them in a dedicated `claudekit-hooks` executable. Hooks will be invoked through `claudekit-hooks [hookname]` commands in Claude Code settings.json, with per-hook configuration stored in `.claudekit/config.json`. ## Background/Problem Statement Currently, hooks are implemented as standalone shell scripts that must be copied to each project. This creates several issues: ### Current Implementation Issues - **Distribution complexity**: Shell scripts must be copied to `.claude/hooks/` - **No centralized configuration**: Hook-specific settings are hardcoded - **Limited testability**: Shell scripts are harder to unit test - **No type safety**: Configuration and execution lack type checking - **Platform inconsistencies**: Shell scripts behave differently across OS ### Desired State - Dedicated `claudekit-hooks` executable for all hook logic - Hooks invoked via `claudekit-hooks [hookname]` - Per-hook configuration in `.claudekit/config.json` - Full TypeScript implementation with type safety - Clear separation between main CLI and hooks subsystem ### Why Hooks Instead of Direct Commands? While tools like ESLint, TypeScript, and test runners can be called directly from Claude Code settings, hooks provide significant value: 1. **Context-Aware Execution**: Hooks receive Claude Code payloads (file paths, tools used) and can make intelligent decisions. For example, only running TypeScript checks on `.ts` files or only running tests related to changed files. 2. **Unified Error Formatting**: Hooks provide consistent, Claude-friendly error messages with clear instructions on how to fix issues. Direct tool output can be verbose and harder to parse. 3. **Project Configuration**: Hooks adapt to project-specific settings (timeouts, custom commands, tool versions) through `.claudekit/config.json` without modifying Claude Code settings. 4. **Graceful Fallbacks**: Hooks check for tool availability and project configuration before running, preventing errors in projects that don't use certain tools. 5. **Enhanced Validations**: Hooks can perform additional checks beyond the base tool, like detecting forbidden `any` types in TypeScript or ensuring checkpoint limits. 6. **Cross-Platform Consistency**: TypeScript hooks work consistently across Windows, macOS, and Linux, unlike shell commands that may vary. ## Goals - ✅ **Dedicated hooks binary**: Separate `claudekit-hooks` executable - ✅ **TypeScript implementation**: Rewrite shell scripts in TypeScript - ✅ **Simple CLI interface**: `claudekit-hooks [hookname]` command - ✅ **Per-hook configuration**: Configurable settings in `.claudekit/config.json` - ✅ **Same behavior**: Maintain hook logic and exit codes (not shell compatibility) - ✅ **Unix philosophy**: Separate tool for separate concern ## Non-Goals - ❌ **Auto-discovery**: No automatic hook detection or registration - ❌ **Hook metadata systems**: No complex metadata or capability definitions - ❌ **Migration tools**: No automated migration from shell to TypeScript - ❌ **Backward compatibility**: Shell script hooks will no longer be supported - ❌ **Coverage requirements**: No minimum coverage percentages or coverage reporting - ❌ **Edge case testing**: No tests for timeouts, malformed JSON, concurrent execution, or very large files - ❌ **Platform-specific tests**: No Windows-specific or cross-platform path handling tests - ❌ **Error scenario tests**: No tests for missing tools, permissions, or disk space issues - ❌ **Performance tests**: No benchmarking, performance regression, or memory usage tests ## Technical Dependencies ### External Libraries - **commander** (^14.0.0): CLI command routing - **zod** (^3.24.1): Configuration validation - **fs-extra** (^11.3.0): File operations ### Internal Dependencies - `cli/cli.ts`: Main claudekit CLI entry point - `cli/hooks-cli.ts`: Hooks binary entry point - `cli/types/config.ts`: Configuration types ## Directory Structure ``` claudekit/ ├── cli/ │ ├── cli.ts # Main claudekit CLI (existing) │ ├── hooks-cli.ts # New hooks CLI entry point │ ├── hooks/ │ │ ├── base.ts # BaseHook abstract class │ │ ├── runner.ts # HookRunner class │ │ ├── registry.ts # Hook registry │ │ ├── typecheck.ts # TypecheckHook implementation │ │ ├── no-any.ts # NoAnyHook implementation │ │ ├── eslint.ts # EslintHook implementation │ │ ├── auto-checkpoint.ts # AutoCheckpointHook implementation │ │ ├── run-related-tests.ts # RunRelatedTestsHook implementation │ │ ├── project-validation.ts # ProjectValidationHook implementation │ │ └── validate-todo.ts # ValidateTodoCompletionHook implementation │ └── types/ │ ├── config.ts # Existing config types │ └── hooks.ts # New hook-specific types ├── bin/ │ ├── claudekit # Existing CLI wrapper │ └── claudekit-hooks # New hooks CLI wrapper ├── dist/ # Built output │ ├── cli.js # Main CLI │ ├── hooks-cli.js # Hooks CLI │ └── ... # Other compiled files ├── .claudekit/ # Project hook configuration │ └── config.json # Hook settings ├── .claude/ # Claude Code configuration │ └── settings.json # Hook matchers └── package.json # Updated with dual binaries ``` ## Hook Execution Patterns Based on analysis of existing shell hooks, all hooks follow consistent patterns for input processing, command execution, output formatting, and exit code handling. ### Input Processing Pattern All hooks read Claude Code JSON from stdin and parse relevant fields: ```typescript interface ClaudePayload { tool_input?: { file_path?: string; [key: string]: any; }; stop_hook_active?: boolean; transcript_path?: string; [key: string]: any; } // Standard input processing flow async function processInput(): Promise<ClaudePayload> { const input = await readStdin(); return JSON.parse(input); } ``` ### Command Execution Patterns Hooks use three main patterns for executing external commands: **Pattern A: Output Capture** ```typescript // Capture output for analysis const { stdout, stderr, exitCode } = await execCommand(command, args); if (stdout.includes('error') || stderr) { return { success: false, output: stdout + stderr }; } ``` **Pattern B: Exit Code Check** ```typescript // Simple success/failure based on exit code const { exitCode } = await execCommand('git', ['status']); if (exitCode !== 0) { return { skip: true, reason: 'Not a git repository' }; } ``` **Pattern C: Streaming with Temp Files** ```typescript // For large outputs or complex processing const tempFile = await createTempFile(); await execCommand(command, args, { outputFile: tempFile }); const output = await readFile(tempFile); await cleanup(tempFile); ``` ### Output Message Patterns Hooks produce three types of output: **Silent Success** ```typescript // For background operations (auto-checkpoint) console.log(JSON.stringify({ suppressOutput: true })); process.exit(0); ``` **Progress Messages** ```typescript // Status updates to stderr (visible to user) console.error('🔍 Running ESLint on file.ts...'); console.error('✅ ESLint check passed!'); process.exit(0); ``` **Structured Error Blocks** ```typescript // Detailed error with instructions console.error(`BLOCKED: TypeScript validation failed. ${errorOutput} MANDATORY INSTRUCTIONS: 1. Fix ALL TypeScript errors shown above 2. Run the project's type check command to verify (Check AGENT.md/CLAUDE.md or package.json scripts) `); process.exit(2); ``` ### Exit Code Logic Hooks use consistent exit codes: - `0` - Success or allowed to continue (skip conditions) - `2` - Block operation with error message Note: The POC incorrectly used exit code 1 for failures. This should be corrected in the full implementation. **Binary Decision** ```typescript if (validationFailed) { showError(); process.exit(2); } process.exit(0); ``` **Aggregated Results** ```typescript let hasFailures = false; for (const check of checks) { if (!await runCheck(check)) { hasFailures = true; } } process.exit(hasFailures ? 2 : 0); ``` **JSON Response (Stop hooks)** ```typescript // Stop hooks return JSON with exit 0 console.log(JSON.stringify({ decision: 'block', reason: 'Incomplete todos remain' })); process.exit(0); ``` ### Tool Detection Pattern Hooks check tool availability before running: ```typescript async function hasTypeScript(projectRoot: string): Promise<boolean> { // Check configuration exists if (!await fileExists(join(projectRoot, 'tsconfig.json'))) { return false; } // Check tool is executable try { await execCommand(packageExec, ['tsc', '--version']); return true; } catch { return false; } } ``` ### State Management Patterns **Git State** ```typescript const { stdout } = await execCommand('git', ['status', '--porcelain']); if (stdout.trim()) { // Has changes - process them } ``` **Complex State (Transcript parsing)** ```typescript const lines = (await readFile(transcriptPath)).split('\n'); for (const line of lines.reverse()) { // Read from end const entry = JSON.parse(line); if (entry.toolUseResult?.newTodos) { return processTodos(entry.toolUseResult.newTodos); } } ``` ### Error Message Template All hooks use consistent error formatting: ``` BLOCKED: [Component] failed. [Detailed error output] MANDATORY INSTRUCTIONS: 1. [Specific required action] 2. [Another required action] ... [Optional examples or context] ``` ### Precondition Checking Hooks validate conditions before execution: ```typescript // File-specific hooks if (!payload.tool_input?.file_path) return exit(0); if (!existsSync(filePath)) return exit(0); if (!filePath.match(/\.(ts|tsx)$/)) return exit(0); // Prevent infinite loops if (payload.stop_hook_active) return exit(0); ``` ## Detailed Design ### 1. Separate Hooks Binary Structure Create new executable entry point: ```typescript // cli/hooks-cli.ts import { Command } from 'commander'; import { HookRunner } from './hooks/runner.js'; export function createHooksCLI(): Command { const program = new Command('claudekit-hooks') .description('Claude Code hooks execution system') .version('1.0.0') .argument('<hook>', 'Hook name to execute') .option('--config <path>', 'Path to config file', '.claudekit/config.json') .option('--list', 'List available hooks') .action(async (hookName: string, options: any) => { if (options.list) { console.log('Available hooks:'); console.log(' typecheck - TypeScript type checking'); console.log(' no-any - Forbid any types in TypeScript'); console.log(' eslint - ESLint code validation'); console.log(' auto-checkpoint - Git auto-checkpoint on stop'); console.log(' run-related-tests - Run tests for changed files'); console.log(' project-validation - Full project validation'); console.log(' validate-todo-completion - Validate todo completions'); process.exit(0); } const hookRunner = new HookRunner(options.config); const exitCode = await hookRunner.run(hookName); process.exit(exitCode); }); return program; } // Entry point if (import.meta.url === `file://${process.argv[1]}`) { createHooksCLI().parse(process.argv); } ``` ### 2. Common Hook Utilities Based on the patterns identified, we need shared utilities: ```typescript // cli/hooks/utils.ts import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs-extra'; import * as path from 'path'; const execAsync = promisify(exec); // Standard input reader export async function readStdin(): Promise<string> { return new Promise((resolve) => { let data = ''; process.stdin.on('data', chunk => data += chunk); process.stdin.on('end', () => resolve(data)); setTimeout(() => resolve(''), 1000); // Timeout fallback }); } // Project root discovery export async function findProjectRoot(startDir: string = process.cwd()): Promise<string> { try { const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: startDir }); return stdout.trim(); } catch { return process.cwd(); } } // Package manager detection export interface PackageManager { name: 'npm' | 'yarn' | 'pnpm'; exec: string; run: string; test: string; } export async function detectPackageManager(dir: string): Promise<PackageManager> { if (await fs.pathExists(path.join(dir, 'pnpm-lock.yaml'))) { return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' }; } if (await fs.pathExists(path.join(dir, 'yarn.lock'))) { return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' }; } if (await fs.pathExists(path.join(dir, 'package.json'))) { // Check packageManager field try { const pkg = await fs.readJson(path.join(dir, 'package.json')); if (pkg.packageManager?.startsWith('pnpm')) { return { name: 'pnpm', exec: 'pnpm dlx', run: 'pnpm run', test: 'pnpm test' }; } if (pkg.packageManager?.startsWith('yarn')) { return { name: 'yarn', exec: 'yarn dlx', run: 'yarn', test: 'yarn test' }; } } catch {} } return { name: 'npm', exec: 'npx', run: 'npm run', test: 'npm test' }; } // Command execution wrapper export interface ExecResult { stdout: string; stderr: string; exitCode: number; } export async function execCommand( command: string, args: string[] = [], options: { cwd?: string; timeout?: number } = {} ): Promise<ExecResult> { const fullCommand = `${command} ${args.join(' ')}`; try { const { stdout, stderr } = await execAsync(fullCommand, { cwd: options.cwd || process.cwd(), timeout: options.timeout || 30000, maxBuffer: 1024 * 1024 * 10 // 10MB }); return { stdout, stderr, exitCode: 0 }; } catch (error: any) { return { stdout: error.stdout || '', stderr: error.stderr || error.message, exitCode: error.code || 1 }; } } // Error formatting export function formatError( title: string, details: string, instructions: string[] ): string { const instructionsList = instructions .map((inst, i) => `${i + 1}. ${inst}`) .join('\n'); return `BLOCKED: ${title} ${details} MANDATORY INSTRUCTIONS: ${instructionsList}`; } // Tool availability checking export async function checkToolAvailable( tool: string, configFile: string, projectRoot: string ): Promise<boolean> { // Check config file exists if (!await fs.pathExists(path.join(projectRoot, configFile))) { return false; } // Check tool is executable const pm = await detectPackageManager(projectRoot); const result = await execCommand(pm.exec, [tool, '--version'], { cwd: projectRoot, timeout: 10000 }); return result.exitCode === 0; } ``` ### 3. Hook Implementation Structure Each hook extends the BaseHook class and implements the execute method. See the complete implementations in sections 4, 6, 8-11. ### 4. Base Hook Class The base hook class incorporates all common patterns: ```typescript // cli/hooks/base.ts import * as fs from 'fs-extra'; import * as path from 'path'; import { execCommand, detectPackageManager, formatError, findProjectRoot } from './utils.js'; import type { ExecResult, PackageManager } from './utils.js'; export interface ClaudePayload { tool_input?: { file_path?: string; [key: string]: any; }; stop_hook_active?: boolean; transcript_path?: string; [key: string]: any; } export interface HookContext { filePath?: string; projectRoot: string; payload: ClaudePayload; packageManager: PackageManager; } export interface HookResult { exitCode: number; suppressOutput?: boolean; jsonResponse?: any; } export interface HookConfig { command?: string; timeout?: number; [key: string]: any; // Hook-specific config } export abstract class BaseHook { abstract name: string; protected config: HookConfig; constructor(config: HookConfig = {}) { this.config = config; } // Main execution method - implements common flow async run(payload: ClaudePayload): Promise<HookResult> { // Check for infinite loop prevention if (payload.stop_hook_active) { return { exitCode: 0 }; } // Extract file path if present const filePath = payload.tool_input?.file_path; // Find project root const projectRoot = await findProjectRoot(filePath ? path.dirname(filePath) : process.cwd()); // Detect package manager const packageManager = await detectPackageManager(projectRoot); // Create context const context: HookContext = { filePath, projectRoot, payload, packageManager }; // Execute hook-specific logic return this.execute(context); } // Hook-specific implementation abstract execute(context: HookContext): Promise<HookResult>; // Common utilities protected async execCommand( command: string, args: string[] = [], options?: { cwd?: string; timeout?: number } ): Promise<ExecResult> { return execCommand(command, args, { timeout: this.config.timeout, ...options }); } // Progress message to stderr protected progress(message: string): void { console.error(message); } // Success message to stderr protected success(message: string): void { console.error(`✅ ${message}`); } // Warning message to stderr protected warning(message: string): void { console.error(`⚠️ ${message}`); } // Error output with instructions protected error(title: string, details: string, instructions: string[]): void { console.error(formatError(title, details, instructions)); } // Silent JSON output protected jsonOutput(data: any): void { console.log(JSON.stringify(data)); } // File operations protected async fileExists(filePath: string): Promise<boolean> { return fs.pathExists(filePath); } protected async readFile(filePath: string): Promise<string> { return fs.readFile(filePath, 'utf-8'); } // Skip conditions protected shouldSkipFile(filePath: string | undefined, extensions: string[]): boolean { if (!filePath) return true; if (!extensions.some(ext => filePath.endsWith(ext))) return true; return false; } } ``` ### 5. Decomposed TypeScript Hooks The original TypeScript hook has been split into two single-purpose hooks: #### 5.1 TypeScript Compiler Hook ```typescript // cli/hooks/typecheck.ts import { BaseHook, HookContext, HookResult } from './base.js'; import { checkToolAvailable } from './utils.js'; export class TypecheckHook extends BaseHook { name = 'typecheck'; async execute(context: HookContext): Promise<HookResult> { const { filePath, projectRoot, packageManager } = context; // Skip if no file or wrong extension if (this.shouldSkipFile(filePath, ['.ts', '.tsx'])) { return { exitCode: 0 }; } // Check if TypeScript is available if (!await checkToolAvailable('tsc', 'tsconfig.json', projectRoot)) { this.warning('No TypeScript configuration found, skipping check'); return { exitCode: 0 }; } this.progress(`📘 Type-checking ${filePath}`); // Run TypeScript compiler const command = this.config.command || `${packageManager.exec} tsc --noEmit`; const result = await this.execCommand(command, [], { cwd: projectRoot }); if (result.exitCode !== 0) { this.error( 'TypeScript compilation failed', result.stderr || result.stdout, [ 'Fix ALL TypeScript errors shown above', 'Run the project\'s type check command to verify all errors are resolved', '(Check AGENT.md/CLAUDE.md or package.json scripts for the exact command)' ] ); return { exitCode: 2 }; } this.success('TypeScript check passed!'); return { exitCode: 0 }; } } ``` #### 5.2 No-Any Types Hook ```typescript // cli/hooks/no-any.ts import { BaseHook, HookContext, HookResult } from './base.js'; export class NoAnyHook extends BaseHook { name = 'no-any'; async execute(context: HookContext): Promise<HookResult> { const { filePath } = context; // Skip if no file or wrong extension if (this.shouldSkipFile(filePath, ['.ts', '.tsx'])) { return { exitCode: 0 }; } this.progress(`🚫 Checking for 'any' types in ${filePath}`); const content = await this.readFile(filePath!); const lines = content.split('\n'); const errors: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineNum = i + 1; // Skip comments and test utilities if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.includes('expect.any(') || line.includes('.any(')) { continue; } // Check for forbidden 'any' patterns const anyPattern = /:\s*any\b|:\s*any\[\]|<any>|as\s+any\b|=\s*any\b/; if (anyPattern.test(line)) { errors.push(`Line ${lineNum}: ${line.trim()}`); } } if (errors.length > 0) { this.error( 'Forbidden \'any\' types detected', `❌ File contains ${errors.length} forbidden 'any' type${errors.length > 1 ? 's' : ''}:\n\n${errors.join('\n')}`, [ 'Replace ALL \'any\' types with proper types', 'Use specific interfaces, union types, or generics instead of \'any\'', 'Examples of fixes:', ' - Instead of: data: any → Define: interface Data { ... }', ' - Instead of: items: any[] → Use: items: Item[] or items: Array<{id: string, name: string}>', ' - Instead of: value: any → Use: value: string | number | boolean', ' - Instead of: response: any → Use: response: unknown (then add type guards)' ] ); return { exitCode: 2 }; } this.success('No forbidden \'any\' types found!'); return { exitCode: 0 }; } } ``` ### 6. Hook Runner Implementation The hook runner manages hook execution and configuration: ```typescript // cli/hooks/runner.ts import * as fs from 'fs-extra'; import * as path from 'path'; import { z } from 'zod'; import { readStdin } from './utils.js'; import { BaseHook, HookResult } from './base.js'; // Import all hooks import { TypecheckHook } from './typecheck.js'; import { NoAnyHook } from './no-any.js'; import { EslintHook } from './eslint.js'; import { AutoCheckpointHook } from './auto-checkpoint.js'; import { RunRelatedTestsHook } from './run-related-tests.js'; import { ProjectValidationHook } from './project-validation.js'; import { ValidateTodoCompletionHook } from './validate-todo.js'; // Configuration schema const HookConfigSchema = z.object({ command: z.string().optional(), timeout: z.number().optional().default(30000), }).passthrough(); const ConfigSchema = z.object({ hooks: z.record(z.string(), HookConfigSchema).optional().default({}), }); export class HookRunner { private hooks: Map<string, new (config: any) => BaseHook> = new Map(); private configPath: string; constructor(configPath: string = '.claudekit/config.json') { this.configPath = configPath; // Register all hooks this.hooks.set('typecheck', TypecheckHook); this.hooks.set('no-any', NoAnyHook); this.hooks.set('eslint', EslintHook); this.hooks.set('auto-checkpoint', AutoCheckpointHook); this.hooks.set('run-related-tests', RunRelatedTestsHook); this.hooks.set('project-validation', ProjectValidationHook); this.hooks.set('validate-todo-completion', ValidateTodoCompletionHook); } async run(hookName: string): Promise<number> { // Get hook class const HookClass = this.hooks.get(hookName); if (!HookClass) { console.error(`Unknown hook: ${hookName}`); return 1; } // Load configuration const config = await this.loadConfig(); const hookConfig = config.hooks[hookName] || {}; // Read Claude payload from stdin const input = await readStdin(); let payload; try { payload = JSON.parse(input || '{}'); } catch { payload = {}; } // Create and run hook const hook = new HookClass(hookConfig); const result = await hook.run(payload); // Handle different result types if (result.jsonResponse) { console.log(JSON.stringify(result.jsonResponse)); } return result.exitCode; } private async loadConfig(): Promise<z.infer<typeof ConfigSchema>> { try { const configPath = path.resolve(this.configPath); const configData = await fs.readJson(configPath); return ConfigSchema.parse(configData); } catch { // Return default config if file doesn't exist or is invalid return { hooks: {} }; } } } ``` ### 7. Auto-Checkpoint Hook Example Example of a simple hook using the new patterns: ```typescript // cli/hooks/auto-checkpoint.ts import { BaseHook, HookContext, HookResult } from './base.js'; export class AutoCheckpointHook extends BaseHook { name = 'auto-checkpoint'; async execute(context: HookContext): Promise<HookResult> { const { projectRoot } = context; const prefix = this.config.prefix || 'claude'; const maxCheckpoints = this.config.maxCheckpoints || 10; // Check if there are any changes to checkpoint const { stdout } = await this.execCommand('git', ['status', '--porcelain'], { cwd: projectRoot }); if (!stdout.trim()) { // No changes, suppress output return { exitCode: 0, suppressOutput: true }; } // Create checkpoint with timestamp const timestamp = new Date().toISOString(); const message = `${prefix}-checkpoint: Auto-save at ${timestamp}`; // Add all files temporarily await this.execCommand('git', ['add', '-A'], { cwd: projectRoot }); // Create stash object without modifying working directory const { stdout: stashSha } = await this.execCommand( 'git', ['stash', 'create', message], { cwd: projectRoot } ); if (stashSha.trim()) { // Store the stash in the stash list await this.execCommand( 'git', ['stash', 'store', '-m', message, stashSha.trim()], { cwd: projectRoot } ); // Reset index to unstage files await this.execCommand('git', ['reset'], { cwd: projectRoot }); // Clean up old checkpoints if needed await this.cleanupOldCheckpoints(prefix, maxCheckpoints, projectRoot); } // Silent success return { exitCode: 0, suppressOutput: true, jsonResponse: { suppressOutput: true } }; } private async cleanupOldCheckpoints( prefix: string, maxCount: number, projectRoot: string ): Promise<void> { // Get list of checkpoints const { stdout } = await this.execCommand( 'git', ['stash', 'list'], { cwd: projectRoot } ); const checkpoints = stdout .split('\n') .filter(line => line.includes(`${prefix}-checkpoint`)) .map((line, index) => ({ line, index })); // Remove old checkpoints if over limit if (checkpoints.length > maxCount) { const toRemove = checkpoints.slice(maxCount); for (const checkpoint of toRemove.reverse()) { await this.execCommand( 'git', ['stash', 'drop', `stash@{${checkpoint.index}}`], { cwd: projectRoot } ); } } } } ``` ### 8. Hook Decomposition Benefits The TypeScript hook was decomposed into two single-purpose hooks to follow the Unix philosophy: 1. **`typecheck`** - Runs TypeScript compiler to check for type errors - Focuses solely on compilation errors - Uses the project's TypeScript configuration - Can be configured with custom commands 2. **`no-any`** - Forbids the use of 'any' types in TypeScript files - Single responsibility: enforce strict typing - Fast execution (no compilation required) - Clear, focused error messages This decomposition provides several benefits: - **Granular Control**: Users can enable/disable specific checks - **Performance**: Run only what's needed (e.g., skip compilation for quick 'any' checks) - **Clear Error Messages**: Each hook provides focused feedback for its specific concern - **Flexible Configuration**: Different timeouts and settings for each check - **Parallel Execution**: Claude Code can run both hooks concurrently ### 9. Summary of Key Patterns The hook execution patterns analysis revealed: 1. **Standardized Input Flow**: All hooks read JSON from stdin, parse fields, and validate preconditions 2. **Consistent Exit Codes**: 0 for success/skip, 2 for blocking errors 3. **Three Output Types**: Silent JSON, progress to stderr, structured error blocks 4. **Command Execution**: Wrapped with timeout, output capture, and error handling 5. **Tool Detection**: Check config file + executable availability before running 6. **Error Formatting**: Consistent "BLOCKED:" format with instructions 7. **State Management**: Git operations, transcript parsing, file analysis 8. **Self-Contained Design**: Each hook includes all needed functionality These patterns enable a clean TypeScript implementation with: - Base class handling common flow - Utility module for shared functions - Consistent interfaces across all hooks - Type-safe configuration via Zod schemas - Proper error handling and user feedback ### 10. ESLint Hook Implementation ```typescript // cli/hooks/eslint.ts import * as path from 'path'; import { BaseHook, HookContext, HookResult } from './base.js'; export class EslintHook extends BaseHook { name = 'eslint'; async execute(context: HookContext): Promise<HookResult> { const { filePath, projectRoot } = context; // Skip if no file path or not JavaScript/TypeScript file if (!filePath || !filePath.match(/\.(js|jsx|ts|tsx)$/)) { return { exitCode: 0, skipped: true }; } // Check if ESLint is configured if (!await this.hasEslint(projectRoot)) { this.log('info', 'ESLint not configured, skipping lint check'); return { exitCode: 0, skipped: true }; } this.log('info', `Running ESLint on ${filePath}...`); // Run ESLint const eslintResult = await this.runEslint(filePath, projectRoot); if (eslintResult.code !== 0 || this.hasEslintErrors(eslintResult.stdout)) { const errorMessage = this.formatEslintErrors(eslintResult.stdout || eslintResult.stderr); this.outputError('ESLint check failed', errorMessage); return { exitCode: 2 }; } this.log('info', 'ESLint check passed!'); return { exitCode: 0 }; } private async hasEslint(projectRoot: string): Promise<boolean> { // Check for ESLint config files const configFiles = [ '.eslintrc.json', '.eslintrc.js', '.eslintrc.yml', '.eslintrc.yaml', 'eslint.config.js', 'eslint.config.mjs' ]; for (const configFile of configFiles) { if (await this.fileExists(path.join(projectRoot, configFile))) { // Verify ESLint is available const pm = await this.detectPackageManager(projectRoot); const pmExec = this.getPackageManagerExec(pm); const result = await this.exec(`${pmExec} --quiet eslint --version`, { cwd: projectRoot, timeout: 10000 }); return result.code === 0; } } return false; } private async runEslint(filePath: string, projectRoot: string): Promise<any> { const pm = await this.detectPackageManager(projectRoot); const pmExec = this.getPackageManagerExec(pm); const eslintCommand = this.config.command || `${pmExec} eslint`; // Build ESLint arguments const eslintArgs: string[] = []; // Add file extensions if configured if (this.config.extensions) { eslintArgs.push(`--ext ${this.config.extensions.join(',')}`); } // Add fix flag if configured if (this.config.fix) { eslintArgs.push('--fix'); } // Add the file path eslintArgs.push(`"${filePath}"`); const fullCommand = `${eslintCommand} ${eslintArgs.join(' ')}`; return await this.exec(fullCommand, { cwd: projectRoot, timeout: this.config.timeout || 30000 }); } private hasEslintErrors(output: string): boolean { return output.includes('error') || output.includes('warning'); } private formatEslintErrors(output: string): string { return ` ${output} MANDATORY INSTRUCTIONS: You MUST fix ALL lint errors and warnings shown above. REQUIRED ACTIONS: 1. Fix all errors shown above 2. Run the project's lint command to verify all issues are resolved (Check AGENT.md/CLAUDE.md or package.json scripts for the exact command) 3. Common fixes: - Missing semicolons or trailing commas - Unused variables (remove or use them) - Console.log statements (remove from production code) - Improper indentation or spacing`; } } ``` ### 11. Run Related Tests Hook Implementation ```typescript // cli/hooks/run-related-tests.ts import * as path from 'path'; import { BaseHook, HookContext, HookResult } from './base.js'; export class RunRelatedTestsHook extends BaseHook { name = 'run-related-tests'; async execute(context: HookContext): Promise<HookResult> { const { filePath, projectRoot, packageManager } = context; // Skip if no file path if (!filePath) { return { exitCode: 0 }; } // Only run tests for source files if (!filePath.match(/\.(js|jsx|ts|tsx)$/)) { return { exitCode: 0 }; } // Skip test files themselves if (filePath.match(/\.(test|spec)\.(js|jsx|ts|tsx)$/)) { return { exitCode: 0 }; } this.progress(`🧪 Running tests related to: ${filePath}...`); // Find related test files const testFiles = await this.findRelatedTestFiles(filePath); if (testFiles.length === 0) { this.warning(`No test files found for ${path.basename(filePath)}`); this.warning(`Consider creating tests in: ${path.dirname(filePath)}/${path.basename(filePath, path.extname(filePath))}.test${path.extname(filePath)}`); return { exitCode: 0 }; } this.progress(`Found related test files: ${testFiles.join(', ')}`); // Run tests const testCommand = this.config.command || `${packageManager.test}`; const result = await this.execCommand(testCommand, ['--', ...testFiles], { cwd: projectRoot }); if (result.exitCode !== 0) { this.error( `Tests failed for ${filePath}`, result.stdout + result.stderr, [ 'You MUST fix ALL test failures, regardless of whether they seem related to your recent changes', 'First, examine the failing test output above to understand what\'s broken', `Run the failing tests individually for detailed output: ${testCommand} -- ${testFiles.join(' ')}`, `Then run ALL tests to ensure nothing else is broken: ${testCommand}`, 'Fix ALL failing tests by:', ' - Reading each test to understand its purpose', ' - Determining if the test or the implementation is wrong', ' - Updating whichever needs to change to match expected behavior', ' - NEVER skip, comment out, or use .skip() to bypass tests', 'Common fixes to consider:', ' - Update mock data to match new types/interfaces', ' - Fix async timing issues with proper await/waitFor', ' - Update component props in tests to match changes', ' - Ensure test database/state is properly reset', ' - Check if API contracts have changed' ] ); return { exitCode: 2 }; } this.success('All related tests passed!'); return { exitCode: 0 }; } private async findRelatedTestFiles(filePath: string): Promise<string[]> { const baseName = path.basename(filePath, path.extname(filePath)); const dirName = path.dirname(filePath); const ext = path.extname(filePath); // Common test file patterns const testPatterns = [ `${dirName}/${baseName}.test${ext}`, `${dirName}/${baseName}.spec${ext}`, `${dirName}/__tests__/${baseName}.test${ext}`, `${dirName}/__tests__/${baseName}.spec${ext}`, ]; const foundFiles: string[] = []; for (const pattern of testPatterns) { if (await this.fileExists(pattern)) { foundFiles.push(pattern); } } return foundFiles; } } ``` ### 12. Project Validation Hook Implementation ```typescript // cli/hooks/project-validation.ts import { BaseHook, HookContext, HookResult } from './base.js'; import { checkToolAvailable } from './utils.js'; export class ProjectValidationHook extends BaseHook { name = 'project-validation'; async execute(context: HookContext): Promise<HookResult> { const { projectRoot, packageManager } = context; this.progress('Running project-wide validation...'); let hasFailures = false; let validationOutput = ''; // Run TypeScript check if available if (await checkToolAvailable('tsc', 'tsconfig.json', projectRoot)) { validationOutput += '📘 Running TypeScript validation...\n'; const tsCommand = this.config.typescriptCommand || `${packageManager.exec} tsc --noEmit`; const tsResult = await this.execCommand(tsCommand, [], { cwd: projectRoot }); if (tsResult.exitCode === 0) { validationOutput += '✅ TypeScript validation passed\n\n'; } else { hasFailures = true; validationOutput += '❌ TypeScript validation failed:\n'; validationOutput += this.indent(tsResult.stderr || tsResult.stdout) + '\n\n'; } } // Run ESLint if available if (await checkToolAvailable('eslint', '.eslintrc.json', projectRoot)) { validationOutput += '🔍 Running ESLint validation...\n'; const eslintCommand = this.config.eslintCommand || `${packageManager.exec} eslint . --ext .js,.jsx,.ts,.tsx`; const eslintResult = await this.execCommand(eslintCommand, [], { cwd: projectRoot }); if (eslintResult.exitCode === 0 && !eslintResult.stdout.includes('error')) { validationOutput += '✅ ESLint validation passed\n\n'; } else { hasFailures = true; validationOutput += '❌ ESLint validation failed:\n'; validationOutput += this.indent(eslintResult.stdout) + '\n\n'; } } // Run tests if available const { stdout: pkgJson } = await this.execCommand('cat', ['package.json'], { cwd: projectRoot }); if (pkgJson.includes('"test"')) { validationOutput += '🧪 Running test suite...\n'; const testCommand = this.config.testCommand || packageManager.test; const testResult = await this.execCommand(testCommand, [], { cwd: projectRoot }); if (testResult.exitCode === 0 && !testResult.stdout.match(/FAIL|failed|Error:|failing/)) { validationOutput += '✅ Test suite passed\n\n'; } else { hasFailures = true; validationOutput += '❌ Test suite failed:\n'; validationOutput += this.indent(testResult.stdout + testResult.stderr) + '\n\n'; } } // Output results if (hasFailures) { // Build list of failed checks const failedChecks: string[] = []; if (validationOutput.includes('❌ TypeScript validation failed')) { failedChecks.push('Type checking command'); } if (validationOutput.includes('❌ ESLint validation failed')) { failedChecks.push('Lint command'); } if (validationOutput.includes('❌ Test suite failed')) { failedChecks.push('Test command'); } console.error(`████ Project Validation Failed ████ Your implementation has validation errors that must be fixed: ${validationOutput} REQUIRED ACTIONS: 1. Fix all errors shown above 2. Run the failed validation commands to verify fixes: ${failedChecks.map(check => ` - ${check}`).join('\n')} (Check AGENT.md/CLAUDE.md or package.json scripts for exact commands) 3. Make necessary corrections 4. The validation will run again automatically`); return { exitCode: 2 }; } this.success('All validations passed! Great work!'); return { exitCode: 0 }; } private indent(text: string, spaces: number = 2): string { return text.split('\n').map(line => ' '.repeat(spaces) + line).join('\n'); } } ``` ### 13. Validate Todo Completion Hook Implementation ```typescript // cli/hooks/validate-todo.ts import * as path from 'path'; import { BaseHook, HookContext, HookResult } from './base.js'; interface Todo { content: string; status: 'pending' | 'in_progress' | 'completed'; } export class ValidateTodoCompletionHook extends BaseHook { name = 'validate-todo-completion'; async execute(context: HookContext): Promise<HookResult> { const { payload } = context; // Get transcript path let transcriptPath = payload.transcript_path; if (!transcriptPath) { // Allow stop - no transcript to check return { exitCode: 0 }; } // Expand ~ to home directory transcriptPath = transcriptPath.replace(/^~/, process.env.HOME || ''); if (!await this.fileExists(transcriptPath)) { // Allow stop - transcript not found return { exitCode: 0 }; } // Find the most recent todo state const todoState = await this.findLatestTodoState(transcriptPath); if (!todoState) { // No todos found, allow stop return { exitCode: 0 }; } // Check for incomplete todos const incompleteTodos = todoState.filter(todo => todo.status !== 'completed'); if (incompleteTodos.length > 0) { // Block stop and return JSON response const reason = `You have ${incompleteTodos.length} incomplete todo items. You must complete all tasks before stopping: ${incompleteTodos.map(todo => ` - [${todo.status}] ${todo.content}`).join('\n')} Use TodoRead to see the current status, then complete all remaining tasks. Mark each task as completed using TodoWrite as you finish them.`; this.jsonOutput({ decision: 'block', reason: reason }); return { exitCode: 0 }; // Note: exit 0 for Stop hooks, JSON controls decision } // All todos complete, allow stop return { exitCode: 0 }; } private async findLatestTodoState(transcriptPath: string): Promise<Todo[] | null> { const content = await this.readFile(transcriptPath); const lines = content.split('\n').filter(line => line.trim()); // Read from end to find most recent todo state for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; try { const entry = JSON.parse(line); if (entry.toolUseResult?.newTodos && Array.isArray(entry.toolUseResult.newTodos)) { return entry.toolUseResult.newTodos; } } catch { // Not valid JSON, continue } } return null; } } ``` ### 14. Hook Registry ```typescript // cli/hooks/registry.ts import { TypecheckHook } from './typecheck.js'; import { NoAnyHook } from './no-any.js'; import { EslintHook } from './eslint.js'; import { AutoCheckpointHook } from './auto-checkpoint.js'; import { RunRelatedTestsHook } from './run-related-tests.js'; import { ProjectValidationHook } from './project-validation.js'; import { ValidateTodoCompletionHook } from './validate-todo.js'; export const HOOK_REGISTRY = { 'typecheck': TypecheckHook, 'no-any': NoAnyHook, 'eslint': EslintHook, 'auto-checkpoint': AutoCheckpointHook, 'run-related-tests': RunRelatedTestsHook, 'project-validation': ProjectValidationHook, 'validate-todo-completion': ValidateTodoCompletionHook, }; export type HookName = keyof typeof HOOK_REGISTRY; ``` ### 15. Configuration Schema ```typescript // cli/types/hooks.ts import { z } from 'zod'; // Base hook configuration that all hooks share export const BaseHookConfigSchema = z.object({ command: z.string().optional(), timeout: z.number().optional().default(30000), }).passthrough(); // Allow hook-specific fields like 'pattern', 'prefix', etc. // Complete configuration schema export const ClaudekitConfigSchema = z.object({ hooks: z.record(z.string(), BaseHookConfigSchema).optional().default({}), }); export type HookConfig = z.infer<typeof BaseHookConfigSchema>; export type ClaudekitConfig = z.infer<typeof ClaudekitConfigSchema>; ``` ### 16. Example .claudekit/config.json **Important**: The separation of concerns is clean: - `.claudekit/config.json` defines **HOW** hooks run (commands, timeouts, settings) - `.claude/settings.json` defines **WHICH** hooks run (via matcher rules) A hook is executed when it's referenced in `.claude/settings.json`. If the hook has configuration in `.claudekit/config.json`, those settings are used. Otherwise, the hook runs with default settings. ```json { "hooks": { "typecheck": { "command": "pnpm exec tsc --noEmit", "timeout": 45000 }, "no-any": { "timeout": 5000 }, "eslint": { "command": "pnpm exec eslint", "timeout": 30000 }, "run-related-tests": { "command": "pnpm test", "timeout": 60000, "pattern": "**/*.{test,spec}.{js,ts}" }, "auto-checkpoint": { "timeout": 10000, "prefix": "claude", "maxCheckpoints": 10 }, "project-validation": { "command": "pnpm run validate", "timeout": 120000 }, "validate-todo-completion": { "timeout": 5000 } } } ``` ### 17. Claude Code Integration Update `.claude/settings.json` to use claudekit-hooks: ```json { "hooks": { "PostToolUse": [ { "matcher": "Write|Edit|MultiEdit", "hooks": [ {"type": "command", "command": "claudekit-hooks typecheck"}, {"type": "command", "command": "claudekit-hooks no-any"} ] }, { "matcher": "Write|Edit|MultiEdit", "hooks": [{"type": "command", "command": "claudekit-hooks eslint"}] } ], "Stop": [ { "matcher": "*", "hooks": [ {"type": "command", "command": "claudekit-hooks auto-checkpoint"}, {"type": "command", "command": "claudekit-hooks validate-todo-completion"} ] } ] } } ``` ## User Experience ### Setup Flow 1. Install claudekit: `npm install -g claudekit` 2. Initialize project: `claudekit setup` 3. Configure hooks in `.claudekit/config.json` 4. Update `.claude/settings.json` to use `claudekit-hooks [name]` ### Configuration Discovery The hooks system automatically finds the project configuration by: 1. **Traversing upward** from the current directory looking for `.claudekit/config.json` 2. **Falling back to git root** if no `.claudekit` directory is found 3. **Using current directory** as a last resort This ensures hooks work correctly when Claude Code is run from any subdirectory within the project. ### Hook Binary Usage ```bash # List available hooks claudekit-hooks --list # Run specific hook (typically called by Claude Code) claudekit-hooks typecheck # Run with custom config claudekit-hooks eslint --config .claudekit/custom-config.json # Can also work standalone for testing echo '{"tool_input": {"file_path": "src/index.ts"}}' | claudekit-hooks typecheck ``` ### Configuration Example ```bash # User customizes test command for their project echo '{ "hooks": { "run-related-tests": { "testCommand": "pnpm test", "coverage": true } } }' > .claudekit/config.json ``` ## Testing Strategy The testing approach focuses on core functionality validation to ensure hooks work as expected in typical scenarios. ### Unit Tests - Test each hook class in isolation - Mock file system and command execution - Verify exit codes and error handling - Test configuration loading and validation - Focus on happy path and basic error cases ### Integration Tests - Test full hook execution flow - Verify stdin payload parsing - Test with various project configurations - Ensure behavioral compatibility with shell script hooks - Validate hook integration with Claude Code payloads ### Test Structure ```typescript describe('TypecheckHook', () => { it('should skip when no tsconfig.json exists', async () => { const hook = new TypecheckHook({}); const result = await hook.execute({ projectRoot: '/mock/project', filePath: 'test.ts', payload: {}, }); expect(result.exitCode).toBe(0); expect(result.skipped).toBe(true); }); it('should use custom command from config', async () => { const hook = new TypecheckHook({ command: 'yarn tsc' }); // ... test implementation }); }); ``` ## Integration Test Setup ### Test Structure ```typescript // tests/hooks/integration.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const HOOKS_BIN = path.join(__dirname, '../../dist/hooks-cli.js'); describe('claudekit-hooks integration', () => { let testDir: