claudekit
Version:
CLI tools for Claude Code development workflow
562 lines (495 loc) âĸ 20.7 kB
text/typescript
/**
* ClaudeKit Hooks CLI
* Command-line interface for managing and executing Claude Code hooks
*/
import { Command } from 'commander';
import { setImmediate } from 'node:timers';
import * as path from 'node:path';
import { HookRunner } from './hooks/runner.js';
import { profileHooks } from './hooks/profile.js';
import { SessionHookManager } from './hooks/session-utils.js';
import { loadConfig, configExists, loadUserConfig } from './utils/config.js';
import type { Config } from './types/config.js';
// Helper types for fuzzy matching
interface MatchResult {
type: 'exact' | 'multiple' | 'none' | 'not-configured';
hook?: string;
hooks?: string[];
suggestions?: string[];
}
// Helper function to resolve hook names with fuzzy matching
async function resolveHookName(input: string, projectHooks: string[]): Promise<MatchResult> {
// 1. Exact match in configured hooks
if (projectHooks.includes(input)) {
return { type: 'exact', hook: input };
}
// 2. Partial match in configured hooks
const partial = projectHooks.filter(name =>
name.includes(input) || name.startsWith(input)
);
if (partial.length === 1 && partial[0] !== undefined) {
return { type: 'exact', hook: partial[0] };
}
if (partial.length > 1) {
return { type: 'multiple', hooks: partial };
}
// 3. Check if hook exists in registry but not configured
const { HOOK_REGISTRY } = await import('./hooks/registry.js');
const registryHooks = Object.keys(HOOK_REGISTRY);
if (registryHooks.includes(input)) {
return { type: 'not-configured', hook: input };
}
const registryPartial = registryHooks.filter(name =>
name.includes(input) || name.startsWith(input)
);
if (registryPartial.length === 1 && registryPartial[0] !== undefined) {
return { type: 'not-configured', hook: registryPartial[0] };
}
// 4. No match anywhere
return { type: 'none', suggestions: projectHooks };
}
// Helper function to get project-configured hooks
async function getProjectHooks(): Promise<string[]> {
const hooks = new Set<string>();
// Helper function to extract hooks from a config with type safety
const extractHooksFromConfig = (config: Config): void => {
for (const [, matchers] of Object.entries(config.hooks)) {
if (!Array.isArray(matchers)) {
continue; // Skip non-array values
}
for (const matcher of matchers) {
if (!Array.isArray(matcher.hooks)) {
continue; // Skip invalid matcher structure
}
for (const hook of matcher.hooks) {
if (!hook?.command || typeof hook.command !== 'string') {
continue; // Skip invalid hook structure
}
// Extract hook name from command like "claudekit-hooks run typecheck-changed"
const match = hook.command.match(/claudekit-hooks\s+run\s+([^\s]+)/);
if (match !== null && match[1] !== undefined && match[1] !== '') {
hooks.add(match[1]);
}
}
}
}
};
try {
// Load project-level hooks
const projectRoot = process.cwd();
if (await configExists(projectRoot)) {
const projectConfig = await loadConfig(projectRoot);
extractHooksFromConfig(projectConfig);
}
// Load user-level hooks
const userConfig = await loadUserConfig();
extractHooksFromConfig(userConfig);
} catch (error) {
// Log the error for debugging but continue with whatever hooks we found
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error('[DEBUG] Failed to load hooks configuration:', error);
}
// Return what we have so far instead of an empty array
}
// Return sorted array of unique hooks from both project and user configurations
return Array.from(hooks).sort();
}
// Helper function to discover transcript files in a directory and return most recent UUID
async function discoverTranscriptUuid(transcriptDir: string): Promise<string | null> {
try {
const fs = await import('node:fs/promises');
const files = await fs.readdir(transcriptDir);
const transcriptFiles = files.filter(f => f.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl?$/i));
if (transcriptFiles.length > 0) {
// Get the most recent transcript file
const stats = await Promise.all(
transcriptFiles.map(async file => ({
file,
stat: await fs.stat(path.join(transcriptDir, file))
}))
);
stats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime());
const sessionManager = new SessionHookManager();
const firstFile = stats[0];
return firstFile ? sessionManager.extractTranscriptUuid(firstFile.file) : null;
}
} catch (error) {
// Debug: Log errors during development
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`Debug: discoverTranscriptUuid failed for ${transcriptDir}:`, error);
}
}
return null;
}
// Helper function to generate session marker hash and extract transcript UUID
async function getSessionIdentifier(): Promise<string | null> {
// Generate marker hash for session connection (as per spec)
const { exec } = await import('node:child_process');
const { promisify } = await import('node:util');
const execAsync = promisify(exec);
try {
// Generate POSIX-compatible hash using /dev/urandom, od, and tr
const { stdout: hash } = await execAsync("head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \\n'");
const sessionHash = hash.trim();
// Inject hash as hidden comment to connect transcript with session
console.log(`<!-- claudekit-session-marker:${sessionHash} -->`);
// Try to get transcript UUID from Claude Code environment
const transcriptPath = process.env['CLAUDE_TRANSCRIPT_PATH'];
if (transcriptPath !== undefined && transcriptPath !== '') {
const sessionManager = new SessionHookManager();
return sessionManager.extractTranscriptUuid(transcriptPath);
}
// Try to extract UUID from ITERM_SESSION_ID (common in Claude Code sessions)
const itermSessionId = process.env['ITERM_SESSION_ID'];
if (itermSessionId !== undefined && itermSessionId !== '') {
const uuidMatch = itermSessionId.match(/([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})/i);
if (uuidMatch !== null && uuidMatch[1] !== undefined) {
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`Debug: Using UUID from ITERM_SESSION_ID: ${uuidMatch[1]}`);
}
return uuidMatch[1].toLowerCase();
}
}
// Try current directory's .claude/transcripts
const projectTranscriptDir = path.join(process.cwd(), '.claude', 'transcripts');
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`Debug: Trying project transcripts at ${projectTranscriptDir}`);
}
const projectUuid = await discoverTranscriptUuid(projectTranscriptDir);
if (projectUuid !== null) {
return projectUuid;
}
// Try user's home directory ~/.claude/transcripts
const os = await import('node:os');
const homeTranscriptDir = path.join(os.homedir(), '.claude', 'transcripts');
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`Debug: Trying home transcripts at ${homeTranscriptDir}`);
}
const homeUuid = await discoverTranscriptUuid(homeTranscriptDir);
if (homeUuid !== null) {
return homeUuid;
}
// Fallback: If we're in Claude Code but can't find transcript files,
// use the session marker hash as session ID (for testing/development)
if (process.env['CLAUDECODE'] === '1') {
if (process.env['CLAUDEKIT_DEBUG'] === 'true') {
console.error(`Debug: Using session marker hash as fallback session ID: ${sessionHash}`);
}
return sessionHash;
}
return null;
} catch (error) {
// Fallback if hash generation fails
console.error('Warning: Could not generate session marker hash:', error);
return null;
}
}
// Helper function to require valid session identifier
async function requireSessionIdentifier(): Promise<string> {
const sessionId = await getSessionIdentifier();
if (sessionId === null) {
console.error('â Cannot determine current Claude Code session.');
console.error('This command must be run from within an active Claude Code session.');
process.exit(1);
}
return sessionId;
}
// Helper function to format hook status consistently
function formatHookStatus(hook: string, isDisabled: boolean): string {
const emoji = isDisabled ? 'đ' : 'â
';
return ` ${emoji} ${hook}`;
}
// Helper function to format detailed hook status (for status command)
function formatDetailedHookStatus(hook: string, isDisabled: boolean): string {
const status = isDisabled ? 'đ disabled' : 'â
enabled';
return ` ${hook}: ${status}`;
}
// Helper function to handle match results with command-specific actions
async function handleMatchResult(
matchResult: MatchResult,
hookName: string,
sessionManager: SessionHookManager,
sessionId: string,
actions: {
onExactMatch: (hook: string, isDisabled: boolean) => Promise<void>;
onMultipleMatches: (hooks: string[]) => Promise<void>;
onNotConfigured: (hook: string) => void;
onNotFound: (projectHooks: string[]) => void;
}
): Promise<void> {
switch (matchResult.type) {
case 'exact':
if (matchResult.hook !== undefined) {
const isDisabled = await sessionManager.isHookDisabled(sessionId, matchResult.hook);
await actions.onExactMatch(matchResult.hook, isDisabled);
}
break;
case 'multiple':
console.log(`đ¤ Multiple hooks match '${hookName}':`);
if (matchResult.hooks !== undefined) {
await actions.onMultipleMatches(matchResult.hooks);
}
break;
case 'not-configured':
if (matchResult.hook !== undefined) {
actions.onNotConfigured(matchResult.hook);
}
break;
case 'none': {
console.log(`â No hook found matching '${hookName}'`);
const projectHooks = matchResult.suggestions ?? [];
actions.onNotFound(projectHooks);
break;
}
}
}
export function createHooksCLI(): Command {
const program = new Command('claudekit-hooks')
.description('Claude Code hooks execution system')
.version('1.0.0')
.option('--config <path>', 'Path to config file', '.claudekit/config.json')
.option('--list', 'List available hooks')
.option('--debug', 'Enable debug logging');
// Add list command
program
.command('list')
.description('List available hooks')
.action(async () => {
const { HOOK_REGISTRY } = await import('./hooks/registry.js');
console.log('Available hooks:');
for (const [id, HookClass] of Object.entries(HOOK_REGISTRY)) {
const description = HookClass.metadata?.description ?? `${id} hook`;
const padding = ' '.repeat(Math.max(0, 30 - id.length));
console.log(` ${id}${padding}- ${description}`);
}
});
// Add stats command
program
.command('stats')
.description('Show hook execution statistics')
.action(async () => {
const { printHookReport } = await import('./hooks/logging.js');
await printHookReport();
});
// Add recent command
program
.command('recent [limit]')
.description('Show recent hook executions')
.action(async (limit?: string) => {
const { getRecentExecutions } = await import('./hooks/logging.js');
const executions = await getRecentExecutions(limit !== undefined ? parseInt(limit) : 20);
if (executions.length === 0) {
console.log('No recent hook executions found.');
return;
}
console.log('\n=== Recent Hook Executions ===\n');
for (const exec of executions) {
const status = exec.exitCode === 0 ? 'â' : 'â';
const time = new Date(exec.timestamp).toLocaleString();
console.log(`${status} ${exec.hookName} - ${time} (${exec.executionTime}ms)`);
}
});
// Add profile command
program
.command('profile [hook]')
.description('Profile hook performance (time and output)')
.option('-i, --iterations <n>', 'Number of iterations', '1')
.action(async (hook, options) => {
// Parse iterations to number (CLI provides string)
const iterations = parseInt(options.iterations, 10);
if (isNaN(iterations) || iterations < 1) {
console.error('Error: Iterations must be a positive number');
process.exit(1);
}
await profileHooks(hook, { iterations });
});
// Add disable command
program
.command('disable [hook-name]')
.description('Disable a hook for this session')
.action(async (hookName?: string) => {
const sessionManager = new SessionHookManager();
const sessionId = await requireSessionIdentifier();
const projectHooks = await getProjectHooks();
if (hookName === undefined) {
console.log('Available hooks for this project:');
for (const hook of projectHooks) {
const isDisabled = await sessionManager.isHookDisabled(sessionId, hook);
console.log(formatHookStatus(hook, isDisabled));
}
console.log('\nUsage: claudekit-hooks disable [hook-name]');
return;
}
const matchResult = await resolveHookName(hookName, projectHooks);
await handleMatchResult(matchResult, hookName, sessionManager, sessionId, {
onExactMatch: async (hook: string, isDisabled: boolean) => {
if (isDisabled) {
console.log(`â ī¸ Hook '${hook}' is already disabled for this session`);
} else {
await sessionManager.disableHook(sessionId, hook);
console.log(`đ Disabled ${hook} for this session`);
}
},
onMultipleMatches: async (hooks: string[]) => {
for (const hook of hooks) {
console.log(` ${hook}`);
}
console.log(`Be more specific: claudekit-hooks disable [exact-name]`);
},
onNotConfigured: (hook: string) => {
console.log(`âĒ Hook '${hook}' is not configured for this project`);
console.log('This hook exists but is not configured in .claude/settings.json');
},
onNotFound: (projectHooks: string[]) => {
if (projectHooks.length === 0) {
console.log('No hooks are configured for this project in .claude/settings.json');
} else {
console.log('Available hooks configured for this project:');
for (const hook of projectHooks) {
console.log(` ${hook}`);
}
console.log('Try: claudekit-hooks disable [exact-name]');
}
}
});
});
// Add enable command
program
.command('enable [hook-name]')
.description('Enable a hook for this session')
.action(async (hookName?: string) => {
const sessionManager = new SessionHookManager();
const sessionId = await requireSessionIdentifier();
const projectHooks = await getProjectHooks();
if (hookName === undefined) {
console.log('Available hooks for this project:');
for (const hook of projectHooks) {
const isDisabled = await sessionManager.isHookDisabled(sessionId, hook);
console.log(formatHookStatus(hook, isDisabled));
}
console.log('\nUsage: claudekit-hooks enable [hook-name]');
return;
}
const matchResult = await resolveHookName(hookName, projectHooks);
await handleMatchResult(matchResult, hookName, sessionManager, sessionId, {
onExactMatch: async (hook: string, isDisabled: boolean) => {
if (!isDisabled) {
console.log(`âšī¸ Hook '${hook}' is not currently disabled for this session`);
} else {
await sessionManager.enableHook(sessionId, hook);
console.log(`â
Re-enabled ${hook} for this session`);
}
},
onMultipleMatches: async (hooks: string[]) => {
for (const hook of hooks) {
console.log(` ${hook}`);
}
console.log(`Be more specific: claudekit-hooks enable [exact-name]`);
},
onNotConfigured: (hook: string) => {
console.log(`âĒ Hook '${hook}' is not configured for this project`);
console.log('This hook exists but is not configured in .claude/settings.json');
},
onNotFound: (projectHooks: string[]) => {
if (projectHooks.length === 0) {
console.log('No hooks are configured for this project in .claude/settings.json');
} else {
console.log('Available hooks configured for this project:');
for (const hook of projectHooks) {
console.log(` ${hook}`);
}
console.log('Try: claudekit-hooks enable [exact-name]');
}
}
});
});
// Add status command
program
.command('status [hook-name]')
.description('Show status of a hook for this session')
.action(async (hookName?: string) => {
const sessionManager = new SessionHookManager();
const sessionId = await requireSessionIdentifier();
const projectHooks = await getProjectHooks();
if (hookName === undefined) {
console.log('Hook status for this project:');
for (const hook of projectHooks) {
const isDisabled = await sessionManager.isHookDisabled(sessionId, hook);
console.log(formatDetailedHookStatus(hook, isDisabled));
}
console.log('\nUsage: claudekit-hooks status [hook-name]');
return;
}
const matchResult = await resolveHookName(hookName, projectHooks);
await handleMatchResult(matchResult, hookName, sessionManager, sessionId, {
onExactMatch: async (hook: string, isDisabled: boolean) => {
console.log(formatDetailedHookStatus(hook, isDisabled).trimStart());
},
onMultipleMatches: async (hooks: string[]) => {
for (const hook of hooks) {
const isDisabled = await sessionManager.isHookDisabled(sessionId, hook);
console.log(formatDetailedHookStatus(hook, isDisabled));
}
},
onNotConfigured: (hook: string) => {
console.log(`${hook}: âĒ not configured`);
console.log('This hook exists but is not configured in .claude/settings.json');
},
onNotFound: (projectHooks: string[]) => {
if (projectHooks.length === 0) {
console.log('No hooks are configured for this project in .claude/settings.json');
} else {
console.log('Available hooks configured for this project:');
for (const hook of projectHooks) {
console.log(` ${hook}`);
}
console.log('Try: claudekit-hooks status [exact-name]');
}
}
});
});
// Add run command (default)
program
.command('run <hook>')
.description('Run a specific hook')
.option('--debug', 'Enable debug logging')
.action(async (hookName: string, options) => {
const globalOpts = program.opts();
const hookRunner = new HookRunner(
globalOpts['config'] as string | undefined,
globalOpts['debug'] === true || options.debug === true
);
const exitCode = await hookRunner.run(hookName);
// Force process exit to ensure clean shutdown
// Use setImmediate to allow any final I/O to complete
setImmediate(() => {
process.exit(exitCode);
});
});
// Handle --list option
program.hook('preAction', async (thisCommand) => {
if (thisCommand.opts()['list'] === true) {
const { HOOK_REGISTRY } = await import('./hooks/registry.js');
console.log('Available hooks:');
for (const [id, HookClass] of Object.entries(HOOK_REGISTRY)) {
const description = HookClass.metadata?.description ?? `${id} hook`;
const padding = ' '.repeat(Math.max(0, 30 - id.length));
console.log(` ${id}${padding}- ${description}`);
}
process.exit(0);
}
});
return program;
}
// Entry point - check if this file is being run directly
// In CommonJS build, import.meta.url is undefined, so we check __filename
let isMainModule = false;
if (typeof import.meta !== 'undefined' && import.meta.url) {
isMainModule = import.meta.url === `file://${process.argv[1]}`;
} else if (typeof __filename !== 'undefined') {
isMainModule = __filename === process.argv[1];
}
if (isMainModule) {
createHooksCLI().parse(process.argv);
}