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}