/** * Hook Command - CLI endpoint for Claude Code hooks * Provides simplified interface for hook operations, replacing complex bash/curl commands */ import chalk from 'chalk'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; interface HookOptions { stdin?: boolean; sessionId?: string; prompt?: string; type?: 'session-start' | 'context'; } interface HookData { session_id?: string; prompt?: string; cwd?: string; tool_input?: Record; } interface SessionState { firstLoad: string; loadCount: number; lastPrompt?: string; } /** * Read JSON data from stdin (for Claude Code hooks) */ async function readStdin(): Promise { return new Promise((resolve) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('readable', () => { let chunk; while ((chunk = process.stdin.read()) !== null) { data += chunk; } }); process.stdin.on('end', () => { resolve(data); }); // Handle case where stdin is empty or not piped if (process.stdin.isTTY) { resolve(''); } }); } /** * Get session state file path * Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions */ function getSessionStateFile(sessionId: string): string { const stateDir = join(homedir(), '.claude', '.ccw-sessions'); if (!existsSync(stateDir)) { mkdirSync(stateDir, { recursive: true }); } return join(stateDir, `session-${sessionId}.json`); } /** * Load session state from file */ function loadSessionState(sessionId: string): SessionState | null { const stateFile = getSessionStateFile(sessionId); if (!existsSync(stateFile)) { return null; } try { const content = readFileSync(stateFile, 'utf-8'); return JSON.parse(content) as SessionState; } catch { return null; } } /** * Save session state to file */ function saveSessionState(sessionId: string, state: SessionState): void { const stateFile = getSessionStateFile(sessionId); writeFileSync(stateFile, JSON.stringify(state, null, 2)); } /** * Get project path from hook data or current working directory */ function getProjectPath(hookCwd?: string): string { return hookCwd || process.cwd(); } /** * Session context action - provides progressive context loading * First prompt: returns session overview with clusters * Subsequent prompts: returns intent-matched sessions */ async function sessionContextAction(options: HookOptions): Promise { let { stdin, sessionId, prompt } = options; let hookCwd: string | undefined; // If --stdin flag is set, read from stdin (Claude Code hook format) if (stdin) { try { const stdinData = await readStdin(); if (stdinData) { const hookData = JSON.parse(stdinData) as HookData; sessionId = hookData.session_id || sessionId; hookCwd = hookData.cwd; prompt = hookData.prompt || prompt; } } catch { // Silently continue if stdin parsing fails } } if (!sessionId) { if (!stdin) { console.error(chalk.red('Error: --session-id is required')); console.error(chalk.gray('Usage: ccw hook session-context --session-id ')); console.error(chalk.gray(' ccw hook session-context --stdin')); } process.exit(stdin ? 0 : 1); } try { const projectPath = getProjectPath(hookCwd); // Load existing session state const existingState = loadSessionState(sessionId); const isFirstPrompt = !existingState; // Update session state const newState: SessionState = isFirstPrompt ? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt } : { ...existingState, loadCount: existingState.loadCount + 1, lastPrompt: prompt }; saveSessionState(sessionId, newState); // Determine context type and generate content let contextType: 'session-start' | 'context'; let content = ''; // Dynamic import to avoid circular dependencies const { SessionClusteringService } = await import('../core/session-clustering-service.js'); const clusteringService = new SessionClusteringService(projectPath); if (isFirstPrompt) { // First prompt: return session overview with clusters contextType = 'session-start'; content = await clusteringService.getProgressiveIndex({ type: 'session-start', sessionId }); } else if (prompt && prompt.trim().length > 0) { // Subsequent prompts with content: return intent-matched sessions contextType = 'context'; content = await clusteringService.getProgressiveIndex({ type: 'context', sessionId, prompt }); } else { // Subsequent prompts without content: return minimal context contextType = 'context'; content = ''; // No context needed for empty prompts } if (stdin) { // For hooks: output content directly to stdout if (content) { process.stdout.write(content); } process.exit(0); } // Interactive mode: show detailed output console.log(chalk.green('Session Context')); console.log(chalk.gray('─'.repeat(40))); console.log(chalk.cyan('Session ID:'), sessionId); console.log(chalk.cyan('Type:'), contextType); console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No'); console.log(chalk.cyan('Load Count:'), newState.loadCount); console.log(chalk.gray('─'.repeat(40))); if (content) { console.log(content); } else { console.log(chalk.gray('(No context generated)')); } } catch (error) { if (stdin) { // Silent failure for hooks process.exit(0); } console.error(chalk.red(`Error: ${(error as Error).message}`)); process.exit(1); } } /** * Notify dashboard action - send notification to running ccw view server */ async function notifyAction(options: HookOptions): Promise { const { stdin } = options; let hookData: HookData = {}; if (stdin) { try { const stdinData = await readStdin(); if (stdinData) { hookData = JSON.parse(stdinData) as HookData; } } catch { // Silently continue if stdin parsing fails } } try { const { notifyRefreshRequired } = await import('../tools/notifier.js'); await notifyRefreshRequired(); if (!stdin) { console.log(chalk.green('Notification sent to dashboard')); } process.exit(0); } catch (error) { if (stdin) { process.exit(0); } console.error(chalk.red(`Error: ${(error as Error).message}`)); process.exit(1); } } /** * Show help for hook command */ function showHelp(): void { console.log(` ${chalk.bold('ccw hook')} - CLI endpoint for Claude Code hooks ${chalk.bold('USAGE')} ccw hook [options] ${chalk.bold('SUBCOMMANDS')} session-context Progressive session context loading (replaces curl/bash hook) notify Send notification to ccw view dashboard ${chalk.bold('OPTIONS')} --stdin Read input from stdin (for Claude Code hooks) --session-id Session ID (alternative to stdin) --prompt Current prompt text (alternative to stdin) ${chalk.bold('EXAMPLES')} ${chalk.gray('# Use in Claude Code hook (settings.json):')} ccw hook session-context --stdin ${chalk.gray('# Interactive usage:')} ccw hook session-context --session-id abc123 ${chalk.gray('# Notify dashboard:')} ccw hook notify --stdin ${chalk.bold('HOOK CONFIGURATION')} ${chalk.gray('Add to .claude/settings.json:')} { "hooks": { "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "ccw hook session-context --stdin" }] }] } } `); } /** * Main hook command handler */ export async function hookCommand( subcommand: string, args: string | string[], options: HookOptions ): Promise { switch (subcommand) { case 'session-context': case 'context': await sessionContextAction(options); break; case 'notify': await notifyAction(options); break; case 'help': case undefined: showHelp(); break; default: console.error(chalk.red(`Unknown subcommand: ${subcommand}`)); console.error(chalk.gray('Run "ccw hook help" for usage information')); process.exit(1); } }