import { spawn } from "node:child_process"; import { getHomeDirectory } from "./home-directory"; import { findBashPath, findZshPath } from "./shell-path"; export interface CommandResult { exitCode: number; stdout?: string; stderr?: string; } const DEFAULT_HOOK_TIMEOUT_MS = 30_000; const SIGKILL_GRACE_MS = 5_000; export interface ExecuteHookOptions { forceZsh?: boolean; zshPath?: string; /** Timeout in milliseconds. Process is killed after this. Default: 30000 */ timeoutMs?: number; } export async function executeHookCommand( command: string, stdin: string, cwd: string, options?: ExecuteHookOptions, ): Promise { const home = getHomeDirectory(); const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS; const expandedCommand = command .replace(/^~(?=\/|$)/g, home) .replace(/\s~(?=\/)/g, ` ${home}`) .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd); let finalCommand = expandedCommand; if (options?.forceZsh) { const zshPath = findZshPath(options.zshPath); const escapedCommand = expandedCommand.replace(/'/g, "'\\''"); if (zshPath) { finalCommand = `${zshPath} -lc '${escapedCommand}'`; } else { const bashPath = findBashPath(); if (bashPath) { finalCommand = `${bashPath} -lc '${escapedCommand}'`; } } } return new Promise(resolve => { let settled = false; let killTimer: ReturnType | null = null; const isWin32 = process.platform === "win32"; const proc = spawn(finalCommand, { cwd, shell: true, detached: !isWin32, env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, }); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data: Buffer) => { stdout += data.toString(); }); proc.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); }); proc.stdin?.on("error", () => {}); proc.stdin?.write(stdin); proc.stdin?.end(); const settle = (result: CommandResult) => { if (settled) return; settled = true; if (killTimer) clearTimeout(killTimer); if (timeoutTimer) clearTimeout(timeoutTimer); resolve(result); }; proc.on("close", code => { settle({ exitCode: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim(), }); }); proc.on("error", err => { settle({ exitCode: 1, stderr: err.message }); }); const killProcessGroup = (signal: NodeJS.Signals) => { try { if (!isWin32 && proc.pid) { process.kill(-proc.pid, signal); } else { proc.kill(signal); } } catch {} }; const timeoutTimer = setTimeout(() => { if (settled) return; // Kill entire process group to avoid orphaned children killProcessGroup("SIGTERM"); killTimer = setTimeout(() => { if (settled) return; killProcessGroup("SIGKILL"); }, SIGKILL_GRACE_MS); // Append timeout notice to stderr stderr += `\nHook command timed out after ${timeoutMs}ms`; }, timeoutMs); // Don't let the timeout timer keep the process alive if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) { timeoutTimer.unref(); } }); }