From 2c82bd880a1e6c092ed141569927b98e6ef3a390 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Wed, 21 Jan 2026 16:23:15 -0800 Subject: [PATCH] feat: implement Claude Code-compatible hooks system (#607) --- src/cli/App.tsx | 136 ++++- src/cli/commands/registry.ts | 10 +- src/cli/components/HooksManager.tsx | 549 +++++++++++++++++ src/hooks/executor.ts | 246 ++++++++ src/hooks/index.ts | 382 ++++++++++++ src/hooks/loader.ts | 238 ++++++++ src/hooks/types.ts | 289 +++++++++ src/hooks/writer.ts | 243 ++++++++ src/permissions/checker.ts | 62 ++ src/settings-manager.ts | 4 + src/tests/hooks/e2e.test.ts | 658 ++++++++++++++++++++ src/tests/hooks/executor.test.ts | 539 ++++++++++++++++ src/tests/hooks/integration.test.ts | 914 ++++++++++++++++++++++++++++ src/tests/hooks/loader.test.ts | 758 +++++++++++++++++++++++ src/tests/settings-manager.test.ts | 201 ++++++ src/tools/impl/Task.ts | 26 + src/tools/manager.ts | 39 ++ src/utils/secrets.ts | 6 + 18 files changed, 5292 insertions(+), 8 deletions(-) create mode 100644 src/cli/components/HooksManager.tsx create mode 100644 src/hooks/executor.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/loader.ts create mode 100644 src/hooks/types.ts create mode 100644 src/hooks/writer.ts create mode 100644 src/tests/hooks/e2e.test.ts create mode 100644 src/tests/hooks/executor.test.ts create mode 100644 src/tests/hooks/integration.test.ts create mode 100644 src/tests/hooks/loader.test.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 220fd08..6f4f2de 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -45,6 +45,14 @@ import { sendMessageStream } from "../agent/message"; import { getModelDisplayName, getModelInfo } from "../agent/model"; import { SessionStats } from "../agent/stats"; import { INTERRUPTED_BY_USER } from "../constants"; +import { + runNotificationHooks, + runPreCompactHooks, + runSessionEndHooks, + runSessionStartHooks, + runStopHooks, + runUserPromptSubmitHooks, +} from "../hooks"; import type { ApprovalContext } from "../permissions/analyzer"; import { type PermissionMode, permissionMode } from "../permissions/mode"; import { @@ -93,6 +101,7 @@ import { colors } from "./components/colors"; import { ErrorMessage } from "./components/ErrorMessageRich"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; +import { HooksManager } from "./components/HooksManager"; import { Input } from "./components/InputRich"; import { McpConnectFlow } from "./components/McpConnectFlow"; import { McpSelector } from "./components/McpSelector"; @@ -213,8 +222,16 @@ function uid(prefix: string) { // Send desktop notification via terminal bell // Modern terminals (iTerm2, Ghostty, WezTerm, Kitty) convert this to a desktop // notification when the terminal is not focused -function sendDesktopNotification() { +function sendDesktopNotification( + message = "Awaiting your input", + level: "info" | "warning" | "error" = "info", +) { + // Send terminal bell for native notification process.stdout.write("\x07"); + // Run Notification hooks (fire-and-forget, don't block) + runNotificationHooks(message, level).catch(() => { + // Silently ignore hook errors + }); } // Check if error is retriable based on stop reason and run metadata @@ -880,6 +897,7 @@ export default function App({ | "mcp" | "mcp-connect" | "help" + | "hooks" | null; const [activeOverlay, setActiveOverlay] = useState(null); const [feedbackPrefill, setFeedbackPrefill] = useState(""); @@ -948,6 +966,8 @@ export default function App({ // Session stats tracking const sessionStatsRef = useRef(new SessionStats()); + const sessionStartTimeRef = useRef(Date.now()); + const sessionHooksRanRef = useRef(false); // Wire up session stats to telemetry for safety net handlers useEffect(() => { @@ -961,6 +981,39 @@ export default function App({ }; }, []); + // Run SessionStart hooks when agent becomes available + useEffect(() => { + if (agentId && !sessionHooksRanRef.current) { + sessionHooksRanRef.current = true; + // Determine if this is a new session or resumed + const isNewSession = !initialConversationId; + runSessionStartHooks( + isNewSession, + agentId, + agentName ?? undefined, + conversationIdRef.current ?? undefined, + ).catch(() => { + // Silently ignore hook errors + }); + } + }, [agentId, agentName, initialConversationId]); + + // Run SessionEnd hooks on unmount + useEffect(() => { + return () => { + const durationMs = Date.now() - sessionStartTimeRef.current; + runSessionEndHooks( + durationMs, + undefined, // messageCount not tracked in SessionStats + undefined, // toolCallCount not tracked in SessionStats + agentIdRef.current ?? undefined, + conversationIdRef.current ?? undefined, + ).catch(() => { + // Silently ignore hook errors + }); + }; + }, []); + // Show exit stats on exit (double Ctrl+C) const [showExitStats, setShowExitStats] = useState(false); @@ -2092,6 +2145,17 @@ export default function App({ conversationBusyRetriesRef.current = 0; lastDequeuedMessageRef.current = null; // Clear - message was processed successfully + // Run Stop hooks (fire-and-forget) + runStopHooks( + stopReasonToHandle, + buffersRef.current.order.length, + Array.from(buffersRef.current.byId.values()).filter( + (item) => item.kind === "tool_call", + ).length, + ).catch(() => { + // Silently ignore hook errors + }); + // Disable eager approval check after first successful message (LET-7101) // Any new approvals from here on are from our own turn, not orphaned if (needsEagerApprovalCheck) { @@ -2101,7 +2165,7 @@ export default function App({ // Send desktop notification when turn completes // and we're not about to auto-send another queued message if (!waitingForQueueCancelRef.current) { - sendDesktopNotification(); + sendDesktopNotification("Turn completed, awaiting your input"); } // Check if we were waiting for cancel but stream finished naturally @@ -2638,7 +2702,7 @@ export default function App({ setAutoDeniedApprovals(autoDeniedResults); setStreaming(false); // Notify user that approval is needed - sendDesktopNotification(); + sendDesktopNotification("Approval needed"); return; } @@ -2740,7 +2804,7 @@ export default function App({ setMessageQueue([]); setStreaming(false); - sendDesktopNotification(); + sendDesktopNotification("Agent execution error", "error"); refreshDerived(); return; } @@ -2893,7 +2957,7 @@ export default function App({ setMessageQueue([]); setStreaming(false); - sendDesktopNotification(); // Notify user of error + sendDesktopNotification("Stream error", "error"); // Notify user of error refreshDerived(); return; } @@ -2972,7 +3036,7 @@ export default function App({ setMessageQueue([]); setStreaming(false); - sendDesktopNotification(); // Notify user of error + sendDesktopNotification("Execution error", "error"); // Notify user of error refreshDerived(); return; } @@ -3026,7 +3090,7 @@ export default function App({ setMessageQueue([]); setStreaming(false); - sendDesktopNotification(); // Notify user of error + sendDesktopNotification("Processing error", "error"); // Notify user of error refreshDerived(); } finally { // Check if this conversation was superseded by an ESC interrupt @@ -3906,6 +3970,30 @@ export default function App({ if (!msg) return { submitted: false }; + // Run UserPromptSubmit hooks - can block the prompt from being processed + const isCommand = msg.startsWith("/"); + const hookResult = await runUserPromptSubmitHooks( + msg, + isCommand, + agentId, + conversationIdRef.current, + ); + if (hookResult.blocked) { + // Show feedback from hook in the transcript + const feedbackId = uid("status"); + const feedback = hookResult.feedback.join("\n") || "Blocked by hook"; + buffersRef.current.byId.set(feedbackId, { + kind: "status", + id: feedbackId, + lines: [ + `${feedback}`, + ], + }); + buffersRef.current.order.push(feedbackId); + refreshDerived(); + return { submitted: false }; + } + // Capture the generation at submission time, BEFORE any async work. // This allows detecting if ESC was pressed during async operations. const submissionGeneration = conversationGenerationRef.current; @@ -4195,6 +4283,12 @@ export default function App({ return { submitted: true }; } + // Special handling for /hooks command - opens hooks manager + if (trimmed === "/hooks") { + setActiveOverlay("hooks"); + return { submitted: true }; + } + // Special handling for /usage command - show session stats if (trimmed === "/usage") { const cmdId = uid("cmd"); @@ -4657,6 +4751,29 @@ export default function App({ setCommandRunning(true); try { + // Run PreCompact hooks - can block the compact operation + const preCompactResult = await runPreCompactHooks( + undefined, // context_length - not available here + undefined, // max_context_length - not available here + agentId, + conversationIdRef.current, + ); + if (preCompactResult.blocked) { + const feedback = + preCompactResult.feedback.join("\n") || "Blocked by hook"; + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: msg, + output: `Compact blocked: ${feedback}`, + phase: "finished", + success: false, + }); + refreshDerived(); + setCommandRunning(false); + return { submitted: true }; + } + const client = await getClient(); // SDK types are out of date - compact returns CompactionResponse, not void const result = (await client.agents.messages.compact( @@ -8631,6 +8748,11 @@ Plan file path: ${planFilePath}`; {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } + {/* Hooks Manager - for managing hooks configuration */} + {activeOverlay === "hooks" && ( + + )} + {/* New Agent Dialog - for naming new agent before creation */} {activeOverlay === "new" && ( = { return "Opening help..."; }, }, + "/hooks": { + desc: "Manage hooks configuration", + order: 36, + handler: () => { + // Handled specially in App.tsx to open hooks manager + return "Opening hooks manager..."; + }, + }, "/terminal": { desc: "Setup terminal shortcuts [--revert]", - order: 36, + order: 37, handler: async (args: string[]) => { const { detectTerminalType, diff --git a/src/cli/components/HooksManager.tsx b/src/cli/components/HooksManager.tsx new file mode 100644 index 0000000..d6c76a9 --- /dev/null +++ b/src/cli/components/HooksManager.tsx @@ -0,0 +1,549 @@ +// src/cli/components/HooksManager.tsx +// Interactive TUI for managing hooks configuration + +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { memo, useCallback, useEffect, useState } from "react"; +import type { HookEvent, HookMatcher } from "../../hooks/types"; +import { + addHookMatcher, + countHooksForEvent, + countTotalHooks, + type HookMatcherWithSource, + loadHooksWithSource, + removeHookMatcher, + type SaveLocation, +} from "../../hooks/writer"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +// Box drawing characters +const BOX_TOP_LEFT = "╭"; +const BOX_TOP_RIGHT = "╮"; +const BOX_BOTTOM_LEFT = "╰"; +const BOX_BOTTOM_RIGHT = "╯"; +const BOX_HORIZONTAL = "─"; +const BOX_VERTICAL = "│"; + +interface HooksManagerProps { + onClose: () => void; +} + +type Screen = + | "events" + | "matchers" + | "add-matcher" + | "add-command" + | "save-location" + | "delete-confirm"; + +// All hook events with descriptions +const HOOK_EVENTS: { event: HookEvent; description: string }[] = [ + { event: "PreToolUse", description: "Before tool execution" }, + { event: "PostToolUse", description: "After tool execution" }, + { event: "PermissionRequest", description: "When permission is requested" }, + { event: "UserPromptSubmit", description: "When user submits a prompt" }, + { event: "Notification", description: "When notifications are sent" }, + { event: "Stop", description: "When the agent finishes responding" }, + { event: "SubagentStop", description: "When a subagent completes" }, + { event: "PreCompact", description: "Before context compaction" }, + { event: "Setup", description: "When invoked with --init flags" }, + { event: "SessionStart", description: "When a session starts" }, + { event: "SessionEnd", description: "When a session ends" }, +]; + +// Available tools for matcher suggestions +const TOOL_NAMES = [ + "Task", + "Bash", + "Glob", + "Grep", + "Read", + "Edit", + "Write", + "WebFetch", + "TodoWrite", + "WebSearch", + "AskUserQuestion", + "Skill", + "EnterPlanMode", + "ExitPlanMode", + "KillShell", +]; + +// Save location options +const SAVE_LOCATIONS: { + location: SaveLocation; + label: string; + path: string; +}[] = [ + { + location: "project-local", + label: "Project settings (local)", + path: ".letta/settings.local.json", + }, + { + location: "project", + label: "Project settings", + path: ".letta/settings.json", + }, + { location: "user", label: "User settings", path: "~/.letta/settings.json" }, +]; + +function getSourceLabel(source: SaveLocation): string { + switch (source) { + case "user": + return "User"; + case "project": + return "Project"; + case "project-local": + return "Local"; + } +} + +/** + * Create a box border line + */ +function boxLine(content: string, width: number): string { + const innerWidth = width - 2; + const paddedContent = content.padEnd(innerWidth).slice(0, innerWidth); + return `${BOX_VERTICAL}${paddedContent}${BOX_VERTICAL}`; +} + +function boxTop(width: number): string { + return `${BOX_TOP_LEFT}${BOX_HORIZONTAL.repeat(width - 2)}${BOX_TOP_RIGHT}`; +} + +function boxBottom(width: number): string { + return `${BOX_BOTTOM_LEFT}${BOX_HORIZONTAL.repeat(width - 2)}${BOX_BOTTOM_RIGHT}`; +} + +export const HooksManager = memo(function HooksManager({ + onClose, +}: HooksManagerProps) { + const terminalWidth = useTerminalWidth(); + const boxWidth = Math.min(terminalWidth - 4, 70); + + const [screen, setScreen] = useState("events"); + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedEvent, setSelectedEvent] = useState(null); + const [matchers, setMatchers] = useState([]); + const [totalHooks, setTotalHooks] = useState(0); + + // New hook state + const [newMatcher, setNewMatcher] = useState(""); + const [newCommand, setNewCommand] = useState(""); + const [selectedLocation, setSelectedLocation] = useState(0); + + // Delete confirmation + const [deleteMatcherIndex, setDeleteMatcherIndex] = useState(-1); + const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No + + // Refresh counts - called when hooks change + const refreshCounts = useCallback(() => { + setTotalHooks(countTotalHooks()); + }, []); + + // Load total hooks count on mount and when returning to events screen + useEffect(() => { + if (screen === "events") { + refreshCounts(); + } + }, [screen, refreshCounts]); + + // Load matchers when event is selected + const loadMatchers = useCallback((event: HookEvent) => { + const loaded = loadHooksWithSource(event); + setMatchers(loaded); + }, []); + + // Handle adding a hook + const handleAddHook = useCallback(async () => { + if (!selectedEvent || !newCommand.trim()) return; + + const location = SAVE_LOCATIONS[selectedLocation]?.location; + if (!location) return; + + const matcher: HookMatcher = { + matcher: newMatcher.trim() || "*", + hooks: [{ type: "command", command: newCommand.trim() }], + }; + + await addHookMatcher(selectedEvent, matcher, location); + loadMatchers(selectedEvent); + refreshCounts(); + + // Reset and go back to matchers + setNewMatcher(""); + setNewCommand(""); + setSelectedLocation(0); + setScreen("matchers"); + setSelectedIndex(0); + }, [ + selectedEvent, + newMatcher, + newCommand, + selectedLocation, + loadMatchers, + refreshCounts, + ]); + + // Handle deleting a hook + const handleDeleteHook = useCallback(async () => { + if (deleteMatcherIndex < 0 || !selectedEvent) return; + + const matcher = matchers[deleteMatcherIndex]; + if (!matcher) return; + + await removeHookMatcher(selectedEvent, matcher.sourceIndex, matcher.source); + loadMatchers(selectedEvent); + refreshCounts(); + + // Reset and go back to matchers + setDeleteMatcherIndex(-1); + setScreen("matchers"); + setSelectedIndex(0); + }, [ + deleteMatcherIndex, + selectedEvent, + matchers, + loadMatchers, + refreshCounts, + ]); + + useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onClose(); + return; + } + + // Handle each screen + if (screen === "events") { + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(HOOK_EVENTS.length - 1, prev + 1)); + } else if (key.return) { + const selected = HOOK_EVENTS[selectedIndex]; + if (selected) { + setSelectedEvent(selected.event); + loadMatchers(selected.event); + setScreen("matchers"); + setSelectedIndex(0); + } + } else if (key.escape) { + onClose(); + } + } else if (screen === "matchers") { + // Items: [+ Add new matcher] + existing matchers + const itemCount = matchers.length + 1; + + if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1)); + } else if (key.return) { + if (selectedIndex === 0) { + // Add new matcher + setScreen("add-matcher"); + setNewMatcher(""); + } else { + // Could add edit functionality here + } + } else if ((input === "d" || input === "D") && selectedIndex > 0) { + // Delete selected matcher + setDeleteMatcherIndex(selectedIndex - 1); + setDeleteConfirmIndex(1); // Default to No + setScreen("delete-confirm"); + } else if (key.escape) { + setScreen("events"); + setSelectedIndex(0); + setSelectedEvent(null); + } + } else if (screen === "add-matcher") { + // Text input handles most keys + if (key.return && !key.shift) { + setScreen("add-command"); + setNewCommand(""); + } else if (key.escape) { + setScreen("matchers"); + setSelectedIndex(0); + setNewMatcher(""); + } + } else if (screen === "add-command") { + if (key.return && !key.shift) { + setScreen("save-location"); + setSelectedLocation(0); + } else if (key.escape) { + setScreen("add-matcher"); + } + } else if (screen === "save-location") { + if (key.upArrow) { + setSelectedLocation((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedLocation((prev) => + Math.min(SAVE_LOCATIONS.length - 1, prev + 1), + ); + } else if (key.return) { + handleAddHook(); + } else if (key.escape) { + setScreen("add-command"); + } + } else if (screen === "delete-confirm") { + if (key.upArrow || key.downArrow) { + setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0)); + } else if (key.return) { + if (deleteConfirmIndex === 0) { + handleDeleteHook(); + } else { + setScreen("matchers"); + } + } else if (key.escape) { + setScreen("matchers"); + } + } + }); + + // Render Events List + if (screen === "events") { + return ( + + {boxTop(boxWidth)} + + {boxLine( + ` Hooks${" ".repeat(boxWidth - 20)}${totalHooks} hooks `, + boxWidth, + )} + + {boxBottom(boxWidth)} + + + {HOOK_EVENTS.map((item, index) => { + const isSelected = index === selectedIndex; + const hookCount = countHooksForEvent(item.event); + const prefix = isSelected ? "❯" : " "; + const countStr = hookCount > 0 ? ` (${hookCount})` : ""; + + return ( + + + {prefix} {index + 1}. {item.event} + + - {item.description} + {countStr} + + ); + })} + + + Enter to select · esc to cancel + + ); + } + + // Render Matchers List + if (screen === "matchers" && selectedEvent) { + return ( + + {boxTop(boxWidth)} + {boxLine(` ${selectedEvent} - Tool Matchers `, boxWidth)} + {boxBottom(boxWidth)} + + Input to command is JSON of tool call arguments. + Exit code 0 - stdout/stderr not shown + + Exit code 2 - show stderr to model and block tool call + + + Other exit codes - show stderr to user only but continue + + + + {/* Add new matcher option */} + + + {selectedIndex === 0 ? "❯" : " "} 1.{" "} + + + Add new matcher... + + + {/* Existing matchers */} + {matchers.map((matcher, index) => { + const isSelected = index + 1 === selectedIndex; + const prefix = isSelected ? "❯" : " "; + const sourceLabel = `[${getSourceLabel(matcher.source)}]`; + const matcherPattern = matcher.matcher || "*"; + const command = matcher.hooks[0]?.command || ""; + const truncatedCommand = + command.length > 30 ? `${command.slice(0, 27)}...` : command; + + return ( + + + {prefix} {index + 2}.{" "} + + {sourceLabel} + {matcherPattern.padEnd(12)} + {truncatedCommand} + + ); + })} + + + Enter to select · d to delete · esc to go back + + ); + } + + // Render Add Matcher - Tool Pattern Input + if (screen === "add-matcher" && selectedEvent) { + return ( + + {boxTop(boxWidth)} + + {boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)} + + {boxBottom(boxWidth)} + + Input to command is JSON of tool call arguments. + Exit code 0 - stdout/stderr not shown + + Exit code 2 - show stderr to model and block tool call + + + + Possible matcher values for field tool_name: + {TOOL_NAMES.join(", ")} + + + Tool matcher: + {boxTop(boxWidth - 2)} + + {BOX_VERTICAL} + + + {boxBottom(boxWidth - 2)} + + + Example Matchers: + • Write (single tool) + • Write|Edit (multiple tools) + • * (all tools) + + Enter to continue · esc to cancel + + ); + } + + // Render Add Matcher - Command Input + if (screen === "add-command" && selectedEvent) { + return ( + + {boxTop(boxWidth)} + + {boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)} + + {boxBottom(boxWidth)} + + Matcher: {newMatcher || "*"} + + + Command: + {boxTop(boxWidth - 2)} + + {BOX_VERTICAL} + + + {boxBottom(boxWidth - 2)} + + + Enter to continue · esc to go back + + ); + } + + // Render Save Location Picker + if (screen === "save-location" && selectedEvent) { + return ( + + {boxTop(boxWidth)} + {boxLine(" Save hook configuration ", boxWidth)} + {boxBottom(boxWidth)} + + + Event: {selectedEvent} + Matcher: {newMatcher || "*"} + Command: {newCommand} + + + Where should this hook be saved? + + + {SAVE_LOCATIONS.map((loc, index) => { + const isSelected = index === selectedLocation; + const prefix = isSelected ? "❯" : " "; + + return ( + + + {prefix} {index + 1}. {loc.label} + + {loc.path} + + ); + })} + + + Enter to confirm · esc to go back + + ); + } + + // Render Delete Confirmation + if (screen === "delete-confirm" && deleteMatcherIndex >= 0) { + const matcher = matchers[deleteMatcherIndex]; + + return ( + + {boxTop(boxWidth)} + {boxLine(" Delete hook? ", boxWidth)} + {boxBottom(boxWidth)} + + + Matcher: {matcher?.matcher || "*"} + Command: {matcher?.hooks[0]?.command} + Source: {matcher ? getSourceLabel(matcher.source) : ""} + + + + + {deleteConfirmIndex === 0 ? "❯" : " "} Yes, delete + + + + + {deleteConfirmIndex === 1 ? "❯" : " "} No, cancel + + + + + Enter to confirm · esc to cancel + + ); + } + + return null; +}); diff --git a/src/hooks/executor.ts b/src/hooks/executor.ts new file mode 100644 index 0000000..155fc7f --- /dev/null +++ b/src/hooks/executor.ts @@ -0,0 +1,246 @@ +// src/hooks/executor.ts +// Executes hook commands with JSON input via stdin + +import { spawn } from "node:child_process"; +import { + type HookCommand, + type HookExecutionResult, + HookExitCode, + type HookInput, + type HookResult, +} from "./types"; + +/** Default timeout for hook execution (60 seconds) */ +const DEFAULT_TIMEOUT_MS = 60000; + +/** + * Execute a single hook command with JSON input via stdin + */ +export async function executeHookCommand( + hook: HookCommand, + input: HookInput, + workingDirectory: string = process.cwd(), +): Promise { + const startTime = Date.now(); + const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS; + const inputJson = JSON.stringify(input); + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + let resolved = false; + + const safeResolve = (result: HookResult) => { + if (!resolved) { + resolved = true; + // Log hook completion + const exitLabel = + result.exitCode === HookExitCode.ALLOW + ? "\x1b[32m✓ allowed\x1b[0m" + : result.exitCode === HookExitCode.BLOCK + ? "\x1b[31m✗ blocked\x1b[0m" + : "\x1b[33m⚠ error\x1b[0m"; + console.log( + `\x1b[90m[hook] ${exitLabel} (${result.durationMs}ms)${result.stdout ? ` stdout: ${result.stdout.slice(0, 100)}` : ""}${result.stderr ? ` stderr: ${result.stderr.slice(0, 100)}` : ""}\x1b[0m`, + ); + resolve(result); + } + }; + + try { + // Log hook start + console.log(`\x1b[90m[hook] Running: ${hook.command}\x1b[0m`); + + // Spawn shell process to run the hook command + const child = spawn("sh", ["-c", hook.command], { + cwd: workingDirectory, + env: { + ...process.env, + // Add hook-specific environment variables + LETTA_HOOK_EVENT: input.event_type, + LETTA_WORKING_DIR: workingDirectory, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Set up timeout + const timeoutId = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + // Give process time to clean up, then force kill + setTimeout(() => { + if (!resolved) { + child.kill("SIGKILL"); + } + }, 1000); + }, timeout); + + // Write JSON input to stdin + if (child.stdin) { + // Handle stdin errors (e.g., EPIPE if process exits before reading) + child.stdin.on("error", () => { + // Silently ignore - process may have exited before reading stdin + }); + child.stdin.write(inputJson); + child.stdin.end(); + } + + // Collect stdout + if (child.stdout) { + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + } + + // Collect stderr + if (child.stderr) { + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + } + + // Handle process exit + child.on("close", (code) => { + clearTimeout(timeoutId); + const durationMs = Date.now() - startTime; + + // Map exit code to our enum + let exitCode: HookExitCode; + if (timedOut) { + exitCode = HookExitCode.ERROR; + } else if (code === null) { + exitCode = HookExitCode.ERROR; + } else if (code === 0) { + exitCode = HookExitCode.ALLOW; + } else if (code === 2) { + exitCode = HookExitCode.BLOCK; + } else { + exitCode = HookExitCode.ERROR; + } + + safeResolve({ + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + timedOut, + durationMs, + ...(timedOut && { error: `Hook timed out after ${timeout}ms` }), + }); + }); + + // Handle spawn error + child.on("error", (error) => { + clearTimeout(timeoutId); + const durationMs = Date.now() - startTime; + + safeResolve({ + exitCode: HookExitCode.ERROR, + stdout: stdout.trim(), + stderr: stderr.trim(), + timedOut: false, + durationMs, + error: `Failed to execute hook: ${error.message}`, + }); + }); + } catch (error) { + const durationMs = Date.now() - startTime; + safeResolve({ + exitCode: HookExitCode.ERROR, + stdout: "", + stderr: "", + timedOut: false, + durationMs, + error: `Failed to spawn hook process: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }); +} + +/** + * Execute multiple hooks sequentially and aggregate results + * Stops early if any hook returns BLOCK (exit code 2) + */ +export async function executeHooks( + hooks: HookCommand[], + input: HookInput, + workingDirectory: string = process.cwd(), +): Promise { + const results: HookResult[] = []; + const feedback: string[] = []; + let blocked = false; + let errored = false; + + for (const hook of hooks) { + const result = await executeHookCommand(hook, input, workingDirectory); + results.push(result); + + // Collect feedback from stdout when hook blocks + if (result.exitCode === HookExitCode.BLOCK) { + blocked = true; + if (result.stdout) { + feedback.push(result.stdout); + } + // Stop processing more hooks after a block + break; + } + + // Track errors but continue processing + if (result.exitCode === HookExitCode.ERROR) { + errored = true; + if (result.stderr) { + feedback.push(`Hook error: ${result.stderr}`); + } else if (result.error) { + feedback.push(`Hook error: ${result.error}`); + } + } + } + + return { + blocked, + errored, + feedback, + results, + }; +} + +/** + * Execute hooks in parallel (for non-blocking hooks like PostToolUse) + */ +export async function executeHooksParallel( + hooks: HookCommand[], + input: HookInput, + workingDirectory: string = process.cwd(), +): Promise { + const results = await Promise.all( + hooks.map((hook) => executeHookCommand(hook, input, workingDirectory)), + ); + + const feedback: string[] = []; + let blocked = false; + let errored = false; + + for (const result of results) { + if (result.exitCode === HookExitCode.BLOCK) { + blocked = true; + if (result.stdout) { + feedback.push(result.stdout); + } + } + if (result.exitCode === HookExitCode.ERROR) { + errored = true; + if (result.stderr) { + feedback.push(`Hook error: ${result.stderr}`); + } else if (result.error) { + feedback.push(`Hook error: ${result.error}`); + } + } + } + + return { + blocked, + errored, + feedback, + results, + }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..facb04b --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,382 @@ +// src/hooks/index.ts +// Main hooks module - provides high-level API for running hooks + +import { executeHooks, executeHooksParallel } from "./executor"; +import { getHooksForEvent, hasHooksForEvent, loadHooks } from "./loader"; +import type { + HookEvent, + HookExecutionResult, + NotificationHookInput, + PermissionRequestHookInput, + PostToolUseHookInput, + PreCompactHookInput, + PreToolUseHookInput, + SessionEndHookInput, + SessionStartHookInput, + SetupHookInput, + StopHookInput, + SubagentStopHookInput, + UserPromptSubmitHookInput, +} from "./types"; + +export { clearHooksCache } from "./loader"; +// Re-export types for convenience +export * from "./types"; + +// ============================================================================ +// High-level hook runner functions +// ============================================================================ + +/** + * Run PreToolUse hooks before a tool is executed + * Can block the tool call by returning blocked: true + */ +export async function runPreToolUseHooks( + toolName: string, + toolInput: Record, + toolCallId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "PreToolUse", + toolName, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: workingDirectory, + tool_name: toolName, + tool_input: toolInput, + tool_call_id: toolCallId, + }; + + // Run sequentially - stop on first block + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run PostToolUse hooks after a tool has executed + * These run in parallel since they cannot block + */ +export async function runPostToolUseHooks( + toolName: string, + toolInput: Record, + toolResult: { status: "success" | "error"; output?: string }, + toolCallId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "PostToolUse", + toolName, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: PostToolUseHookInput = { + event_type: "PostToolUse", + working_directory: workingDirectory, + tool_name: toolName, + tool_input: toolInput, + tool_call_id: toolCallId, + tool_result: toolResult, + }; + + // Run in parallel since PostToolUse cannot block + return executeHooksParallel(hooks, input, workingDirectory); +} + +/** + * Run PermissionRequest hooks when a permission dialog would be shown + * Can auto-allow (exit 0) or auto-deny (exit 2) the permission + */ +export async function runPermissionRequestHooks( + toolName: string, + toolInput: Record, + permissionType: "allow" | "deny" | "ask", + scope?: "session" | "project" | "user", + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "PermissionRequest", + toolName, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: PermissionRequestHookInput = { + event_type: "PermissionRequest", + working_directory: workingDirectory, + tool_name: toolName, + tool_input: toolInput, + permission: { + type: permissionType, + scope, + }, + }; + + // Run sequentially - first hook that returns 0 or 2 determines outcome + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run UserPromptSubmit hooks before processing a user's prompt + * Can block the prompt from being processed + */ +export async function runUserPromptSubmitHooks( + prompt: string, + isCommand: boolean, + agentId?: string, + conversationId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "UserPromptSubmit", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: UserPromptSubmitHookInput = { + event_type: "UserPromptSubmit", + working_directory: workingDirectory, + prompt, + is_command: isCommand, + agent_id: agentId, + conversation_id: conversationId, + }; + + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run Notification hooks when a notification is sent + * These run in parallel and cannot block + */ +export async function runNotificationHooks( + message: string, + level: "info" | "warning" | "error" = "info", + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "Notification", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: NotificationHookInput = { + event_type: "Notification", + working_directory: workingDirectory, + message, + level, + }; + + // Run in parallel - notifications cannot block + return executeHooksParallel(hooks, input, workingDirectory); +} + +/** + * Run Stop hooks when the agent finishes responding + * Can block stoppage (exit 2), stderr shown to model + */ +export async function runStopHooks( + stopReason: string, + messageCount?: number, + toolCallCount?: number, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent("Stop", undefined, workingDirectory); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: StopHookInput = { + event_type: "Stop", + working_directory: workingDirectory, + stop_reason: stopReason, + message_count: messageCount, + tool_call_count: toolCallCount, + }; + + // Run sequentially - Stop can block + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run SubagentStop hooks when a subagent task completes + * Can block stoppage (exit 2), stderr shown to subagent + */ +export async function runSubagentStopHooks( + subagentType: string, + subagentId: string, + success: boolean, + error?: string, + agentId?: string, + conversationId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "SubagentStop", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: SubagentStopHookInput = { + event_type: "SubagentStop", + working_directory: workingDirectory, + subagent_type: subagentType, + subagent_id: subagentId, + success, + error, + agent_id: agentId, + conversation_id: conversationId, + }; + + // Run sequentially - SubagentStop can block + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run PreCompact hooks before a compact operation + * Cannot block, stderr shown to user only + */ +export async function runPreCompactHooks( + contextLength?: number, + maxContextLength?: number, + agentId?: string, + conversationId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "PreCompact", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: PreCompactHookInput = { + event_type: "PreCompact", + working_directory: workingDirectory, + context_length: contextLength, + max_context_length: maxContextLength, + agent_id: agentId, + conversation_id: conversationId, + }; + + // Run in parallel - PreCompact cannot block + return executeHooksParallel(hooks, input, workingDirectory); +} + +/** + * Run Setup hooks when CLI is invoked with init flags + */ +export async function runSetupHooks( + initType: "init" | "init-only" | "maintenance", + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent("Setup", undefined, workingDirectory); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: SetupHookInput = { + event_type: "Setup", + working_directory: workingDirectory, + init_type: initType, + }; + + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run SessionStart hooks when a session begins + */ +export async function runSessionStartHooks( + isNewSession: boolean, + agentId?: string, + agentName?: string, + conversationId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "SessionStart", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: SessionStartHookInput = { + event_type: "SessionStart", + working_directory: workingDirectory, + is_new_session: isNewSession, + agent_id: agentId, + agent_name: agentName, + conversation_id: conversationId, + }; + + return executeHooks(hooks, input, workingDirectory); +} + +/** + * Run SessionEnd hooks when a session ends + */ +export async function runSessionEndHooks( + durationMs?: number, + messageCount?: number, + toolCallCount?: number, + agentId?: string, + conversationId?: string, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = await getHooksForEvent( + "SessionEnd", + undefined, + workingDirectory, + ); + if (hooks.length === 0) { + return { blocked: false, errored: false, feedback: [], results: [] }; + } + + const input: SessionEndHookInput = { + event_type: "SessionEnd", + working_directory: workingDirectory, + duration_ms: durationMs, + message_count: messageCount, + tool_call_count: toolCallCount, + agent_id: agentId, + conversation_id: conversationId, + }; + + // Run in parallel - SessionEnd cannot block (session is already ending) + return executeHooksParallel(hooks, input, workingDirectory); +} + +/** + * Check if hooks are configured for a specific event + */ +export async function hasHooks( + event: HookEvent, + workingDirectory: string = process.cwd(), +): Promise { + const config = await loadHooks(workingDirectory); + return hasHooksForEvent(config, event); +} diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts new file mode 100644 index 0000000..a27d836 --- /dev/null +++ b/src/hooks/loader.ts @@ -0,0 +1,238 @@ +// src/hooks/loader.ts +// Loads and matches hooks from settings + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { exists, readFile } from "../utils/fs.js"; +import type { HookCommand, HookEvent, HooksConfig } from "./types"; + +/** + * Cache for loaded hooks configurations + */ +let globalHooksCache: HooksConfig | null = null; +const projectHooksCache: Map = new Map(); +const projectLocalHooksCache: Map = new Map(); + +/** + * Clear hooks cache (useful for testing or when settings change) + */ +export function clearHooksCache(): void { + globalHooksCache = null; + projectHooksCache.clear(); + projectLocalHooksCache.clear(); +} + +/** + * Get the path to global hooks settings + */ +function getGlobalSettingsPath(): string { + const home = process.env.HOME || homedir(); + return join(home, ".letta", "settings.json"); +} + +/** + * Get the path to project hooks settings + */ +function getProjectSettingsPath(workingDirectory: string): string { + return join(workingDirectory, ".letta", "settings.json"); +} + +/** + * Get the path to project-local hooks settings (gitignored) + */ +function getProjectLocalSettingsPath(workingDirectory: string): string { + return join(workingDirectory, ".letta", "settings.local.json"); +} + +/** + * Load hooks configuration from a settings file + */ +async function loadHooksFromFile(path: string): Promise { + if (!exists(path)) { + return null; + } + + try { + const content = await readFile(path); + const settings = JSON.parse(content) as { hooks?: HooksConfig }; + return settings.hooks || null; + } catch (error) { + // Silently ignore parse errors - don't break the app for bad hooks config + console.warn(`Failed to load hooks from ${path}:`, error); + return null; + } +} + +/** + * Load global hooks configuration from ~/.letta/settings.json + */ +export async function loadGlobalHooks(): Promise { + if (globalHooksCache !== null) { + return globalHooksCache; + } + + const path = getGlobalSettingsPath(); + const hooks = await loadHooksFromFile(path); + globalHooksCache = hooks || {}; + return globalHooksCache; +} + +/** + * Load project hooks configuration from .letta/settings.json + */ +export async function loadProjectHooks( + workingDirectory: string = process.cwd(), +): Promise { + const cached = projectHooksCache.get(workingDirectory); + if (cached !== undefined) { + return cached; + } + + const path = getProjectSettingsPath(workingDirectory); + const hooks = await loadHooksFromFile(path); + const result = hooks || {}; + projectHooksCache.set(workingDirectory, result); + return result; +} + +/** + * Load project-local hooks configuration from .letta/settings.local.json + */ +export async function loadProjectLocalHooks( + workingDirectory: string = process.cwd(), +): Promise { + const cached = projectLocalHooksCache.get(workingDirectory); + if (cached !== undefined) { + return cached; + } + + const path = getProjectLocalSettingsPath(workingDirectory); + const hooks = await loadHooksFromFile(path); + const result = hooks || {}; + projectLocalHooksCache.set(workingDirectory, result); + return result; +} + +/** + * Merge hooks configurations + * Priority order: project-local > project > global + * For each event, matchers are ordered by priority (local first, global last) + */ +export function mergeHooksConfigs( + global: HooksConfig, + project: HooksConfig, + projectLocal: HooksConfig = {}, +): HooksConfig { + const merged: HooksConfig = {}; + const allEvents = new Set([ + ...Object.keys(global), + ...Object.keys(project), + ...Object.keys(projectLocal), + ]) as Set; + + for (const event of allEvents) { + const globalMatchers = global[event] || []; + const projectMatchers = project[event] || []; + const projectLocalMatchers = projectLocal[event] || []; + // Project-local matchers run first, then project, then global + merged[event] = [ + ...projectLocalMatchers, + ...projectMatchers, + ...globalMatchers, + ]; + } + + return merged; +} + +/** + * Load merged hooks configuration (global + project + project-local) + */ +export async function loadHooks( + workingDirectory: string = process.cwd(), +): Promise { + const [global, project, projectLocal] = await Promise.all([ + loadGlobalHooks(), + loadProjectHooks(workingDirectory), + loadProjectLocalHooks(workingDirectory), + ]); + + return mergeHooksConfigs(global, project, projectLocal); +} + +/** + * Check if a tool name matches a matcher pattern + * Patterns: + * - "*" or "": matches all tools + * - "ToolName": exact match + * - "Tool1|Tool2|Tool3": matches any of the listed tools + */ +export function matchesTool(pattern: string, toolName: string): boolean { + // Empty or "*" matches everything + if (!pattern || pattern === "*") { + return true; + } + + // Check for pipe-separated list + if (pattern.includes("|")) { + const tools = pattern.split("|").map((t) => t.trim()); + return tools.includes(toolName); + } + + // Exact match + return pattern === toolName; +} + +/** + * Get all hooks that match a specific event and tool name + */ +export function getMatchingHooks( + config: HooksConfig, + event: HookEvent, + toolName?: string, +): HookCommand[] { + const matchers = config[event]; + if (!matchers || matchers.length === 0) { + return []; + } + + const hooks: HookCommand[] = []; + + for (const matcher of matchers) { + // For non-tool events, matcher is usually empty/"*" + // For tool events, check if the tool matches + if (!toolName || matchesTool(matcher.matcher, toolName)) { + hooks.push(...matcher.hooks); + } + } + + return hooks; +} + +/** + * Check if there are any hooks configured for a specific event + */ +export function hasHooksForEvent( + config: HooksConfig, + event: HookEvent, +): boolean { + const matchers = config[event]; + if (!matchers || matchers.length === 0) { + return false; + } + + // Check if any matcher has hooks + return matchers.some((m) => m.hooks && m.hooks.length > 0); +} + +/** + * Convenience function to load hooks and get matching ones for an event + */ +export async function getHooksForEvent( + event: HookEvent, + toolName?: string, + workingDirectory: string = process.cwd(), +): Promise { + const config = await loadHooks(workingDirectory); + return getMatchingHooks(config, event, toolName); +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..e8ad623 --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,289 @@ +// src/hooks/types.ts +// Types for Letta Code hooks system (Claude Code-compatible) + +/** + * Hook event types that can trigger hooks + */ +export type HookEvent = + | "PreToolUse" // Runs before tool calls (can block them) + | "PostToolUse" // Runs after tool calls complete (cannot block) + | "PermissionRequest" // Runs when a permission dialog is shown (can allow or deny) + | "UserPromptSubmit" // Runs when the user submits a prompt (can block) + | "Notification" // Runs when a notification is sent (cannot block) + | "Stop" // Runs when the agent finishes responding (can block) + | "SubagentStop" // Runs when subagent tasks complete (can block) + | "PreCompact" // Runs before a compact operation (cannot block) + | "Setup" // Runs when invoked with --init, --init-only, or --maintenance flags + | "SessionStart" // Runs when a new session starts or is resumed + | "SessionEnd"; // Runs when session ends (cannot block) + +/** + * Individual hook command configuration + */ +export interface HookCommand { + /** Type of hook - currently only "command" is supported */ + type: "command"; + /** Shell command to execute */ + command: string; + /** Optional timeout in milliseconds (default: 60000) */ + timeout?: number; +} + +/** + * Hook matcher configuration - matches hooks to specific tools/events + */ +export interface HookMatcher { + /** + * Tool name pattern to match: + * - Exact name: "Bash", "Edit", "Write" + * - Multiple tools: "Edit|Write" + * - All tools: "*" or "" + */ + matcher: string; + /** List of hooks to run when matched */ + hooks: HookCommand[]; +} + +/** + * Full hooks configuration stored in settings + */ +export type HooksConfig = { + [K in HookEvent]?: HookMatcher[]; +}; + +/** + * Exit codes from hook execution + */ +export enum HookExitCode { + /** Allow/continue - hook completed successfully, proceed with action */ + ALLOW = 0, + /** Error - hook encountered an error */ + ERROR = 1, + /** Block/deny - hook requests to block the action */ + BLOCK = 2, +} + +/** + * Result of executing a single hook + */ +export interface HookResult { + /** Exit code from the hook command */ + exitCode: HookExitCode; + /** Standard output from the hook */ + stdout: string; + /** Standard error from the hook */ + stderr: string; + /** Whether the hook timed out */ + timedOut: boolean; + /** Duration in milliseconds */ + durationMs: number; + /** Error message if hook failed to execute */ + error?: string; +} + +/** + * Aggregated result from running all matched hooks + */ +export interface HookExecutionResult { + /** Whether any hook blocked the action */ + blocked: boolean; + /** Whether any hook errored */ + errored: boolean; + /** Feedback messages from hooks (stdout when blocking) */ + feedback: string[]; + /** Individual results from each hook */ + results: HookResult[]; +} + +// ============================================================================ +// Input payloads for different hook events +// ============================================================================ + +/** + * Base input structure sent to all hooks + */ +export interface HookInputBase { + /** The event type that triggered this hook */ + event_type: HookEvent; + /** Working directory */ + working_directory: string; + /** Session ID if available */ + session_id?: string; +} + +/** + * Input for PreToolUse hooks + */ +export interface PreToolUseHookInput extends HookInputBase { + event_type: "PreToolUse"; + /** Name of the tool being used */ + tool_name: string; + /** Tool input arguments */ + tool_input: Record; + /** Tool call ID */ + tool_call_id?: string; +} + +/** + * Input for PostToolUse hooks + */ +export interface PostToolUseHookInput extends HookInputBase { + event_type: "PostToolUse"; + /** Name of the tool that was used */ + tool_name: string; + /** Tool input arguments */ + tool_input: Record; + /** Tool call ID */ + tool_call_id?: string; + /** Tool execution result */ + tool_result?: { + status: "success" | "error"; + output?: string; + }; +} + +/** + * Input for PermissionRequest hooks + */ +export interface PermissionRequestHookInput extends HookInputBase { + event_type: "PermissionRequest"; + /** Name of the tool requesting permission */ + tool_name: string; + /** Tool input arguments */ + tool_input: Record; + /** Permission being requested */ + permission: { + type: "allow" | "deny" | "ask"; + scope?: "session" | "project" | "user"; + }; +} + +/** + * Input for UserPromptSubmit hooks + */ +export interface UserPromptSubmitHookInput extends HookInputBase { + event_type: "UserPromptSubmit"; + /** The user's prompt text */ + prompt: string; + /** Whether this is a command (starts with /) */ + is_command: boolean; + /** Agent ID if available */ + agent_id?: string; + /** Conversation ID if available */ + conversation_id?: string; +} + +/** + * Input for Notification hooks + */ +export interface NotificationHookInput extends HookInputBase { + event_type: "Notification"; + /** Notification message */ + message: string; + /** Notification type/level */ + level?: "info" | "warning" | "error"; +} + +/** + * Input for Stop hooks + */ +export interface StopHookInput extends HookInputBase { + event_type: "Stop"; + /** Stop reason from the API */ + stop_reason: string; + /** Number of messages in the turn */ + message_count?: number; + /** Number of tool calls in the turn */ + tool_call_count?: number; +} + +/** + * Input for SubagentStop hooks + */ +export interface SubagentStopHookInput extends HookInputBase { + event_type: "SubagentStop"; + /** Subagent type */ + subagent_type: string; + /** Subagent ID */ + subagent_id: string; + /** Whether subagent succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Subagent's agent ID */ + agent_id?: string; + /** Subagent's conversation ID */ + conversation_id?: string; +} + +/** + * Input for PreCompact hooks + */ +export interface PreCompactHookInput extends HookInputBase { + event_type: "PreCompact"; + /** Current context length */ + context_length?: number; + /** Maximum context length */ + max_context_length?: number; + /** Agent ID */ + agent_id?: string; + /** Conversation ID */ + conversation_id?: string; +} + +/** + * Input for Setup hooks + */ +export interface SetupHookInput extends HookInputBase { + event_type: "Setup"; + /** Which init flag was used */ + init_type: "init" | "init-only" | "maintenance"; +} + +/** + * Input for SessionStart hooks + */ +export interface SessionStartHookInput extends HookInputBase { + event_type: "SessionStart"; + /** Whether this is a new session or resumed */ + is_new_session: boolean; + /** Agent ID */ + agent_id?: string; + /** Agent name */ + agent_name?: string; + /** Conversation ID */ + conversation_id?: string; +} + +/** + * Input for SessionEnd hooks + */ +export interface SessionEndHookInput extends HookInputBase { + event_type: "SessionEnd"; + /** Session duration in milliseconds */ + duration_ms?: number; + /** Total messages in session */ + message_count?: number; + /** Total tool calls in session */ + tool_call_count?: number; + /** Agent ID */ + agent_id?: string; + /** Conversation ID */ + conversation_id?: string; +} + +/** + * Union type for all hook inputs + */ +export type HookInput = + | PreToolUseHookInput + | PostToolUseHookInput + | PermissionRequestHookInput + | UserPromptSubmitHookInput + | NotificationHookInput + | StopHookInput + | SubagentStopHookInput + | PreCompactHookInput + | SetupHookInput + | SessionStartHookInput + | SessionEndHookInput; diff --git a/src/hooks/writer.ts b/src/hooks/writer.ts new file mode 100644 index 0000000..5eac385 --- /dev/null +++ b/src/hooks/writer.ts @@ -0,0 +1,243 @@ +// src/hooks/writer.ts +// Functions to write hooks to settings files via settings-manager + +import { settingsManager } from "../settings-manager"; +import { clearHooksCache } from "./loader"; +import type { HookEvent, HookMatcher, HooksConfig } from "./types"; + +/** + * Save location for hooks + */ +export type SaveLocation = "user" | "project" | "project-local"; + +/** + * Load hooks config from a specific location + */ +export function loadHooksFromLocation( + location: SaveLocation, + workingDirectory: string = process.cwd(), +): HooksConfig { + try { + switch (location) { + case "user": + return settingsManager.getSettings().hooks || {}; + case "project": + return ( + settingsManager.getProjectSettings(workingDirectory)?.hooks || {} + ); + case "project-local": + return ( + settingsManager.getLocalProjectSettings(workingDirectory)?.hooks || {} + ); + } + } catch { + // Settings not loaded yet, return empty + return {}; + } +} + +/** + * Save hooks config to a specific location + * Note: This is async because it may need to load settings first + */ +export async function saveHooksToLocation( + hooks: HooksConfig, + location: SaveLocation, + workingDirectory: string = process.cwd(), +): Promise { + // Ensure settings are loaded before updating + switch (location) { + case "user": + settingsManager.updateSettings({ hooks }); + break; + case "project": + // Load project settings if not already loaded + try { + settingsManager.getProjectSettings(workingDirectory); + } catch { + await settingsManager.loadProjectSettings(workingDirectory); + } + settingsManager.updateProjectSettings({ hooks }, workingDirectory); + break; + case "project-local": + // Load local project settings if not already loaded + try { + settingsManager.getLocalProjectSettings(workingDirectory); + } catch { + await settingsManager.loadLocalProjectSettings(workingDirectory); + } + settingsManager.updateLocalProjectSettings({ hooks }, workingDirectory); + break; + } + + // Clear cache so changes take effect immediately + clearHooksCache(); +} + +/** + * Add a new hook matcher to an event + */ +export async function addHookMatcher( + event: HookEvent, + matcher: HookMatcher, + location: SaveLocation, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = loadHooksFromLocation(location, workingDirectory); + + // Initialize event array if needed + if (!hooks[event]) { + hooks[event] = []; + } + + // Add the new matcher + const eventMatchers = hooks[event]; + if (eventMatchers) { + eventMatchers.push(matcher); + } + + await saveHooksToLocation(hooks, location, workingDirectory); +} + +/** + * Remove a hook matcher from an event by index + */ +export async function removeHookMatcher( + event: HookEvent, + matcherIndex: number, + location: SaveLocation, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = loadHooksFromLocation(location, workingDirectory); + const eventMatchers = hooks[event]; + + if ( + !eventMatchers || + matcherIndex < 0 || + matcherIndex >= eventMatchers.length + ) { + throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`); + } + + // Remove the matcher at the given index + eventMatchers.splice(matcherIndex, 1); + + // Clean up empty arrays + if (eventMatchers.length === 0) { + delete hooks[event]; + } + + await saveHooksToLocation(hooks, location, workingDirectory); +} + +/** + * Update a hook matcher at a specific index + */ +export async function updateHookMatcher( + event: HookEvent, + matcherIndex: number, + matcher: HookMatcher, + location: SaveLocation, + workingDirectory: string = process.cwd(), +): Promise { + const hooks = loadHooksFromLocation(location, workingDirectory); + const eventMatchers = hooks[event]; + + if ( + !eventMatchers || + matcherIndex < 0 || + matcherIndex >= eventMatchers.length + ) { + throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`); + } + + // Update the matcher at the given index + eventMatchers[matcherIndex] = matcher; + + await saveHooksToLocation(hooks, location, workingDirectory); +} + +/** + * Hook matcher with source tracking for display + */ +export interface HookMatcherWithSource extends HookMatcher { + source: SaveLocation; + sourceIndex: number; // Index within that source file +} + +/** + * Load all hooks for an event with source tracking + * Returns matchers tagged with their source location + */ +export function loadHooksWithSource( + event: HookEvent, + workingDirectory: string = process.cwd(), +): HookMatcherWithSource[] { + const result: HookMatcherWithSource[] = []; + + // Load from each location and tag with source + const locations: SaveLocation[] = ["project-local", "project", "user"]; + + for (const location of locations) { + const hooks = loadHooksFromLocation(location, workingDirectory); + const matchers = hooks[event] || []; + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (matcher) { + result.push({ + ...matcher, + source: location, + sourceIndex: i, + }); + } + } + } + + return result; +} + +/** + * Count total hooks across all events and locations + */ +export function countTotalHooks( + workingDirectory: string = process.cwd(), +): number { + let count = 0; + + const locations: SaveLocation[] = ["project-local", "project", "user"]; + + for (const location of locations) { + const hooks = loadHooksFromLocation(location, workingDirectory); + for (const event of Object.keys(hooks) as HookEvent[]) { + const matchers = hooks[event] || []; + for (const matcher of matchers) { + count += matcher.hooks.length; + } + } + } + + return count; +} + +/** + * Count hooks for a specific event across all locations + */ +export function countHooksForEvent( + event: HookEvent, + workingDirectory: string = process.cwd(), +): number { + let count = 0; + + const locations: SaveLocation[] = ["project-local", "project", "user"]; + + for (const location of locations) { + const hooks = loadHooksFromLocation(location, workingDirectory); + const matchers = hooks[event] || []; + for (const matcher of matchers) { + count += matcher.hooks.length; + } + } + + return count; +} diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index b90997e..fe8e704 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -2,6 +2,7 @@ // Main permission checking logic import { resolve } from "node:path"; +import { runPermissionRequestHooks } from "../hooks"; import { cliPermissions } from "./cli"; import { matchesBashPattern, @@ -473,3 +474,64 @@ function getDefaultDecision( // Everything else defaults to ask return "ask"; } + +/** + * Check permission for a tool execution with hook support. + * When the decision would be "ask" (show permission dialog), runs PermissionRequest hooks + * which can auto-allow (exit 0) or auto-deny (exit 2) without showing UI. + * + * @param toolName - Name of the tool + * @param toolArgs - Tool arguments + * @param permissions - Loaded permission rules + * @param workingDirectory - Current working directory + */ +export async function checkPermissionWithHooks( + toolName: string, + toolArgs: ToolArgs, + permissions: PermissionRules, + workingDirectory: string = process.cwd(), +): Promise { + // First, check permission using normal rules + const result = checkPermission( + toolName, + toolArgs, + permissions, + workingDirectory, + ); + + // If decision is "ask", run PermissionRequest hooks to see if they auto-allow/deny + if (result.decision === "ask") { + const hookResult = await runPermissionRequestHooks( + toolName, + toolArgs, + "ask", + undefined, + workingDirectory, + ); + + // If hook blocked (exit code 2), deny the permission + if (hookResult.blocked) { + const feedback = hookResult.feedback.join("\n") || "Denied by hook"; + return { + decision: "deny", + matchedRule: "PermissionRequest hook", + reason: feedback, + }; + } + + // If hook succeeded (exit code 0 from any hook), allow the permission + // Check if any hook ran and returned success + const anyHookAllowed = hookResult.results.some( + (r) => r.exitCode === 0 && !r.timedOut && !r.error, + ); + if (anyHookAllowed) { + return { + decision: "allow", + matchedRule: "PermissionRequest hook", + reason: "Allowed by hook", + }; + } + } + + return result; +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index dd3f9b6..b466584 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import type { HooksConfig } from "./hooks/types"; import type { PermissionRules } from "./permissions/types"; import { debugWarn } from "./utils/debug.js"; import { exists, mkdir, readFile, writeFile } from "./utils/fs.js"; @@ -35,6 +36,7 @@ export interface Settings { pinnedAgents?: string[]; // Array of agent IDs pinned globally createDefaultAgents?: boolean; // Create Memo/Incognito default agents on startup (default: true) permissions?: PermissionRules; + hooks?: HooksConfig; // Hook commands that run at various lifecycle points env?: Record; // Letta Cloud OAuth token management (stored separately in secrets) refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets @@ -54,12 +56,14 @@ export interface Settings { export interface ProjectSettings { localSharedBlockIds: Record; + hooks?: HooksConfig; // Project-specific hook commands (checked in) } export interface LocalProjectSettings { lastAgent: string | null; // DEPRECATED: kept for migration to lastSession lastSession?: SessionRef; // Current session (agent + conversation) permissions?: PermissionRules; + hooks?: HooksConfig; // Project-specific hook commands profiles?: Record; // DEPRECATED: old format, kept for migration pinnedAgents?: string[]; // Array of agent IDs pinned locally memoryReminderInterval?: number | null; // null = disabled, number = overrides global diff --git a/src/tests/hooks/e2e.test.ts b/src/tests/hooks/e2e.test.ts new file mode 100644 index 0000000..e2dc43b --- /dev/null +++ b/src/tests/hooks/e2e.test.ts @@ -0,0 +1,658 @@ +// src/tests/hooks/e2e.test.ts +// E2E tests that verify hooks are triggered during actual CLI operation + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const projectRoot = process.cwd(); + +// Skip on Windows - hooks executor uses `sh -c` which doesn't exist on Windows +const isWindows = process.platform === "win32"; + +interface TestEnv { + baseDir: string; + projectDir: string; + fakeHome: string; + markerFile: string; +} + +/** + * Create an isolated test environment with hooks config + */ +function setupTestEnv(): TestEnv { + const baseDir = join( + tmpdir(), + `hooks-e2e-${process.pid}-${Math.random().toString(36).slice(2)}`, + ); + const projectDir = join(baseDir, "project"); + const fakeHome = join(baseDir, "home"); + const markerFile = join(baseDir, "hook-marker.txt"); + + mkdirSync(join(projectDir, ".letta"), { recursive: true }); + mkdirSync(join(fakeHome, ".letta"), { recursive: true }); + + return { baseDir, projectDir, fakeHome, markerFile }; +} + +/** + * Clean up test environment + */ +function cleanup(env: TestEnv): void { + try { + rmSync(env.baseDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} + +/** + * Write hooks config to project settings + */ +function writeHooksConfig(env: TestEnv, hooks: Record): void { + writeFileSync( + join(env.projectDir, ".letta", "settings.json"), + JSON.stringify({ hooks }), + ); +} + +/** + * Run CLI with isolated environment and capture output + */ +async function runCli( + args: string[], + env: TestEnv, + options: { timeoutMs?: number } = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + const { timeoutMs = 120000 } = options; + + return new Promise((resolve, reject) => { + // Run bun with the entry point directly (not "run dev") so we can use + // a temp directory as cwd. This allows hooks to load from the temp dir. + const proc = spawn("bun", [join(projectRoot, "src/index.ts"), ...args], { + cwd: env.projectDir, + env: { + ...process.env, + HOME: env.fakeHome, + LETTA_CODE_AGENT_ROLE: "subagent", + // Skip keychain check since we're using a fake HOME directory + LETTA_SKIP_KEYCHAIN_CHECK: "1", + }, + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + const timeout = setTimeout(() => { + proc.kill(); + reject( + new Error( + `Timeout after ${timeoutMs}ms. stdout: ${stdout}, stderr: ${stderr}`, + ), + ); + }, timeoutMs); + + proc.on("close", (code) => { + clearTimeout(timeout); + resolve({ stdout, stderr, exitCode: code }); + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** + * Read marker file contents, return empty string if not exists + */ +function readMarker(env: TestEnv): string { + if (!existsSync(env.markerFile)) { + return ""; + } + return readFileSync(env.markerFile, "utf-8"); +} + +/** + * Check if LETTA_API_KEY is available for E2E tests + */ +function hasApiKey(): boolean { + return !!process.env.LETTA_API_KEY; +} + +// ============================================================================ +// E2E Tests - Require API key, skip gracefully if missing +// Skip on Windows - hooks use `sh -c` shell commands +// ============================================================================ + +describe.skipIf(isWindows)("Hooks E2E Tests", () => { + let env: TestEnv; + + beforeEach(() => { + env = setupTestEnv(); + }); + + afterEach(() => { + cleanup(env); + }); + + // ============================================================================ + // PreToolUse Hooks + // ============================================================================ + + describe("PreToolUse hooks", () => { + test( + "hook fires when Read tool is called", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + PreToolUse: [ + { + matcher: "Read", + hooks: [ + { + type: "command", + command: `echo "PreToolUse:Read" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli( + [ + "--new-agent", + "-m", + "haiku", + "--yolo", + "-p", + "Read the file /etc/hostname and tell me what it says. Do not ask for confirmation.", + ], + env, + ); + + const marker = readMarker(env); + expect(marker).toContain("PreToolUse:Read"); + }, + { timeout: 180000 }, + ); + + test( + "hook fires for any tool with wildcard matcher", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "PreToolUse:ANY" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli( + [ + "--new-agent", + "-m", + "haiku", + "--yolo", + "-p", + "Read the file /etc/hostname", + ], + env, + ); + + const marker = readMarker(env); + expect(marker).toContain("PreToolUse:ANY"); + }, + { timeout: 180000 }, + ); + + test( + "hook exit 2 blocks tool execution", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "BLOCKED_BY_HOOK" >> "${env.markerFile}" && echo "Hook blocked this tool" && exit 2`, + }, + ], + }, + ], + }); + + const result = await runCli( + [ + "--new-agent", + "-m", + "haiku", + "--yolo", + "-p", + "Read /etc/hostname", + "--output-format", + "json", + ], + env, + ); + + // Hook should have written to marker (proving it ran) + const marker = readMarker(env); + expect(marker).toContain("BLOCKED_BY_HOOK"); + + // Exit should be 0 (CLI handles blocked gracefully) + expect(result.exitCode).toBe(0); + }, + { timeout: 180000 }, + ); + + test( + "hook receives JSON input with tool_name", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + const inputFile = join(env.baseDir, "hook-input.json"); + + writeHooksConfig(env, { + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `cat > "${inputFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "--yolo", "-p", "Read /etc/hostname"], + env, + ); + + // Check hook received proper input + if (existsSync(inputFile)) { + const input = JSON.parse(readFileSync(inputFile, "utf-8")); + expect(input.event_type).toBe("PreToolUse"); + expect(input.tool_name).toBeDefined(); + expect(input.working_directory).toBeDefined(); + } + // If file doesn't exist, tool wasn't called (which is valid) + }, + { timeout: 180000 }, + ); + }); + + // ============================================================================ + // PostToolUse Hooks + // ============================================================================ + + describe("PostToolUse hooks", () => { + test( + "hook fires after tool execution", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + PostToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "PostToolUse:FIRED" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "--yolo", "-p", "Read /etc/hostname"], + env, + ); + + const marker = readMarker(env); + expect(marker).toContain("PostToolUse:FIRED"); + }, + { timeout: 180000 }, + ); + + test( + "hook receives tool_result in input", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + const inputFile = join(env.baseDir, "post-tool-input.json"); + + writeHooksConfig(env, { + PostToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `cat > "${inputFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "--yolo", "-p", "Read /etc/hostname"], + env, + ); + + if (existsSync(inputFile)) { + const input = JSON.parse(readFileSync(inputFile, "utf-8")); + expect(input.event_type).toBe("PostToolUse"); + expect(input.tool_name).toBeDefined(); + // PostToolUse should have tool_result + expect(input.tool_result).toBeDefined(); + } + }, + { timeout: 180000 }, + ); + }); + + // ============================================================================ + // SessionStart Hooks + // NOTE: SessionStart hooks only fire in interactive mode (App.tsx), not in + // headless mode (headless.ts). The -p flag runs in headless mode, so these + // tests verify the hook config is valid but the hooks won't actually fire. + // ============================================================================ + + describe("SessionStart hooks", () => { + test.skip( + "hook fires when CLI starts (SKIPPED: only works in interactive mode)", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + SessionStart: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "SessionStart:FIRED" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli(["--new-agent", "-m", "haiku", "-p", "Say OK"], env); + + const marker = readMarker(env); + expect(marker).toContain("SessionStart:FIRED"); + }, + { timeout: 180000 }, + ); + + test( + "hook receives session info in input", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + const inputFile = join(env.baseDir, "session-start-input.json"); + + writeHooksConfig(env, { + SessionStart: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `cat > "${inputFile}"`, + }, + ], + }, + ], + }); + + await runCli(["--new-agent", "-m", "haiku", "-p", "Say OK"], env); + + if (existsSync(inputFile)) { + const input = JSON.parse(readFileSync(inputFile, "utf-8")); + expect(input.event_type).toBe("SessionStart"); + expect(input.working_directory).toBeDefined(); + } + }, + { timeout: 180000 }, + ); + }); + + // ============================================================================ + // UserPromptSubmit Hooks + // NOTE: UserPromptSubmit hooks only fire in interactive mode (App.tsx), not in + // headless mode (headless.ts). The -p flag runs in headless mode, so these + // tests verify the hook config is valid but the hooks won't actually fire. + // ============================================================================ + + describe("UserPromptSubmit hooks", () => { + test.skip( + "hook fires before prompt processing (SKIPPED: only works in interactive mode)", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + UserPromptSubmit: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "UserPromptSubmit:FIRED" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "-p", "Say hello world"], + env, + ); + + const marker = readMarker(env); + expect(marker).toContain("UserPromptSubmit:FIRED"); + }, + { timeout: 180000 }, + ); + + test.skip( + "hook receives prompt text in input (SKIPPED: only works in interactive mode)", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + const inputFile = join(env.baseDir, "prompt-input.json"); + + writeHooksConfig(env, { + UserPromptSubmit: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `cat > "${inputFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "-p", "Test prompt message"], + env, + ); + + if (existsSync(inputFile)) { + const input = JSON.parse(readFileSync(inputFile, "utf-8")); + expect(input.event_type).toBe("UserPromptSubmit"); + expect(input.prompt).toBe("Test prompt message"); + } + }, + { timeout: 180000 }, + ); + + test.skip( + "hook exit 2 blocks prompt processing (SKIPPED: only works in interactive mode)", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + UserPromptSubmit: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "BLOCKED" >> "${env.markerFile}" && echo "Prompt blocked" && exit 2`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "-p", "This should be blocked"], + env, + ); + + // Hook ran and wrote marker + const marker = readMarker(env); + expect(marker).toContain("BLOCKED"); + + // The prompt was blocked - check for error output or non-zero exit + // (exact behavior depends on implementation) + }, + { timeout: 180000 }, + ); + }); + + // ============================================================================ + // Multiple Hooks + // NOTE: Only PreToolUse and PostToolUse work in headless mode. SessionStart + // and UserPromptSubmit only fire in interactive mode (App.tsx). + // ============================================================================ + + describe("Multiple hooks", () => { + test( + "PreToolUse and PostToolUse fire in correct order", + async () => { + if (!hasApiKey()) { + console.log("SKIP: Missing LETTA_API_KEY"); + return; + } + + writeHooksConfig(env, { + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "1:PreToolUse" >> "${env.markerFile}"`, + }, + ], + }, + ], + PostToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: `echo "2:PostToolUse" >> "${env.markerFile}"`, + }, + ], + }, + ], + }); + + await runCli( + ["--new-agent", "-m", "haiku", "--yolo", "-p", "Read /etc/hostname"], + env, + ); + + const marker = readMarker(env); + + // PreToolUse should fire before PostToolUse + expect(marker).toContain("1:PreToolUse"); + expect(marker).toContain("2:PostToolUse"); + + // Verify order: PreToolUse comes before PostToolUse + const preIndex = marker.indexOf("1:PreToolUse"); + const postIndex = marker.indexOf("2:PostToolUse"); + expect(preIndex).toBeLessThan(postIndex); + }, + { timeout: 180000 }, + ); + }); +}); diff --git a/src/tests/hooks/executor.test.ts b/src/tests/hooks/executor.test.ts new file mode 100644 index 0000000..4b08311 --- /dev/null +++ b/src/tests/hooks/executor.test.ts @@ -0,0 +1,539 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + executeHookCommand, + executeHooks, + executeHooksParallel, +} from "../../hooks/executor"; +import { + type HookCommand, + HookExitCode, + type PostToolUseHookInput, + type PreToolUseHookInput, + type SessionStartHookInput, + type StopHookInput, +} from "../../hooks/types"; + +// Skip on Windows - hooks executor uses `sh -c` which doesn't exist on Windows +const isWindows = process.platform === "win32"; + +describe.skipIf(isWindows)("Hooks Executor", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `hooks-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("executeHookCommand", () => { + test("executes simple echo command and returns output", async () => { + const hook: HookCommand = { + type: "command", + command: "echo 'hello world'", + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: { command: "ls" }, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + expect(result.stdout).toBe("hello world"); + expect(result.stderr).toBe(""); + expect(result.timedOut).toBe(false); + }); + + test("receives JSON input via stdin", async () => { + // Create a script that reads stdin and outputs it + const scriptPath = join(tempDir, "read-stdin.sh"); + writeFileSync(scriptPath, `#!/bin/bash\ncat`, { mode: 0o755 }); + + const hook: HookCommand = { + type: "command", + command: `${scriptPath}`, + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Edit", + tool_input: { file_path: "/test.txt" }, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + const parsedOutput = JSON.parse(result.stdout); + expect(parsedOutput.event_type).toBe("PreToolUse"); + expect(parsedOutput.tool_name).toBe("Edit"); + }); + + test("returns BLOCK (exit code 2) when command exits with 2", async () => { + const hook: HookCommand = { + type: "command", + command: "echo 'blocked' && exit 2", + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Write", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.BLOCK); + expect(result.stdout).toBe("blocked"); + }); + + test("returns ERROR (exit code 1) when command fails", async () => { + const hook: HookCommand = { + type: "command", + command: "echo 'error' >&2 && exit 1", + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ERROR); + expect(result.stderr).toBe("error"); + }); + + test("times out and returns ERROR", async () => { + const hook: HookCommand = { + type: "command", + command: "sleep 10", + timeout: 100, // 100ms timeout + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ERROR); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("timed out"); + }); + + test("receives environment variables", async () => { + const hook: HookCommand = { + type: "command", + command: "echo $LETTA_HOOK_EVENT", + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + expect(result.stdout).toBe("PreToolUse"); + }); + }); + + describe("executeHooks", () => { + test("executes multiple hooks sequentially", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "echo 'first'" }, + { type: "command", command: "echo 'second'" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHooks(hooks, input, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(false); + expect(result.results).toHaveLength(2); + expect(result.results[0]?.stdout).toBe("first"); + expect(result.results[1]?.stdout).toBe("second"); + }); + + test("stops on first blocking hook", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "echo 'allowed'" }, + { type: "command", command: "echo 'blocked' && exit 2" }, + { type: "command", command: "echo 'should not run'" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Write", + tool_input: {}, + }; + + const result = await executeHooks(hooks, input, tempDir); + + expect(result.blocked).toBe(true); + expect(result.results).toHaveLength(2); // Only first two ran + expect(result.feedback).toContain("blocked"); + }); + + test("continues after error but tracks it", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "echo 'error' >&2 && exit 1" }, + { type: "command", command: "echo 'continued'" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: {}, + }; + + const result = await executeHooks(hooks, input, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[0]?.exitCode).toBe(HookExitCode.ERROR); + expect(result.results[1]?.exitCode).toBe(HookExitCode.ALLOW); + }); + + test("returns empty result for empty hooks array", async () => { + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHooks([], input, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(false); + expect(result.results).toHaveLength(0); + }); + + test("collects feedback from blocking hooks", async () => { + const hooks: HookCommand[] = [ + { + type: "command", + command: "echo 'Reason: file is dangerous' && exit 2", + }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Write", + tool_input: { file_path: "/etc/passwd" }, + }; + + const result = await executeHooks(hooks, input, tempDir); + + expect(result.blocked).toBe(true); + expect(result.feedback).toContain("Reason: file is dangerous"); + }); + + test("collects error feedback from stderr", async () => { + const hooks: HookCommand[] = [ + { + type: "command", + command: "echo 'Configuration error' >&2 && exit 1", + }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Bash", + tool_input: {}, + }; + + const result = await executeHooks(hooks, input, tempDir); + + expect(result.errored).toBe(true); + expect( + result.feedback.some((f) => f.includes("Configuration error")), + ).toBe(true); + }); + }); + + describe("executeHooksParallel", () => { + test("executes multiple hooks in parallel", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "echo 'parallel-1'" }, + { type: "command", command: "echo 'parallel-2'" }, + { type: "command", command: "echo 'parallel-3'" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHooksParallel(hooks, input, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(false); + expect(result.results).toHaveLength(3); + }); + + test("aggregates results from all parallel hooks including errors", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "echo 'result-a'" }, + { type: "command", command: "echo 'error' >&2 && exit 1" }, + { type: "command", command: "echo 'blocked' && exit 2" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Write", + tool_input: {}, + }; + + const result = await executeHooksParallel(hooks, input, tempDir); + + expect(result.blocked).toBe(true); + expect(result.errored).toBe(true); + expect(result.results).toHaveLength(3); // All hooks ran (parallel doesn't stop early) + }); + + test("returns empty result for empty hooks array", async () => { + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHooksParallel([], input, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(false); + expect(result.feedback).toEqual([]); + expect(result.results).toEqual([]); + }); + + test("parallel execution is faster than sequential for slow hooks", async () => { + const hooks: HookCommand[] = [ + { type: "command", command: "sleep 0.1 && echo 'a'" }, + { type: "command", command: "sleep 0.1 && echo 'b'" }, + { type: "command", command: "sleep 0.1 && echo 'c'" }, + ]; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const startTime = Date.now(); + const result = await executeHooksParallel(hooks, input, tempDir); + const duration = Date.now() - startTime; + + expect(result.results).toHaveLength(3); + // Sequential would take ~300ms, parallel should be ~100ms + expect(duration).toBeLessThan(250); + }); + }); + + describe("Different hook input types", () => { + test("handles PostToolUse input with tool_result", async () => { + const hook: HookCommand = { type: "command", command: "cat" }; + + const input: PostToolUseHookInput = { + event_type: "PostToolUse", + working_directory: tempDir, + tool_name: "Write", + tool_input: { file_path: "/test.txt", content: "hello" }, + tool_result: { status: "success", output: "File written" }, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + const parsed = JSON.parse(result.stdout); + expect(parsed.event_type).toBe("PostToolUse"); + expect(parsed.tool_result.status).toBe("success"); + }); + + test("handles Stop input with stop_reason", async () => { + const hook: HookCommand = { type: "command", command: "cat" }; + + const input: StopHookInput = { + event_type: "Stop", + working_directory: tempDir, + stop_reason: "end_turn", + message_count: 5, + tool_call_count: 3, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + const parsed = JSON.parse(result.stdout); + expect(parsed.event_type).toBe("Stop"); + expect(parsed.stop_reason).toBe("end_turn"); + }); + + test("handles SessionStart input", async () => { + const hook: HookCommand = { type: "command", command: "cat" }; + + const input: SessionStartHookInput = { + event_type: "SessionStart", + working_directory: tempDir, + is_new_session: true, + agent_id: "agent-123", + agent_name: "Test Agent", + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + const parsed = JSON.parse(result.stdout); + expect(parsed.event_type).toBe("SessionStart"); + expect(parsed.is_new_session).toBe(true); + expect(parsed.agent_name).toBe("Test Agent"); + }); + }); + + describe("Edge cases", () => { + test("handles command that outputs very long output", async () => { + // Generate a command that outputs 10KB of data + const hook: HookCommand = { + type: "command", + command: + "for i in $(seq 1 1000); do echo 'line $i: some data here'; done", + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + expect(result.stdout.length).toBeGreaterThan(1000); + }); + + test("handles command with special characters in output", async () => { + const hook: HookCommand = { + type: "command", + command: `echo '{"special": "chars: \\n\\t\\r"}'`, + }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.exitCode).toBe(HookExitCode.ALLOW); + expect(result.stdout).toContain("special"); + }); + + test("tracks duration for fast commands", async () => { + const hook: HookCommand = { type: "command", command: "echo 'fast'" }; + + const input: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "Read", + tool_input: {}, + }; + + const result = await executeHookCommand(hook, input, tempDir); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(result.durationMs).toBeLessThan(1000); + }); + + test("handles hook script with complex JSON parsing", async () => { + const scriptPath = join(tempDir, "parse-json.sh"); + writeFileSync( + scriptPath, + `#!/bin/bash +input=$(cat) +tool_name=$(echo "$input" | grep -o '"tool_name":"[^"]*"' | cut -d'"' -f4) +if [ "$tool_name" = "DangerousTool" ]; then + echo "Blocked: $tool_name" + exit 2 +fi +echo "Allowed: $tool_name" +exit 0`, + { mode: 0o755 }, + ); + + const hook: HookCommand = { type: "command", command: scriptPath }; + + // Test allowed + const allowedInput: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "SafeTool", + tool_input: {}, + }; + const allowedResult = await executeHookCommand( + hook, + allowedInput, + tempDir, + ); + expect(allowedResult.exitCode).toBe(HookExitCode.ALLOW); + expect(allowedResult.stdout).toContain("Allowed: SafeTool"); + + // Test blocked + const blockedInput: PreToolUseHookInput = { + event_type: "PreToolUse", + working_directory: tempDir, + tool_name: "DangerousTool", + tool_input: {}, + }; + const blockedResult = await executeHookCommand( + hook, + blockedInput, + tempDir, + ); + expect(blockedResult.exitCode).toBe(HookExitCode.BLOCK); + expect(blockedResult.stdout).toContain("Blocked: DangerousTool"); + }); + }); +}); diff --git a/src/tests/hooks/integration.test.ts b/src/tests/hooks/integration.test.ts new file mode 100644 index 0000000..773730f --- /dev/null +++ b/src/tests/hooks/integration.test.ts @@ -0,0 +1,914 @@ +// src/tests/hooks/integration.test.ts +// Integration tests for all 11 hook types + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearHooksCache, + hasHooks, + runNotificationHooks, + runPermissionRequestHooks, + runPostToolUseHooks, + runPreCompactHooks, + runPreToolUseHooks, + runSessionEndHooks, + runSessionStartHooks, + runSetupHooks, + runStopHooks, + runSubagentStopHooks, + runUserPromptSubmitHooks, +} from "../../hooks"; + +// Skip on Windows - hooks executor uses `sh -c` which doesn't exist on Windows +const isWindows = process.platform === "win32"; + +describe.skipIf(isWindows)("Hooks Integration Tests", () => { + let tempDir: string; + let fakeHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + const baseDir = join( + tmpdir(), + `hooks-integration-${process.pid}-${Math.random().toString(36).slice(2)}`, + ); + // Create separate directories for HOME and project to avoid double-loading + fakeHome = join(baseDir, "home"); + tempDir = join(baseDir, "project"); + mkdirSync(fakeHome, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + // Override HOME to isolate from real global hooks + originalHome = process.env.HOME; + process.env.HOME = fakeHome; + clearHooksCache(); + }); + + afterEach(() => { + // Restore HOME + process.env.HOME = originalHome; + try { + // Clean up the parent directory + const baseDir = join(tempDir, ".."); + rmSync(baseDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + clearHooksCache(); + }); + + // Helper to create hook config + function createHooksConfig(hooks: Record) { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ hooks }), + ); + } + + // ============================================================================ + // PreToolUse Hooks + // ============================================================================ + + describe("PreToolUse hooks", () => { + test("allows tool execution (exit 0)", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "Write", + hooks: [{ type: "command", command: "echo 'allowed' && exit 0" }], + }, + ], + }); + + const result = await runPreToolUseHooks( + "Write", + { file_path: "/test.txt", content: "hello" }, + "tool-123", + tempDir, + ); + + expect(result.blocked).toBe(false); + expect(result.results[0]?.stdout).toBe("allowed"); + }); + + test("blocks tool execution (exit 2)", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "Write", + hooks: [ + { + type: "command", + command: "echo 'Blocked: write to sensitive file' && exit 2", + }, + ], + }, + ], + }); + + const result = await runPreToolUseHooks( + "Write", + { file_path: "/etc/passwd" }, + undefined, + tempDir, + ); + + expect(result.blocked).toBe(true); + expect(result.feedback).toContain("Blocked: write to sensitive file"); + }); + + test("matches by tool name pattern", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "Edit|Write", + hooks: [{ type: "command", command: "echo 'file operation'" }], + }, + ], + }); + + const editResult = await runPreToolUseHooks( + "Edit", + {}, + undefined, + tempDir, + ); + expect(editResult.results).toHaveLength(1); + + const writeResult = await runPreToolUseHooks( + "Write", + {}, + undefined, + tempDir, + ); + expect(writeResult.results).toHaveLength(1); + }); + + test("returns empty result when no hooks configured", async () => { + const result = await runPreToolUseHooks("Bash", {}, undefined, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(false); + expect(result.results).toHaveLength(0); + }); + }); + + // ============================================================================ + // PostToolUse Hooks + // ============================================================================ + + describe("PostToolUse hooks", () => { + test("runs after tool execution", async () => { + createHooksConfig({ + PostToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'post hook ran'" }], + }, + ], + }); + + const result = await runPostToolUseHooks( + "Write", + { file_path: "/test.txt" }, + { status: "success", output: "File written" }, + "tool-456", + tempDir, + ); + + expect(result.blocked).toBe(false); + expect(result.results[0]?.stdout).toBe("post hook ran"); + }); + + test("receives tool result in input", async () => { + createHooksConfig({ + PostToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runPostToolUseHooks( + "Bash", + { command: "ls" }, + { status: "success", output: "file1\nfile2" }, + undefined, + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.tool_result?.status).toBe("success"); + expect(parsed.tool_result?.output).toBe("file1\nfile2"); + }); + + test("runs hooks in parallel", async () => { + createHooksConfig({ + PostToolUse: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "sleep 0.1 && echo 'hook1'" }, + { type: "command", command: "sleep 0.1 && echo 'hook2'" }, + ], + }, + ], + }); + + const start = Date.now(); + const result = await runPostToolUseHooks( + "Read", + {}, + { status: "success" }, + undefined, + tempDir, + ); + const duration = Date.now() - start; + + expect(result.results).toHaveLength(2); + expect(duration).toBeLessThan(250); // Parallel should be ~100ms + }); + }); + + // ============================================================================ + // PermissionRequest Hooks + // ============================================================================ + + describe("PermissionRequest hooks", () => { + test("can auto-allow permission (exit 0)", async () => { + createHooksConfig({ + PermissionRequest: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "exit 0" }], + }, + ], + }); + + const result = await runPermissionRequestHooks( + "Bash", + { command: "ls" }, + "ask", + "session", + tempDir, + ); + + expect(result.blocked).toBe(false); + }); + + test("can auto-deny permission (exit 2)", async () => { + createHooksConfig({ + PermissionRequest: [ + { + matcher: "Bash", + hooks: [ + { + type: "command", + command: "echo 'Denied: dangerous command' && exit 2", + }, + ], + }, + ], + }); + + const result = await runPermissionRequestHooks( + "Bash", + { command: "rm -rf /" }, + "ask", + undefined, + tempDir, + ); + + expect(result.blocked).toBe(true); + expect(result.feedback).toContain("Denied: dangerous command"); + }); + + test("receives permission type and scope in input", async () => { + createHooksConfig({ + PermissionRequest: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runPermissionRequestHooks( + "Edit", + { file_path: "/config.json" }, + "allow", + "project", + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.permission?.type).toBe("allow"); + expect(parsed.permission?.scope).toBe("project"); + }); + }); + + // ============================================================================ + // UserPromptSubmit Hooks + // ============================================================================ + + describe("UserPromptSubmit hooks", () => { + test("runs before prompt is processed", async () => { + createHooksConfig({ + UserPromptSubmit: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'validating prompt'" }], + }, + ], + }); + + const result = await runUserPromptSubmitHooks( + "Help me write code", + false, + "agent-123", + "conv-456", + tempDir, + ); + + expect(result.blocked).toBe(false); + expect(result.results[0]?.stdout).toBe("validating prompt"); + }); + + test("can block prompt submission (exit 2)", async () => { + createHooksConfig({ + UserPromptSubmit: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "echo 'Blocked: contains sensitive info' && exit 2", + }, + ], + }, + ], + }); + + const result = await runUserPromptSubmitHooks( + "My password is secret123", + false, + undefined, + undefined, + tempDir, + ); + + expect(result.blocked).toBe(true); + }); + + test("receives prompt and command flag in input", async () => { + createHooksConfig({ + UserPromptSubmit: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runUserPromptSubmitHooks( + "/clear", + true, + undefined, + undefined, + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.prompt).toBe("/clear"); + expect(parsed.is_command).toBe(true); + }); + }); + + // ============================================================================ + // Notification Hooks + // ============================================================================ + + describe("Notification hooks", () => { + test("runs on notification", async () => { + createHooksConfig({ + Notification: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "echo 'notification received'" }, + ], + }, + ], + }); + + const result = await runNotificationHooks( + "Task completed", + "info", + tempDir, + ); + + expect(result.blocked).toBe(false); + expect(result.results[0]?.stdout).toBe("notification received"); + }); + + test("receives message and level in input", async () => { + createHooksConfig({ + Notification: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runNotificationHooks( + "Error occurred", + "error", + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.message).toBe("Error occurred"); + expect(parsed.level).toBe("error"); + }); + + test("runs hooks in parallel", async () => { + createHooksConfig({ + Notification: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "sleep 0.1 && echo 'n1'" }, + { type: "command", command: "sleep 0.1 && echo 'n2'" }, + ], + }, + ], + }); + + const start = Date.now(); + const result = await runNotificationHooks("test", "info", tempDir); + const duration = Date.now() - start; + + expect(result.results).toHaveLength(2); + expect(duration).toBeLessThan(250); + }); + }); + + // ============================================================================ + // Stop Hooks + // ============================================================================ + + describe("Stop hooks", () => { + test("runs when Claude finishes responding", async () => { + createHooksConfig({ + Stop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'turn complete'" }], + }, + ], + }); + + const result = await runStopHooks("end_turn", 5, 3, tempDir); + + expect(result.results[0]?.stdout).toBe("turn complete"); + }); + + test("receives stop_reason and counts in input", async () => { + createHooksConfig({ + Stop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runStopHooks("max_tokens", 10, 7, tempDir); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.stop_reason).toBe("max_tokens"); + expect(parsed.message_count).toBe(10); + expect(parsed.tool_call_count).toBe(7); + }); + }); + + // ============================================================================ + // SubagentStop Hooks + // ============================================================================ + + describe("SubagentStop hooks", () => { + test("runs when subagent completes", async () => { + createHooksConfig({ + SubagentStop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'subagent done'" }], + }, + ], + }); + + const result = await runSubagentStopHooks( + "explore", + "subagent-123", + true, + undefined, + "agent-456", + "conv-789", + tempDir, + ); + + expect(result.results[0]?.stdout).toBe("subagent done"); + }); + + test("receives subagent info in input", async () => { + createHooksConfig({ + SubagentStop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runSubagentStopHooks( + "plan", + "subagent-abc", + false, + "Task failed", + undefined, + undefined, + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.subagent_type).toBe("plan"); + expect(parsed.subagent_id).toBe("subagent-abc"); + expect(parsed.success).toBe(false); + expect(parsed.error).toBe("Task failed"); + }); + }); + + // ============================================================================ + // PreCompact Hooks + // ============================================================================ + + describe("PreCompact hooks", () => { + test("runs before compact operation", async () => { + createHooksConfig({ + PreCompact: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "echo 'preparing to compact'" }, + ], + }, + ], + }); + + const result = await runPreCompactHooks( + 50000, + 100000, + "agent-123", + "conv-456", + tempDir, + ); + + expect(result.results[0]?.stdout).toBe("preparing to compact"); + }); + + test("can block compact operation (exit 2)", async () => { + createHooksConfig({ + PreCompact: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "echo 'Cannot compact now' && exit 2", + }, + ], + }, + ], + }); + + const result = await runPreCompactHooks( + 10000, + 100000, + undefined, + undefined, + tempDir, + ); + + expect(result.blocked).toBe(true); + expect(result.feedback).toContain("Cannot compact now"); + }); + + test("receives context info in input", async () => { + createHooksConfig({ + PreCompact: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runPreCompactHooks( + 75000, + 100000, + undefined, + undefined, + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.context_length).toBe(75000); + expect(parsed.max_context_length).toBe(100000); + }); + }); + + // ============================================================================ + // Setup Hooks + // ============================================================================ + + describe("Setup hooks", () => { + test("runs on init", async () => { + createHooksConfig({ + Setup: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'initializing'" }], + }, + ], + }); + + const result = await runSetupHooks("init", tempDir); + + expect(result.results[0]?.stdout).toBe("initializing"); + }); + + test("runs on maintenance", async () => { + createHooksConfig({ + Setup: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'maintenance mode'" }], + }, + ], + }); + + const result = await runSetupHooks("maintenance", tempDir); + + expect(result.results[0]?.stdout).toBe("maintenance mode"); + }); + + test("receives init_type in input", async () => { + createHooksConfig({ + Setup: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runSetupHooks("init-only", tempDir); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.init_type).toBe("init-only"); + }); + }); + + // ============================================================================ + // SessionStart Hooks + // ============================================================================ + + describe("SessionStart hooks", () => { + test("runs when session starts", async () => { + createHooksConfig({ + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'session started'" }], + }, + ], + }); + + const result = await runSessionStartHooks( + true, + "agent-123", + "Test Agent", + "conv-456", + tempDir, + ); + + expect(result.results[0]?.stdout).toBe("session started"); + }); + + test("receives session info in input", async () => { + createHooksConfig({ + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runSessionStartHooks( + false, + "agent-abc", + "My Agent", + "conv-xyz", + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.is_new_session).toBe(false); + expect(parsed.agent_id).toBe("agent-abc"); + expect(parsed.agent_name).toBe("My Agent"); + }); + }); + + // ============================================================================ + // SessionEnd Hooks + // ============================================================================ + + describe("SessionEnd hooks", () => { + test("runs when session ends", async () => { + createHooksConfig({ + SessionEnd: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'session ended'" }], + }, + ], + }); + + const result = await runSessionEndHooks( + 60000, + 10, + 5, + "agent-123", + "conv-456", + tempDir, + ); + + expect(result.results[0]?.stdout).toBe("session ended"); + }); + + test("receives session stats in input", async () => { + createHooksConfig({ + SessionEnd: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runSessionEndHooks( + 120000, + 25, + 12, + undefined, + undefined, + tempDir, + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.duration_ms).toBe(120000); + expect(parsed.message_count).toBe(25); + expect(parsed.tool_call_count).toBe(12); + }); + + test("runs hooks in parallel (fire and forget)", async () => { + createHooksConfig({ + SessionEnd: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "sleep 0.1 && echo 'e1'" }, + { type: "command", command: "sleep 0.1 && echo 'e2'" }, + ], + }, + ], + }); + + const start = Date.now(); + const result = await runSessionEndHooks( + 1000, + 1, + 1, + undefined, + undefined, + tempDir, + ); + const duration = Date.now() - start; + + expect(result.results).toHaveLength(2); + expect(duration).toBeLessThan(250); + }); + }); + + // ============================================================================ + // hasHooks Tests + // ============================================================================ + + describe("hasHooks helper", () => { + test("returns true when hooks exist", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }); + + const result = await hasHooks("PreToolUse", tempDir); + expect(result).toBe(true); + }); + + test("returns false when no hooks exist", async () => { + createHooksConfig({}); + + const result = await hasHooks("PreToolUse", tempDir); + expect(result).toBe(false); + }); + }); + + // ============================================================================ + // Complex Scenarios + // ============================================================================ + + describe("Complex scenarios", () => { + test("multiple hooks for same event all run", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo 'bash specific'" }], + }, + { + matcher: "*", + hooks: [{ type: "command", command: "echo 'all tools'" }], + }, + ], + }); + + const result = await runPreToolUseHooks("Bash", {}, undefined, tempDir); + + expect(result.results).toHaveLength(2); + expect(result.results[0]?.stdout).toBe("bash specific"); + expect(result.results[1]?.stdout).toBe("all tools"); + }); + + test("first blocking hook stops subsequent hooks (sequential)", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "echo 'check 1'" }, + { type: "command", command: "echo 'BLOCKED' && exit 2" }, + { type: "command", command: "echo 'should not run'" }, + ], + }, + ], + }); + + const result = await runPreToolUseHooks("Write", {}, undefined, tempDir); + + expect(result.blocked).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.feedback).toContain("BLOCKED"); + }); + + test("error hooks do not block subsequent hooks", async () => { + createHooksConfig({ + PreToolUse: [ + { + matcher: "*", + hooks: [ + { type: "command", command: "echo 'error' >&2 && exit 1" }, + { type: "command", command: "echo 'continued'" }, + ], + }, + ], + }); + + const result = await runPreToolUseHooks("Read", {}, undefined, tempDir); + + expect(result.blocked).toBe(false); + expect(result.errored).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[1]?.stdout).toBe("continued"); + }); + }); +}); diff --git a/src/tests/hooks/loader.test.ts b/src/tests/hooks/loader.test.ts new file mode 100644 index 0000000..61762a9 --- /dev/null +++ b/src/tests/hooks/loader.test.ts @@ -0,0 +1,758 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearHooksCache, + getHooksForEvent, + getMatchingHooks, + hasHooksForEvent, + loadHooks, + loadProjectHooks, + loadProjectLocalHooks, + matchesTool, + mergeHooksConfigs, +} from "../../hooks/loader"; +import type { HookEvent, HooksConfig } from "../../hooks/types"; + +describe("Hooks Loader", () => { + let tempDir: string; + let fakeHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + const baseDir = join(tmpdir(), `hooks-loader-test-${Date.now()}`); + // Create separate directories for HOME and project to avoid double-loading + fakeHome = join(baseDir, "home"); + tempDir = join(baseDir, "project"); + mkdirSync(fakeHome, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + // Override HOME to isolate from real global hooks + originalHome = process.env.HOME; + process.env.HOME = fakeHome; + clearHooksCache(); + }); + + afterEach(() => { + // Restore HOME + process.env.HOME = originalHome; + try { + // Clean up the parent directory + const baseDir = join(tempDir, ".."); + rmSync(baseDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + clearHooksCache(); + }); + + describe("loadProjectHooks", () => { + test("returns empty config when no settings file exists", async () => { + const hooks = await loadProjectHooks(tempDir); + expect(hooks).toEqual({}); + }); + + test("loads hooks from .letta/settings.json", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }; + + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify(settings), + ); + + const hooks = await loadProjectHooks(tempDir); + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PreToolUse?.[0]?.matcher).toBe("Bash"); + }); + + test("caches loaded hooks", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo cached" }], + }, + ], + }, + }; + + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify(settings), + ); + + const hooks1 = await loadProjectHooks(tempDir); + const hooks2 = await loadProjectHooks(tempDir); + + // Should return same object from cache + expect(hooks1).toBe(hooks2); + }); + }); + + describe("mergeHooksConfigs", () => { + test("merges global and project configs", () => { + const global: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "global hook" }], + }, + ], + }; + + const project: HooksConfig = { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "project hook" }], + }, + ], + PostToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "post hook" }], + }, + ], + }; + + const merged = mergeHooksConfigs(global, project); + + // Project hooks come first + expect(merged.PreToolUse).toHaveLength(2); + expect(merged.PreToolUse?.[0]?.matcher).toBe("Bash"); // project first + expect(merged.PreToolUse?.[1]?.matcher).toBe("*"); // global second + + // PostToolUse only in project + expect(merged.PostToolUse).toHaveLength(1); + }); + + test("handles empty configs", () => { + const global: HooksConfig = {}; + const project: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "test" }], + }, + ], + }; + + const merged = mergeHooksConfigs(global, project); + expect(merged.PreToolUse).toHaveLength(1); + }); + }); + + describe("matchesTool", () => { + test("wildcard matches all tools", () => { + expect(matchesTool("*", "Bash")).toBe(true); + expect(matchesTool("*", "Edit")).toBe(true); + expect(matchesTool("*", "Write")).toBe(true); + }); + + test("empty string matches all tools", () => { + expect(matchesTool("", "Bash")).toBe(true); + expect(matchesTool("", "Read")).toBe(true); + }); + + test("exact match works", () => { + expect(matchesTool("Bash", "Bash")).toBe(true); + expect(matchesTool("Bash", "Edit")).toBe(false); + }); + + test("pipe-separated list matches any", () => { + expect(matchesTool("Edit|Write", "Edit")).toBe(true); + expect(matchesTool("Edit|Write", "Write")).toBe(true); + expect(matchesTool("Edit|Write", "Bash")).toBe(false); + expect(matchesTool("Edit|Write|Read", "Read")).toBe(true); + }); + }); + + describe("getMatchingHooks", () => { + test("returns hooks for matching tool", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash hook" }], + }, + { + matcher: "Edit", + hooks: [{ type: "command", command: "edit hook" }], + }, + ], + }; + + const bashHooks = getMatchingHooks(config, "PreToolUse", "Bash"); + expect(bashHooks).toHaveLength(1); + expect(bashHooks[0]?.command).toBe("bash hook"); + + const editHooks = getMatchingHooks(config, "PreToolUse", "Edit"); + expect(editHooks).toHaveLength(1); + expect(editHooks[0]?.command).toBe("edit hook"); + }); + + test("returns wildcard hooks for any tool", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "all tools hook" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "PreToolUse", "AnyTool"); + expect(hooks).toHaveLength(1); + expect(hooks[0]?.command).toBe("all tools hook"); + }); + + test("returns multiple matching hooks", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "global hook" }], + }, + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash specific" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "PreToolUse", "Bash"); + expect(hooks).toHaveLength(2); + }); + + test("returns empty array for non-matching event", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "test" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "PostToolUse", "Bash"); + expect(hooks).toHaveLength(0); + }); + + test("returns empty array for non-matching tool", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "Edit", + hooks: [{ type: "command", command: "edit only" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "PreToolUse", "Bash"); + expect(hooks).toHaveLength(0); + }); + + test("handles undefined tool name (for non-tool events)", () => { + const config: HooksConfig = { + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "session hook" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "SessionStart", undefined); + expect(hooks).toHaveLength(1); + }); + + test("returns hooks from multiple matchers in order", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "Bash|Edit", + hooks: [{ type: "command", command: "multi tool" }], + }, + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash specific" }], + }, + { + matcher: "*", + hooks: [{ type: "command", command: "wildcard" }], + }, + ], + }; + + const hooks = getMatchingHooks(config, "PreToolUse", "Bash"); + expect(hooks).toHaveLength(3); + expect(hooks[0]?.command).toBe("multi tool"); + expect(hooks[1]?.command).toBe("bash specific"); + expect(hooks[2]?.command).toBe("wildcard"); + }); + }); + + describe("hasHooksForEvent", () => { + test("returns true when hooks exist for event", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "test" }], + }, + ], + }; + + expect(hasHooksForEvent(config, "PreToolUse")).toBe(true); + }); + + test("returns false when no hooks for event", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "test" }], + }, + ], + }; + + expect(hasHooksForEvent(config, "PostToolUse")).toBe(false); + }); + + test("returns false for empty matchers array", () => { + const config: HooksConfig = { + PreToolUse: [], + }; + + expect(hasHooksForEvent(config, "PreToolUse")).toBe(false); + }); + + test("returns false for matcher with empty hooks", () => { + const config: HooksConfig = { + PreToolUse: [ + { + matcher: "*", + hooks: [], + }, + ], + }; + + expect(hasHooksForEvent(config, "PreToolUse")).toBe(false); + }); + + test("returns true if any matcher has hooks", () => { + const config: HooksConfig = { + PreToolUse: [ + { matcher: "Bash", hooks: [] }, + { matcher: "Edit", hooks: [{ type: "command", command: "test" }] }, + ], + }; + + expect(hasHooksForEvent(config, "PreToolUse")).toBe(true); + }); + }); + + describe("getHooksForEvent", () => { + test("loads and returns matching hooks", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash hook" }], + }, + ], + }, + }; + + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify(settings), + ); + + const hooks = await getHooksForEvent("PreToolUse", "Bash", tempDir); + expect(hooks).toHaveLength(1); + expect(hooks[0]?.command).toBe("bash hook"); + }); + + test("returns empty for non-matching tool", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash hook" }], + }, + ], + }, + }; + + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify(settings), + ); + + const hooks = await getHooksForEvent("PreToolUse", "Edit", tempDir); + expect(hooks).toHaveLength(0); + }); + }); + + describe("All 11 hook events", () => { + const allEvents: HookEvent[] = [ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "UserPromptSubmit", + "Notification", + "Stop", + "SubagentStop", + "PreCompact", + "Setup", + "SessionStart", + "SessionEnd", + ]; + + test("config can have all 11 event types", () => { + const config: HooksConfig = {}; + for (const event of allEvents) { + config[event] = [ + { + matcher: "*", + hooks: [{ type: "command", command: `echo ${event}` }], + }, + ]; + } + + for (const event of allEvents) { + expect(hasHooksForEvent(config, event)).toBe(true); + const hooks = getMatchingHooks(config, event); + expect(hooks).toHaveLength(1); + } + }); + + test("merging preserves all event types", () => { + const global: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "g1" }] }, + ], + SessionStart: [ + { matcher: "*", hooks: [{ type: "command", command: "g2" }] }, + ], + }; + + const project: HooksConfig = { + PostToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "p1" }] }, + ], + SessionEnd: [ + { matcher: "*", hooks: [{ type: "command", command: "p2" }] }, + ], + }; + + const merged = mergeHooksConfigs(global, project); + + expect(merged.PreToolUse).toHaveLength(1); + expect(merged.PostToolUse).toHaveLength(1); + expect(merged.SessionStart).toHaveLength(1); + expect(merged.SessionEnd).toHaveLength(1); + }); + }); + + describe("Edge cases", () => { + test("handles malformed JSON gracefully", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync(join(settingsDir, "settings.json"), "{ invalid json }"); + + // Should not throw, returns empty config + const hooks = await loadProjectHooks(tempDir); + expect(hooks).toEqual({}); + }); + + test("handles settings without hooks field", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ someOtherSetting: true }), + ); + + const hooks = await loadProjectHooks(tempDir); + expect(hooks).toEqual({}); + }); + + test("clearHooksCache resets cache", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "v1" }] }, + ], + }, + }), + ); + + const hooks1 = await loadProjectHooks(tempDir); + expect(hooks1.PreToolUse?.[0]?.hooks[0]?.command).toBe("v1"); + + // Update the file + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "v2" }] }, + ], + }, + }), + ); + + // Without clearing cache, should still return v1 + const hooks2 = await loadProjectHooks(tempDir); + expect(hooks2.PreToolUse?.[0]?.hooks[0]?.command).toBe("v1"); + + // After clearing cache, should return v2 + clearHooksCache(); + const hooks3 = await loadProjectHooks(tempDir); + expect(hooks3.PreToolUse?.[0]?.hooks[0]?.command).toBe("v2"); + }); + }); + + // ============================================================================ + // Project-Local Hooks Tests (settings.local.json) + // ============================================================================ + + describe("loadProjectLocalHooks", () => { + test("returns empty config when no local settings file exists", async () => { + const hooks = await loadProjectLocalHooks(tempDir); + expect(hooks).toEqual({}); + }); + + test("loads hooks from .letta/settings.local.json", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo local" }], + }, + ], + }, + }; + + writeFileSync( + join(settingsDir, "settings.local.json"), + JSON.stringify(settings), + ); + + const hooks = await loadProjectLocalHooks(tempDir); + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("echo local"); + }); + + test("caches loaded local hooks", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + writeFileSync( + join(settingsDir, "settings.local.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "cached" }] }, + ], + }, + }), + ); + + const hooks1 = await loadProjectLocalHooks(tempDir); + const hooks2 = await loadProjectLocalHooks(tempDir); + + // Should return same object from cache + expect(hooks1).toBe(hooks2); + }); + }); + + describe("Merged hooks priority (local > project > global)", () => { + test("project-local hooks run before project hooks", () => { + const global: HooksConfig = {}; + const project: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "project" }] }, + ], + }; + const projectLocal: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "local" }] }, + ], + }; + + const merged = mergeHooksConfigs(global, project, projectLocal); + + expect(merged.PreToolUse).toHaveLength(2); + expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first + expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project"); // Project second + }); + + test("project-local hooks run before global hooks", () => { + const global: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "global" }] }, + ], + }; + const project: HooksConfig = {}; + const projectLocal: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "local" }] }, + ], + }; + + const merged = mergeHooksConfigs(global, project, projectLocal); + + expect(merged.PreToolUse).toHaveLength(2); + expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first + expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("global"); // Global last + }); + + test("all three levels merge correctly", () => { + const global: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "global" }] }, + ], + SessionEnd: [ + { matcher: "*", hooks: [{ type: "command", command: "global-end" }] }, + ], + }; + const project: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "project" }] }, + ], + PostToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "project-post" }], + }, + ], + }; + const projectLocal: HooksConfig = { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "local" }] }, + ], + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "local-start" }], + }, + ], + }; + + const merged = mergeHooksConfigs(global, project, projectLocal); + + // PreToolUse: local -> project -> global + expect(merged.PreToolUse).toHaveLength(3); + expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); + expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project"); + expect(merged.PreToolUse?.[2]?.hooks[0]?.command).toBe("global"); + + // Others only have one source + expect(merged.PostToolUse).toHaveLength(1); + expect(merged.SessionStart).toHaveLength(1); + expect(merged.SessionEnd).toHaveLength(1); + }); + }); + + describe("loadHooks (full merge)", () => { + test("loads and merges all three config sources", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + // Create project settings + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "project" }], + }, + ], + }, + }), + ); + + // Create project-local settings + writeFileSync( + join(settingsDir, "settings.local.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "local" }] }, + ], + }, + }), + ); + + const hooks = await loadHooks(tempDir); + + // Local should come before project + expect(hooks.PreToolUse).toHaveLength(2); + expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); + expect(hooks.PreToolUse?.[1]?.hooks[0]?.command).toBe("project"); + }); + + test("handles missing local settings gracefully", async () => { + const settingsDir = join(tempDir, ".letta"); + mkdirSync(settingsDir, { recursive: true }); + + // Only create project settings (no local) + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "project" }], + }, + ], + }, + }), + ); + + const hooks = await loadHooks(tempDir); + + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("project"); + }); + }); +}); diff --git a/src/tests/settings-manager.test.ts b/src/tests/settings-manager.test.ts index 5c93e5f..bc74bd0 100644 --- a/src/tests/settings-manager.test.ts +++ b/src/tests/settings-manager.test.ts @@ -505,6 +505,207 @@ describe("Settings Manager - Reset", () => { }); }); +// ============================================================================ +// Hooks Configuration Tests +// ============================================================================ + +describe("Settings Manager - Hooks", () => { + beforeEach(async () => { + await settingsManager.initialize(); + }); + + test("Update hooks configuration in global settings", async () => { + settingsManager.updateSettings({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }); + + const settings = settingsManager.getSettings(); + expect(settings.hooks).toBeDefined(); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + expect(settings.hooks?.PreToolUse?.[0]?.matcher).toBe("Bash"); + }); + + test("Hooks configuration persists to disk", async () => { + settingsManager.updateSettings({ + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo persisted" }], + }, + ], + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo session" }], + }, + ], + }, + }); + + // Wait for async persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset and reload + await settingsManager.reset(); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + expect(settings.hooks?.PreToolUse?.[0]?.hooks[0]?.command).toBe( + "echo persisted", + ); + expect(settings.hooks?.SessionStart).toHaveLength(1); + }); + + test("Update hooks in local project settings with patterns", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { + hooks: { + PostToolUse: [ + { + matcher: "Write|Edit", + hooks: [{ type: "command", command: "echo post-tool" }], + }, + ], + }, + }, + testProjectDir, + ); + + const localSettings = + settingsManager.getLocalProjectSettings(testProjectDir); + expect(localSettings.hooks?.PostToolUse).toHaveLength(1); + expect(localSettings.hooks?.PostToolUse?.[0]?.matcher).toBe("Write|Edit"); + }); + + test("Update hooks in local project settings", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { + hooks: { + UserPromptSubmit: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo local-hook" }], + }, + ], + }, + }, + testProjectDir, + ); + + const localSettings = + settingsManager.getLocalProjectSettings(testProjectDir); + expect(localSettings.hooks?.UserPromptSubmit).toHaveLength(1); + }); + + test("Local project hooks persist to disk", async () => { + await settingsManager.loadLocalProjectSettings(testProjectDir); + + settingsManager.updateLocalProjectSettings( + { + hooks: { + Stop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo stop-hook" }], + }, + ], + }, + }, + testProjectDir, + ); + + // Wait for persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Clear cache and reload + await settingsManager.reset(); + await settingsManager.initialize(); + const reloaded = + await settingsManager.loadLocalProjectSettings(testProjectDir); + + expect(reloaded.hooks?.Stop).toHaveLength(1); + expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook"); + }); + + test("All 11 hook event types can be configured", async () => { + const allHookEvents = [ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "UserPromptSubmit", + "Notification", + "Stop", + "SubagentStop", + "PreCompact", + "Setup", + "SessionStart", + "SessionEnd", + ] as const; + + const hooksConfig: Record = {}; + for (const event of allHookEvents) { + hooksConfig[event] = [ + { + matcher: "*", + hooks: [{ type: "command", command: `echo ${event}` }], + }, + ]; + } + + settingsManager.updateSettings({ + hooks: hooksConfig as never, + }); + + const settings = settingsManager.getSettings(); + for (const event of allHookEvents) { + expect(settings.hooks?.[event]).toHaveLength(1); + } + }); + + test("Partial hooks update preserves other hooks", async () => { + settingsManager.updateSettings({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "echo pre" }] }, + ], + PostToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "echo post" }] }, + ], + }, + }); + + // Update only PreToolUse + settingsManager.updateSettings({ + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo updated" }], + }, + ], + }, + }); + + const settings = settingsManager.getSettings(); + // PreToolUse should be updated (replaced) + expect(settings.hooks?.PreToolUse?.[0]?.matcher).toBe("Bash"); + // Note: This test documents current behavior - hooks object is replaced entirely + }); +}); + // ============================================================================ // Edge Cases and Error Handling // ============================================================================ diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index 74966b7..5e4095e 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -16,6 +16,7 @@ import { generateSubagentId, registerSubagent, } from "../../cli/helpers/subagentState.js"; +import { runSubagentStopHooks } from "../../hooks"; import { LIMITS, truncateByChars } from "./truncation.js"; import { validateRequiredParams } from "./validation"; @@ -127,6 +128,18 @@ export async function task(args: TaskArgs): Promise { totalTokens: result.totalTokens, }); + // Run SubagentStop hooks (fire-and-forget) + runSubagentStopHooks( + subagent_type, + subagentId, + result.success, + result.error, + result.agentId, + result.conversationId, + ).catch(() => { + // Silently ignore hook errors + }); + if (!result.success) { return `Error: ${result.error || "Subagent execution failed"}`; } @@ -158,6 +171,19 @@ export async function task(args: TaskArgs): Promise { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); completeSubagent(subagentId, { success: false, error: errorMessage }); + + // Run SubagentStop hooks for error case (fire-and-forget) + runSubagentStopHooks( + subagent_type, + subagentId, + false, + errorMessage, + args.agent_id, + args.conversation_id, + ).catch(() => { + // Silently ignore hook errors + }); + return `Error: ${errorMessage}`; } } diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 99df4fc..3764712 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -1,6 +1,8 @@ +import { getDisplayableToolReturn } from "../agent/approval-execution"; import { getModelInfo } from "../agent/model"; import { getAllSubagentConfigs } from "../agent/subagents"; import { INTERRUPTED_BY_USER } from "../constants"; +import { runPostToolUseHooks, runPreToolUseHooks } from "../hooks"; import { telemetry } from "../telemetry"; import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions"; @@ -755,6 +757,20 @@ export async function executeTool( const startTime = Date.now(); + // Run PreToolUse hooks - can block tool execution + const preHookResult = await runPreToolUseHooks( + internalName, + args as Record, + options?.toolCallId, + ); + if (preHookResult.blocked) { + const feedback = preHookResult.feedback.join("\n") || "Blocked by hook"; + return { + toolReturn: `Error: Tool execution blocked by hook. ${feedback}`, + status: "error", + }; + } + try { // Inject options for tools that support them without altering schemas let enhancedArgs = args; @@ -808,6 +824,19 @@ export async function executeTool( stderr ? stderr.join("\n") : undefined, ); + // Run PostToolUse hooks (async, non-blocking) + runPostToolUseHooks( + internalName, + args as Record, + { + status: toolStatus, + output: getDisplayableToolReturn(flattenedResponse), + }, + options?.toolCallId, + ).catch(() => { + // Silently ignore hook errors - don't affect tool execution + }); + // Return the full response (truncation happens in UI layer only) return { toolReturn: flattenedResponse, @@ -844,6 +873,16 @@ export async function executeTool( errorMessage, ); + // Run PostToolUse hooks for error case (async, non-blocking) + runPostToolUseHooks( + internalName, + args as Record, + { status: "error", output: errorMessage }, + options?.toolCallId, + ).catch(() => { + // Silently ignore hook errors + }); + // Don't console.error here - it pollutes the TUI // The error message is already returned in toolReturn return { diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts index e0c221c..5d8a5e9 100644 --- a/src/utils/secrets.ts +++ b/src/utils/secrets.ts @@ -198,8 +198,14 @@ export async function deleteSecureTokens(): Promise { /** * Check if secrets API is available + * Set LETTA_SKIP_KEYCHAIN_CHECK=1 to skip the check (useful in CI/test environments) */ export async function isKeychainAvailable(): Promise { + // Skip keychain check in test/CI environments to avoid error dialogs + if (process.env.LETTA_SKIP_KEYCHAIN_CHECK === "1") { + return false; + } + if (!secretsAvailable) { return false; }