import { existsSync, readFileSync, statSync } from "node:fs"; import { spawn } from "node:child_process"; import { basename, dirname, join, resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@mariozechner/pi-coding-agent"; const HOOK_TIMEOUT_MS = 10 * 60 * 1000; type HookEventName = "PostToolUse" | "PostToolUseFailure"; type ResolvedCommandHook = { eventName: HookEventName; matcher?: RegExp; matcherText?: string; command: string; source: string; }; type HookState = { projectDir: string; hooks: ResolvedCommandHook[]; }; type CommandRunResult = { code: number; stdout: string; stderr: string; elapsedMs: number; timedOut: boolean; }; function isFile(path: string): boolean { try { return statSync(path).isFile(); } catch { return false; } } function asRecord(value: unknown): Record | undefined { if (typeof value !== "object" || value === null) { return undefined; } return value as Record; } function walkUpDirectories(startDir: string, stopDir?: string): string[] { const directories: string[] = []; const hasStopDir = stopDir !== undefined; let current = resolve(startDir); let parent = dirname(current); let reachedStopDir = hasStopDir && current === stopDir; let reachedFilesystemRoot = parent === current; directories.push(current); while (!reachedStopDir && !reachedFilesystemRoot) { current = parent; parent = dirname(current); reachedStopDir = hasStopDir && current === stopDir; reachedFilesystemRoot = parent === current; directories.push(current); } return directories; } function findNearestGitRoot(startDir: string): string | undefined { for (const directory of walkUpDirectories(startDir)) { if (existsSync(join(directory, ".git"))) { return directory; } } return undefined; } function hasHooksConfig(directory: string): boolean { const claudeSettingsPath = join(directory, ".claude", "settings.json"); const ruleSyncHooksPath = join(directory, ".rulesync", "hooks.json"); const piHooksPath = join(directory, ".pi", "hooks.json"); return isFile(claudeSettingsPath) || isFile(ruleSyncHooksPath) || isFile(piHooksPath); } function findProjectDir(cwd: string): string { const gitRoot = findNearestGitRoot(cwd); for (const directory of walkUpDirectories(cwd, gitRoot)) { if (hasHooksConfig(directory)) { return directory; } } return gitRoot ?? resolve(cwd); } function readJsonFile(path: string): unknown | undefined { if (!isFile(path)) { return undefined; } try { return JSON.parse(readFileSync(path, "utf8")) as unknown; } catch { return undefined; } } function resolveHookCommand(command: string, projectDir: string): string { return command.replace(/\$CLAUDE_PROJECT_DIR\b/g, projectDir); } function compileMatcher(matcherText: string | undefined): RegExp | undefined { if (matcherText === undefined) { return undefined; } try { return new RegExp(matcherText); } catch { return undefined; } } function createHook( eventName: HookEventName, matcherText: string | undefined, command: string, source: string, projectDir: string, ): ResolvedCommandHook | undefined { const matcher = compileMatcher(matcherText); if (matcherText !== undefined && matcher === undefined) { return undefined; } return { eventName, matcher, matcherText, command: resolveHookCommand(command, projectDir), source, }; } function getHookEntries( hooksRecord: Record, eventName: HookEventName, ): unknown[] { const keys = eventName === "PostToolUse" ? ["PostToolUse", "postToolUse"] : ["PostToolUseFailure", "postToolUseFailure"]; for (const key of keys) { const value = hooksRecord[key]; if (Array.isArray(value)) { return value; } } return []; } function parseClaudeSettingsHooks( config: unknown, source: string, projectDir: string, ): ResolvedCommandHook[] { const root = asRecord(config); const hooksRoot = root ? asRecord(root.hooks) : undefined; if (!hooksRoot) { return []; } const hooks: ResolvedCommandHook[] = []; const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"]; for (const eventName of events) { const entries = getHookEntries(hooksRoot, eventName); for (const entry of entries) { const entryRecord = asRecord(entry); if (!entryRecord || !Array.isArray(entryRecord.hooks)) { continue; } const matcherText = typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined; for (const nestedHook of entryRecord.hooks) { const nestedHookRecord = asRecord(nestedHook); if (!nestedHookRecord) { continue; } if (nestedHookRecord.type !== "command") { continue; } if (typeof nestedHookRecord.command !== "string") { continue; } const hook = createHook( eventName, matcherText, nestedHookRecord.command, source, projectDir, ); if (hook) { hooks.push(hook); } } } } return hooks; } function parseSimpleHooksFile( config: unknown, source: string, projectDir: string, ): ResolvedCommandHook[] { const root = asRecord(config); const hooksRoot = root ? asRecord(root.hooks) : undefined; if (!hooksRoot) { return []; } const hooks: ResolvedCommandHook[] = []; const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"]; for (const eventName of events) { const entries = getHookEntries(hooksRoot, eventName); for (const entry of entries) { const entryRecord = asRecord(entry); if (!entryRecord || typeof entryRecord.command !== "string") { continue; } const matcherText = typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined; const hook = createHook( eventName, matcherText, entryRecord.command, source, projectDir, ); if (hook) { hooks.push(hook); } } } return hooks; } function loadHooks(cwd: string): HookState { const projectDir = findProjectDir(cwd); const claudeSettingsPath = join(projectDir, ".claude", "settings.json"); const ruleSyncHooksPath = join(projectDir, ".rulesync", "hooks.json"); const piHooksPath = join(projectDir, ".pi", "hooks.json"); const hooks: ResolvedCommandHook[] = []; const claudeSettings = readJsonFile(claudeSettingsPath); if (claudeSettings !== undefined) { hooks.push(...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir)); } const ruleSyncHooks = readJsonFile(ruleSyncHooksPath); if (ruleSyncHooks !== undefined) { hooks.push(...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir)); } const piHooks = readJsonFile(piHooksPath); if (piHooks !== undefined) { hooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir)); } return { projectDir, hooks, }; } function toClaudeToolName(toolName: string): string { if (toolName === "ls") { return "LS"; } if (toolName.length === 0) { return toolName; } return toolName[0].toUpperCase() + toolName.slice(1); } function matchesHook(hook: ResolvedCommandHook, toolName: string): boolean { if (!hook.matcher) { return true; } const claudeToolName = toClaudeToolName(toolName); hook.matcher.lastIndex = 0; if (hook.matcher.test(toolName)) { return true; } hook.matcher.lastIndex = 0; return hook.matcher.test(claudeToolName); } function extractTextContent(content: unknown): string { if (!Array.isArray(content)) { return ""; } const parts: string[] = []; for (const item of content) { if (!item || typeof item !== "object") { continue; } const itemRecord = item as Record; if (itemRecord.type === "text" && typeof itemRecord.text === "string") { parts.push(itemRecord.text); } } return parts.join("\n"); } function normalizeToolInput(input: Record): Record { const normalized: Record = { ...input }; const pathValue = typeof input.path === "string" ? input.path : undefined; if (pathValue !== undefined) { normalized.file_path = pathValue; normalized.filePath = pathValue; } return normalized; } function buildToolResponse( event: ToolResultEvent, normalizedInput: Record, ): Record { const response: Record = { is_error: event.isError, isError: event.isError, content: event.content, text: extractTextContent(event.content), details: event.details ?? null, }; const filePath = typeof normalizedInput.file_path === "string" ? normalizedInput.file_path : undefined; if (filePath !== undefined) { response.file_path = filePath; response.filePath = filePath; } return response; } function buildHookPayload( event: ToolResultEvent, eventName: HookEventName, ctx: ExtensionContext, projectDir: string, ): Record { const normalizedInput = normalizeToolInput(event.input); const sessionId = ctx.sessionManager.getSessionFile() ?? "ephemeral"; return { session_id: sessionId, cwd: ctx.cwd, claude_project_dir: projectDir, hook_event_name: eventName, tool_name: toClaudeToolName(event.toolName), tool_call_id: event.toolCallId, tool_input: normalizedInput, tool_response: buildToolResponse(event, normalizedInput), }; } function runCommandHook( command: string, cwd: string, payload: Record, ): Promise { return new Promise((resolve) => { const startedAt = Date.now(); const child = spawn("bash", ["-lc", command], { cwd, env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let timedOut = false; let resolved = false; const finish = (code: number) => { if (resolved) { return; } resolved = true; resolve({ code, stdout, stderr, elapsedMs: Date.now() - startedAt, timedOut, }); }; const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); const killTimer = setTimeout(() => { child.kill("SIGKILL"); }, 1000); (killTimer as NodeJS.Timeout & { unref?: () => void }).unref?.(); }, HOOK_TIMEOUT_MS); (timeout as NodeJS.Timeout & { unref?: () => void }).unref?.(); child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); child.on("error", (error) => { clearTimeout(timeout); stderr += `${error.message}\n`; finish(-1); }); child.on("close", (code) => { clearTimeout(timeout); finish(code ?? -1); }); try { child.stdin.write(JSON.stringify(payload)); child.stdin.end(); } catch (error) { const message = error instanceof Error ? error.message : String(error); stderr += `${message}\n`; } }); } function hookEventNameForResult(event: ToolResultEvent): HookEventName { return event.isError ? "PostToolUseFailure" : "PostToolUse"; } function formatDuration(elapsedMs: number): string { if (elapsedMs < 1000) { return `${elapsedMs}ms`; } return `${(elapsedMs / 1000).toFixed(1)}s`; } function hookName(command: string): string { const shPathMatch = command.match(/[^\s|;&]+\.sh\b/); if (shPathMatch) { return basename(shPathMatch[0]); } const firstToken = command.trim().split(/\s+/)[0] ?? "hook"; return basename(firstToken); } export default function(pi: ExtensionAPI) { let state: HookState = { projectDir: process.cwd(), hooks: [], }; const refreshHooks = (cwd: string) => { state = loadHooks(cwd); }; pi.on("session_start", (_event, ctx) => { refreshHooks(ctx.cwd); }); pi.on("session_switch", (_event, ctx) => { refreshHooks(ctx.cwd); }); pi.on("tool_result", async (event, ctx) => { if (state.hooks.length === 0) { return; } const eventName = hookEventNameForResult(event); const matchingHooks = state.hooks.filter( (hook) => hook.eventName === eventName && matchesHook(hook, event.toolName), ); if (matchingHooks.length === 0) { return; } const payload = buildHookPayload(event, eventName, ctx, state.projectDir); const executedCommands = new Set(); for (const hook of matchingHooks) { if (executedCommands.has(hook.command)) { continue; } executedCommands.add(hook.command); const result = await runCommandHook(hook.command, state.projectDir, payload); const name = hookName(hook.command); const duration = formatDuration(result.elapsedMs); if (result.code === 0) { ctx.ui.notify(`󰛢 Hook \`${name}\` executed, took ${duration}`, "info"); continue; } const matcherLabel = hook.matcherText ?? "*"; const errorLine = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`; ctx.ui.notify( `󰛢 Hook \`${name}\` failed after ${duration} (${matcherLabel}) from ${hook.source}: ${errorLine}`, "warning", ); } }); }