import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; export interface HookStdinPayload { session_id?: string; hook_event_name?: string; [key: string]: unknown; } export function parseHookStdin(raw: string | null | undefined): HookStdinPayload | null { if (!raw || !raw.trim()) return null; try { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") return parsed as HookStdinPayload; return null; } catch { return null; } } export function readStdinIfPiped(): string | null { if (process.stdin.isTTY) return null; try { return readFileSync(0, "utf8"); } catch { return null; } } export function shortSessionId(sessionId: string): string { const hex = sessionId.replace(/[^0-9a-fA-F]/g, "").toLowerCase(); if (hex.length >= 8) return hex.slice(0, 8); return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000"; } export function deriveSessionName(sessionId: string, base?: string | null): string { const short = shortSessionId(sessionId); const trimmed = (base ?? "").trim(); if (trimmed) return `${trimmed}-${short}`; return `claude-${short}`; } export interface HookMessage { id: number; from: string; body: string; sentAt: string; } export function formatMessagesForHook(name: string, messages: HookMessage[]): string { if (messages.length === 0) return ""; const header = messages.length === 1 ? `You have 1 new mailbox message for "${name}":` : `You have ${messages.length} new mailbox messages for "${name}":`; const lines: string[] = [header, ""]; for (const m of messages) { lines.push(`[#${m.id}] from ${m.from} (${m.sentAt}):`); for (const bodyLine of m.body.split(/\r?\n/)) { lines.push(` ${bodyLine}`); } lines.push(""); } return lines.join("\n").trimEnd() + "\n"; } export type HookScope = "user" | "project"; export function settingsPathFor(scope: HookScope, cwd: string = process.cwd()): string { if (scope === "user") return join(homedir(), ".claude", "settings.json"); return join(cwd, ".claude", "settings.json"); } interface ClaudeHookCommand { type: "command"; command: string; timeout?: number; } interface ClaudeHookGroup { matcher?: string; hooks: ClaudeHookCommand[]; } interface ClaudeSettings { hooks?: Record; [key: string]: unknown; } const HOOK_EVENT = "UserPromptSubmit"; export function buildHookCommand(name: string, url?: string): string { const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"]; if (url) parts.push("--url", quoteIfNeeded(url)); return parts.join(" "); } function quoteIfNeeded(value: string): string { if (/^[A-Za-z0-9._:/@\-]+$/.test(value)) return value; return `"${value.replace(/(["\\])/g, "\\$1")}"`; } function isOurHookCommand(command: string): boolean { const c = command.trim(); return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c); } export function readSettings(path: string): ClaudeSettings { if (!existsSync(path)) return {}; const raw = readFileSync(path, "utf8"); if (!raw.trim()) return {}; return JSON.parse(raw) as ClaudeSettings; } export function writeSettings(path: string, settings: ClaudeSettings): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); } export interface PatchResult { changed: boolean; reason: "added" | "already-present" | "removed" | "not-present"; } export function applyInstall(settings: ClaudeSettings, command: string): PatchResult { settings.hooks ??= {}; settings.hooks[HOOK_EVENT] ??= []; const groups = settings.hooks[HOOK_EVENT]; for (const group of groups) { for (const hook of group.hooks) { if (hook.command.trim() === command.trim()) { return { changed: false, reason: "already-present" }; } } } let target = groups.find((g) => (g.matcher ?? "") === ""); if (!target) { target = { matcher: "", hooks: [] }; groups.push(target); } target.hooks.push({ type: "command", command }); return { changed: true, reason: "added" }; } export function applyUninstall(settings: ClaudeSettings): PatchResult { const groups = settings.hooks?.[HOOK_EVENT]; if (!groups || groups.length === 0) return { changed: false, reason: "not-present" }; let removed = false; for (const group of groups) { const before = group.hooks.length; group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command)); if (group.hooks.length !== before) removed = true; } settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0); if (settings.hooks![HOOK_EVENT].length === 0) { delete settings.hooks![HOOK_EVENT]; } if (settings.hooks && Object.keys(settings.hooks).length === 0) { delete settings.hooks; } return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" }; }