claudekit
Version:
CLI tools for Claude Code development workflow
215 lines (183 loc) • 6.17 kB
text/typescript
/**
* BaseHook Abstract Class
* Provides the foundation for all Claude Code hooks
*/
import fs from 'fs-extra';
import * as path from 'node:path';
import { execCommand, detectPackageManager, formatError, findProjectRoot } from './utils.js';
import type { ExecResult, PackageManager } from './utils.js';
import { isHookDisabledForSubagent } from './subagent-detector.js';
import { SessionHookManager } from './session-utils.js';
export interface ClaudePayload {
tool_name?: string;
tool_input?: {
file_path?: string;
[key: string]: unknown;
};
stop_hook_active?: boolean;
transcript_path?: string;
hook_event_name?: string;
session_id?: string;
cwd?: string;
[key: string]: unknown;
}
export interface HookContext {
filePath?: string | undefined;
projectRoot: string;
payload: ClaudePayload;
packageManager: PackageManager;
}
export interface HookResult {
exitCode: number;
suppressOutput?: boolean;
jsonResponse?: unknown;
}
export interface HookConfig {
command?: string;
timeout?: number;
[key: string]: unknown; // Hook-specific config
}
export interface HookMetadata {
id: string;
displayName: string;
description: string;
category: 'validation' | 'testing' | 'git' | 'project-management' | 'utility';
triggerEvent:
| 'PostToolUse'
| 'PreToolUse'
| 'Stop'
| 'SubagentStop'
| 'SessionStart'
| 'UserPromptSubmit'
| ('Stop' | 'SubagentStop')[];
matcher?: string; // Tool patterns that trigger this hook (e.g., "Write|Edit|MultiEdit")
dependencies?: string[];
}
export abstract class BaseHook {
abstract name: string;
protected config: HookConfig;
protected debug: boolean;
constructor(config: HookConfig = {}) {
this.config = config;
this.debug = process.env['CLAUDEKIT_DEBUG'] === 'true' || false;
}
// Main execution method - implements common flow
async run(payload: ClaudePayload): Promise<HookResult> {
// Check for infinite loop prevention
if (payload.stop_hook_active === true) {
return { exitCode: 0 };
}
// Check for session-based hook disable
const transcriptPath = payload.transcript_path;
if (transcriptPath !== undefined && transcriptPath !== '') {
const sessionManager = new SessionHookManager();
const transcriptUuid = sessionManager.extractTranscriptUuid(transcriptPath);
if (transcriptUuid !== null) {
const isDisabled = await sessionManager.isHookDisabled(transcriptUuid, this.name);
if (isDisabled) {
if (this.debug) {
console.error(`${this.name}: Skipped - disabled for session ${transcriptUuid}`);
}
return { exitCode: 0, suppressOutput: true };
}
}
}
// Check for subagent context on SubagentStop events
if (payload.hook_event_name === 'SubagentStop') {
const isDisabled = await isHookDisabledForSubagent(this.name, transcriptPath);
if (isDisabled) {
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`${this.name}: Skipping - disabled for subagent`);
}
return { exitCode: 0, suppressOutput: true };
}
}
// Extract file path if present
const filePath = payload.tool_input?.file_path;
// Find project root
const projectRoot = await findProjectRoot(
filePath !== undefined && 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> {
const mergedOptions: { cwd?: string; timeout?: number } = {
...options,
};
// Only set timeout if it's defined in either config or options
if (this.config.timeout !== undefined) {
mergedOptions.timeout = this.config.timeout;
}
if (options?.timeout !== undefined) {
mergedOptions.timeout = options.timeout;
}
if (this.debug) {
console.error(`[DEBUG] Executing command: ${command} ${args.join(' ')}`);
console.error(`[DEBUG] Command options:`, mergedOptions);
}
const result = await execCommand(command, args, mergedOptions);
if (this.debug) {
console.error(`[DEBUG] Command exit code: ${result.exitCode}`);
console.error(`[DEBUG] Command stdout length: ${result.stdout.length}`);
console.error(`[DEBUG] Command stderr length: ${result.stderr.length}`);
if (result.exitCode !== 0) {
console.error(`[DEBUG] Command stderr:`, result.stderr);
}
}
return result;
}
// 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: unknown): 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 === undefined || filePath === '') {
return true;
}
if (!extensions.some((ext) => filePath.endsWith(ext))) {
return true;
}
return false;
}
}