auto-update-daily-20260202
1/**
2 * Pi Extension: Claude Code Hooks Wrapper
3 *
4 * Wraps the Go-based Claude Code hook binaries (claude-hooks-*) to provide
5 * consistent hook behavior across AI coding agents.
6 *
7 * Events mapped:
8 * - session_start -> claude-hooks-initialize-session
9 * - session_shutdown -> claude-hooks-save-session
10 * - tool_call -> claude-hooks-validate-git-push (PreToolUse equivalent)
11 * - tool_result -> claude-hooks-capture-tool-output, claude-hooks-update-terminal-title
12 *
13 * Requirements:
14 * - claude-hooks-* binaries must be in PATH (installed via home-manager)
15 */
16
17import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
18import { execSync, spawn } from "node:child_process";
19
20// Track if session has been initialized
21let sessionInitialized = false;
22
23// Cache binary existence checks
24const binaryCache = new Map<string, boolean>();
25
26// Convert Pi event format to Claude Code JSON format
27function toClaudePreToolUse(event: { toolName: string; input: any }): string {
28 return JSON.stringify({
29 tool_name: capitalize(event.toolName),
30 tool_input: event.input || {},
31 conversation_id: "pi-session",
32 });
33}
34
35function toClaudePostToolUse(event: {
36 toolName: string;
37 input: any;
38 content?: any[];
39 isError?: boolean;
40}): string {
41 return JSON.stringify({
42 tool_name: capitalize(event.toolName),
43 tool_input: event.input || {},
44 tool_response: {
45 content: event.content || [],
46 is_error: event.isError || false,
47 },
48 conversation_id: "pi-session",
49 });
50}
51
52function capitalize(s: string): string {
53 return s.charAt(0).toUpperCase() + s.slice(1);
54}
55
56// Run a Claude hook binary with JSON input
57async function runHook(
58 binary: string,
59 jsonInput?: string
60): Promise<{ exitCode: number; stdout: string; stderr: string }> {
61 return new Promise((resolve) => {
62 const proc = spawn(binary, [], {
63 shell: true,
64 stdio: ["pipe", "pipe", "pipe"],
65 });
66
67 let stdout = "";
68 let stderr = "";
69
70 proc.stdout.on("data", (data) => (stdout += data.toString()));
71 proc.stderr.on("data", (data) => (stderr += data.toString()));
72
73 if (jsonInput) {
74 proc.stdin.write(jsonInput);
75 proc.stdin.end();
76 } else {
77 proc.stdin.end();
78 }
79
80 proc.on("close", (code) => {
81 resolve({ exitCode: code ?? 0, stdout, stderr });
82 });
83
84 proc.on("error", () => {
85 resolve({ exitCode: 1, stdout: "", stderr: `Failed to spawn ${binary}` });
86 });
87 });
88}
89
90// Check if a binary exists in PATH (cached)
91function binaryExists(name: string): boolean {
92 if (binaryCache.has(name)) {
93 return binaryCache.get(name)!;
94 }
95 try {
96 execSync(`which ${name}`, { stdio: "ignore" });
97 binaryCache.set(name, true);
98 return true;
99 } catch {
100 binaryCache.set(name, false);
101 return false;
102 }
103}
104
105export default function (pi: ExtensionAPI) {
106 // Session initialization
107 pi.on("session_start", async (_event, ctx) => {
108 if (sessionInitialized) return;
109 sessionInitialized = true;
110
111 if (binaryExists("claude-hooks-initialize-session")) {
112 const result = await runHook("claude-hooks-initialize-session");
113 if (result.stderr && result.exitCode === 0) {
114 ctx.ui.notify(result.stderr.trim(), "info");
115 }
116 }
117 });
118
119 // Session shutdown
120 pi.on("session_shutdown", async (_event, _ctx) => {
121 if (binaryExists("claude-hooks-save-session")) {
122 await runHook("claude-hooks-save-session");
123 }
124 sessionInitialized = false;
125 });
126
127 // Pre-tool-use validation (can block)
128 pi.on("tool_call", async (event, _ctx) => {
129 // Run validate-git-push for bash commands
130 if (
131 event.toolName.toLowerCase() === "bash" &&
132 binaryExists("claude-hooks-validate-git-push")
133 ) {
134 const jsonInput = toClaudePreToolUse(event);
135 const result = await runHook("claude-hooks-validate-git-push", jsonInput);
136
137 if (result.exitCode !== 0) {
138 return {
139 block: true,
140 reason: result.stderr.trim() || "Command blocked by validate-git-push hook",
141 };
142 }
143 }
144
145 return undefined;
146 });
147
148 // Post-tool-use capture
149 pi.on("tool_result", async (event, _ctx) => {
150 const jsonInput = toClaudePostToolUse(event);
151
152 // Run hooks in parallel
153 const hooks = [
154 "claude-hooks-capture-tool-output",
155 "claude-hooks-update-terminal-title",
156 ].filter(binaryExists);
157
158 await Promise.allSettled(hooks.map((hook) => runHook(hook, jsonInput)));
159 });
160}