import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; type ResolvedPostToolUseHook = { matcher?: RegExp; matcherText?: string; command: string; source: string; }; type HookState = { projectDir: string; postToolUseHooks: ResolvedPostToolUseHook[]; }; 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( matcherText: string | undefined, command: string, source: string, projectDir: string, ): ResolvedPostToolUseHook | undefined { const matcher = compileMatcher(matcherText); if (matcherText !== undefined && matcher === undefined) { return undefined; } return { matcher, matcherText, command: resolveHookCommand(command, projectDir), source, }; } function getPostToolUseEntries(hooksRecord: Record): unknown[] { if (Array.isArray(hooksRecord.PostToolUse)) { return hooksRecord.PostToolUse; } if (Array.isArray(hooksRecord.postToolUse)) { return hooksRecord.postToolUse; } return []; } function parseClaudeSettingsHooks( config: unknown, source: string, projectDir: string, ): ResolvedPostToolUseHook[] { const root = asRecord(config); const hooksRoot = root ? asRecord(root.hooks) : undefined; if (!hooksRoot) { return []; } const entries = getPostToolUseEntries(hooksRoot); const hooks: ResolvedPostToolUseHook[] = []; 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(matcherText, nestedHookRecord.command, source, projectDir); if (hook) { hooks.push(hook); } } } return hooks; } function parseSimpleHooksFile( config: unknown, source: string, projectDir: string, ): ResolvedPostToolUseHook[] { const root = asRecord(config); const hooksRoot = root ? asRecord(root.hooks) : undefined; if (!hooksRoot) { return []; } const entries = getPostToolUseEntries(hooksRoot); const hooks: ResolvedPostToolUseHook[] = []; 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(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 postToolUseHooks: ResolvedPostToolUseHook[] = []; const claudeSettings = readJsonFile(claudeSettingsPath); if (claudeSettings !== undefined) { postToolUseHooks.push( ...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir), ); } const ruleSyncHooks = readJsonFile(ruleSyncHooksPath); if (ruleSyncHooks !== undefined) { postToolUseHooks.push( ...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir), ); } const piHooks = readJsonFile(piHooksPath); if (piHooks !== undefined) { postToolUseHooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir)); } return { projectDir, postToolUseHooks, }; } function toClaudeToolName(toolName: string): string { if (toolName.length === 0) { return toolName; } return toolName[0].toUpperCase() + toolName.slice(1); } function matchesHook(hook: ResolvedPostToolUseHook, 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); } export default function(pi: ExtensionAPI) { let state: HookState = { projectDir: process.cwd(), postToolUseHooks: [], }; 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_execution_end", async (event, ctx) => { if (state.postToolUseHooks.length === 0) { return; } for (const hook of state.postToolUseHooks) { if (!matchesHook(hook, event.toolName)) { continue; } const result = await pi.exec("bash", ["-lc", hook.command], { cwd: state.projectDir, }); if (result.code !== 0) { const matcherLabel = hook.matcherText ?? "*"; const errorLine = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`; ctx.ui.notify( `Hook failed (${matcherLabel}) from ${hook.source}: ${errorLine}`, "warning", ); } } }); }