feat: add stop hook continuation on blocking and example hooks (#657)
This commit is contained in:
21
hooks/fix-on-changes.sh
Executable file
21
hooks/fix-on-changes.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Hook script: Run bun run fix if there are uncommitted changes
|
||||||
|
# Triggered on: Stop event
|
||||||
|
|
||||||
|
# Check if there are any uncommitted changes (staged or unstaged)
|
||||||
|
if git diff --quiet HEAD 2>/dev/null; then
|
||||||
|
echo "No changes, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run fix - capture output and send to stderr on failure
|
||||||
|
output=$(bun run fix 2>&1)
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "$output"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "$output" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
21
hooks/typecheck-on-changes.sh
Executable file
21
hooks/typecheck-on-changes.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Hook script: Run typecheck if there are uncommitted changes
|
||||||
|
# Triggered on: Stop event
|
||||||
|
|
||||||
|
# Check if there are any uncommitted changes (staged or unstaged)
|
||||||
|
if git diff --quiet HEAD 2>/dev/null; then
|
||||||
|
echo "No changes, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run typecheck - capture output and send to stderr on failure
|
||||||
|
output=$(tsc --noEmit --pretty 2>&1)
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "$output"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "$output" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
@@ -2359,16 +2359,51 @@ export default function App({
|
|||||||
conversationBusyRetriesRef.current = 0;
|
conversationBusyRetriesRef.current = 0;
|
||||||
lastDequeuedMessageRef.current = null; // Clear - message was processed successfully
|
lastDequeuedMessageRef.current = null; // Clear - message was processed successfully
|
||||||
|
|
||||||
// Run Stop hooks (fire-and-forget)
|
// Run Stop hooks - if blocked/errored, continue the conversation with feedback
|
||||||
runStopHooks(
|
const stopHookResult = await runStopHooks(
|
||||||
stopReasonToHandle,
|
stopReasonToHandle,
|
||||||
buffersRef.current.order.length,
|
buffersRef.current.order.length,
|
||||||
Array.from(buffersRef.current.byId.values()).filter(
|
Array.from(buffersRef.current.byId.values()).filter(
|
||||||
(item) => item.kind === "tool_call",
|
(item) => item.kind === "tool_call",
|
||||||
).length,
|
).length,
|
||||||
).catch(() => {
|
);
|
||||||
// Silently ignore hook errors
|
|
||||||
});
|
// If hook blocked (exit 2), inject stderr feedback and continue conversation
|
||||||
|
if (stopHookResult.blocked) {
|
||||||
|
const stderrOutput = stopHookResult.results
|
||||||
|
.map((r) => r.stderr)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
const feedback = stderrOutput || "Stop hook blocked";
|
||||||
|
const hookMessage = `<stop-hook>\n${feedback}\n</stop-hook>`;
|
||||||
|
|
||||||
|
// Add status to transcript so user sees what's happening
|
||||||
|
const statusId = uid("status");
|
||||||
|
buffersRef.current.byId.set(statusId, {
|
||||||
|
kind: "status",
|
||||||
|
id: statusId,
|
||||||
|
lines: [
|
||||||
|
"Stop hook encountered blocking error, continuing loop with stderr feedback.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(statusId);
|
||||||
|
refreshDerived();
|
||||||
|
|
||||||
|
// Continue conversation with the hook feedback
|
||||||
|
setTimeout(() => {
|
||||||
|
processConversation(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: hookMessage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ allowReentry: true },
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Disable eager approval check after first successful message (LET-7101)
|
// Disable eager approval check after first successful message (LET-7101)
|
||||||
// Any new approvals from here on are from our own turn, not orphaned
|
// Any new approvals from here on are from our own turn, not orphaned
|
||||||
|
|||||||
@@ -4,14 +4,24 @@
|
|||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import TextInput from "ink-text-input";
|
import TextInput from "ink-text-input";
|
||||||
import { memo, useCallback, useEffect, useState } from "react";
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
import type { HookEvent, HookMatcher } from "../../hooks/types";
|
import {
|
||||||
|
type HookEvent,
|
||||||
|
type HookMatcher,
|
||||||
|
isToolEvent,
|
||||||
|
type SimpleHookEvent,
|
||||||
|
type SimpleHookMatcher,
|
||||||
|
type ToolHookEvent,
|
||||||
|
} from "../../hooks/types";
|
||||||
import {
|
import {
|
||||||
addHookMatcher,
|
addHookMatcher,
|
||||||
|
addSimpleHookMatcher,
|
||||||
countHooksForEvent,
|
countHooksForEvent,
|
||||||
countTotalHooks,
|
countTotalHooks,
|
||||||
type HookMatcherWithSource,
|
type HookMatcherWithSource,
|
||||||
loadHooksWithSource,
|
type HookWithSource,
|
||||||
removeHookMatcher,
|
loadMatchersWithSource,
|
||||||
|
loadSimpleMatchersWithSource,
|
||||||
|
removeHook,
|
||||||
type SaveLocation,
|
type SaveLocation,
|
||||||
} from "../../hooks/writer";
|
} from "../../hooks/writer";
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
@@ -31,8 +41,8 @@ interface HooksManagerProps {
|
|||||||
|
|
||||||
type Screen =
|
type Screen =
|
||||||
| "events"
|
| "events"
|
||||||
| "matchers"
|
| "hooks-list" // Was "matchers" - now handles both matchers and commands
|
||||||
| "add-matcher"
|
| "add-matcher" // For tool events only
|
||||||
| "add-command"
|
| "add-command"
|
||||||
| "save-location"
|
| "save-location"
|
||||||
| "delete-confirm";
|
| "delete-confirm";
|
||||||
@@ -127,7 +137,8 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
const [screen, setScreen] = useState<Screen>("events");
|
const [screen, setScreen] = useState<Screen>("events");
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<HookEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<HookEvent | null>(null);
|
||||||
const [matchers, setMatchers] = useState<HookMatcherWithSource[]>([]);
|
// For tool events: HookMatcherWithSource[], for simple events: HookCommandWithSource[]
|
||||||
|
const [hooks, setHooks] = useState<HookWithSource[]>([]);
|
||||||
const [totalHooks, setTotalHooks] = useState(0);
|
const [totalHooks, setTotalHooks] = useState(0);
|
||||||
|
|
||||||
// New hook state
|
// New hook state
|
||||||
@@ -136,9 +147,12 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
const [selectedLocation, setSelectedLocation] = useState(0);
|
const [selectedLocation, setSelectedLocation] = useState(0);
|
||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
const [deleteMatcherIndex, setDeleteMatcherIndex] = useState(-1);
|
const [deleteHookIndex, setDeleteHookIndex] = useState(-1);
|
||||||
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No
|
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No
|
||||||
|
|
||||||
|
// Helper to check if current event is a tool event
|
||||||
|
const isCurrentToolEvent = selectedEvent ? isToolEvent(selectedEvent) : false;
|
||||||
|
|
||||||
// Refresh counts - called when hooks change
|
// Refresh counts - called when hooks change
|
||||||
const refreshCounts = useCallback(() => {
|
const refreshCounts = useCallback(() => {
|
||||||
setTotalHooks(countTotalHooks());
|
setTotalHooks(countTotalHooks());
|
||||||
@@ -151,10 +165,13 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
}
|
}
|
||||||
}, [screen, refreshCounts]);
|
}, [screen, refreshCounts]);
|
||||||
|
|
||||||
// Load matchers when event is selected
|
// Load hooks when event is selected (matchers for both tool and simple events)
|
||||||
const loadMatchers = useCallback((event: HookEvent) => {
|
const loadHooks = useCallback((event: HookEvent) => {
|
||||||
const loaded = loadHooksWithSource(event);
|
if (isToolEvent(event)) {
|
||||||
setMatchers(loaded);
|
setHooks(loadMatchersWithSource(event as ToolHookEvent));
|
||||||
|
} else {
|
||||||
|
setHooks(loadSimpleMatchersWithSource(event as SimpleHookEvent));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle adding a hook
|
// Handle adding a hook
|
||||||
@@ -164,52 +181,59 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
const location = SAVE_LOCATIONS[selectedLocation]?.location;
|
const location = SAVE_LOCATIONS[selectedLocation]?.location;
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
|
|
||||||
const matcher: HookMatcher = {
|
if (isToolEvent(selectedEvent)) {
|
||||||
matcher: newMatcher.trim() || "*",
|
// Tool events use HookMatcher with matcher pattern
|
||||||
hooks: [{ type: "command", command: newCommand.trim() }],
|
const matcher: HookMatcher = {
|
||||||
};
|
matcher: newMatcher.trim() || "*",
|
||||||
|
hooks: [{ type: "command", command: newCommand.trim() }],
|
||||||
|
};
|
||||||
|
await addHookMatcher(selectedEvent as ToolHookEvent, matcher, location);
|
||||||
|
} else {
|
||||||
|
// Simple events use SimpleHookMatcher (same structure, just no matcher field)
|
||||||
|
const matcher: SimpleHookMatcher = {
|
||||||
|
hooks: [{ type: "command", command: newCommand.trim() }],
|
||||||
|
};
|
||||||
|
await addSimpleHookMatcher(
|
||||||
|
selectedEvent as SimpleHookEvent,
|
||||||
|
matcher,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await addHookMatcher(selectedEvent, matcher, location);
|
loadHooks(selectedEvent);
|
||||||
loadMatchers(selectedEvent);
|
|
||||||
refreshCounts();
|
refreshCounts();
|
||||||
|
|
||||||
// Reset and go back to matchers
|
// Reset and go back to hooks list
|
||||||
setNewMatcher("");
|
setNewMatcher("");
|
||||||
setNewCommand("");
|
setNewCommand("");
|
||||||
setSelectedLocation(0);
|
setSelectedLocation(0);
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [
|
}, [
|
||||||
selectedEvent,
|
selectedEvent,
|
||||||
newMatcher,
|
newMatcher,
|
||||||
newCommand,
|
newCommand,
|
||||||
selectedLocation,
|
selectedLocation,
|
||||||
loadMatchers,
|
loadHooks,
|
||||||
refreshCounts,
|
refreshCounts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle deleting a hook
|
// Handle deleting a hook
|
||||||
const handleDeleteHook = useCallback(async () => {
|
const handleDeleteHook = useCallback(async () => {
|
||||||
if (deleteMatcherIndex < 0 || !selectedEvent) return;
|
if (deleteHookIndex < 0 || !selectedEvent) return;
|
||||||
|
|
||||||
const matcher = matchers[deleteMatcherIndex];
|
const hook = hooks[deleteHookIndex];
|
||||||
if (!matcher) return;
|
if (!hook) return;
|
||||||
|
|
||||||
await removeHookMatcher(selectedEvent, matcher.sourceIndex, matcher.source);
|
await removeHook(selectedEvent, hook.sourceIndex, hook.source);
|
||||||
loadMatchers(selectedEvent);
|
loadHooks(selectedEvent);
|
||||||
refreshCounts();
|
refreshCounts();
|
||||||
|
|
||||||
// Reset and go back to matchers
|
// Reset and go back to hooks list
|
||||||
setDeleteMatcherIndex(-1);
|
setDeleteHookIndex(-1);
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [
|
}, [deleteHookIndex, selectedEvent, hooks, loadHooks, refreshCounts]);
|
||||||
deleteMatcherIndex,
|
|
||||||
selectedEvent,
|
|
||||||
matchers,
|
|
||||||
loadMatchers,
|
|
||||||
refreshCounts,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
// CTRL-C: immediately cancel
|
// CTRL-C: immediately cancel
|
||||||
@@ -228,16 +252,16 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
const selected = HOOK_EVENTS[selectedIndex];
|
const selected = HOOK_EVENTS[selectedIndex];
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSelectedEvent(selected.event);
|
setSelectedEvent(selected.event);
|
||||||
loadMatchers(selected.event);
|
loadHooks(selected.event);
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}
|
}
|
||||||
} else if (key.escape) {
|
} else if (key.escape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} else if (screen === "matchers") {
|
} else if (screen === "hooks-list") {
|
||||||
// Items: [+ Add new matcher] + existing matchers
|
// Items: [+ Add new hook] + existing hooks
|
||||||
const itemCount = matchers.length + 1;
|
const itemCount = hooks.length + 1;
|
||||||
|
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||||
@@ -245,15 +269,20 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1));
|
setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1));
|
||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
// Add new matcher
|
// Add new hook - for tool events, go to matcher screen; for simple, go to command
|
||||||
setScreen("add-matcher");
|
if (isCurrentToolEvent) {
|
||||||
setNewMatcher("");
|
setScreen("add-matcher");
|
||||||
|
setNewMatcher("");
|
||||||
|
} else {
|
||||||
|
setScreen("add-command");
|
||||||
|
setNewCommand("");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Could add edit functionality here
|
// Could add edit functionality here
|
||||||
}
|
}
|
||||||
} else if ((input === "d" || input === "D") && selectedIndex > 0) {
|
} else if ((input === "d" || input === "D") && selectedIndex > 0) {
|
||||||
// Delete selected matcher
|
// Delete selected hook
|
||||||
setDeleteMatcherIndex(selectedIndex - 1);
|
setDeleteHookIndex(selectedIndex - 1);
|
||||||
setDeleteConfirmIndex(1); // Default to No
|
setDeleteConfirmIndex(1); // Default to No
|
||||||
setScreen("delete-confirm");
|
setScreen("delete-confirm");
|
||||||
} else if (key.escape) {
|
} else if (key.escape) {
|
||||||
@@ -262,12 +291,12 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
setSelectedEvent(null);
|
setSelectedEvent(null);
|
||||||
}
|
}
|
||||||
} else if (screen === "add-matcher") {
|
} else if (screen === "add-matcher") {
|
||||||
// Text input handles most keys
|
// Text input handles most keys (tool events only)
|
||||||
if (key.return && !key.shift) {
|
if (key.return && !key.shift) {
|
||||||
setScreen("add-command");
|
setScreen("add-command");
|
||||||
setNewCommand("");
|
setNewCommand("");
|
||||||
} else if (key.escape) {
|
} else if (key.escape) {
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setNewMatcher("");
|
setNewMatcher("");
|
||||||
}
|
}
|
||||||
@@ -276,7 +305,13 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
setScreen("save-location");
|
setScreen("save-location");
|
||||||
setSelectedLocation(0);
|
setSelectedLocation(0);
|
||||||
} else if (key.escape) {
|
} else if (key.escape) {
|
||||||
setScreen("add-matcher");
|
// Go back to matcher screen for tool events, or hooks list for simple
|
||||||
|
if (isCurrentToolEvent) {
|
||||||
|
setScreen("add-matcher");
|
||||||
|
} else {
|
||||||
|
setScreen("hooks-list");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (screen === "save-location") {
|
} else if (screen === "save-location") {
|
||||||
if (key.upArrow) {
|
if (key.upArrow) {
|
||||||
@@ -297,10 +332,10 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
if (deleteConfirmIndex === 0) {
|
if (deleteConfirmIndex === 0) {
|
||||||
handleDeleteHook();
|
handleDeleteHook();
|
||||||
} else {
|
} else {
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
}
|
}
|
||||||
} else if (key.escape) {
|
} else if (key.escape) {
|
||||||
setScreen("matchers");
|
setScreen("hooks-list");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -342,49 +377,76 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Matchers List
|
// Render Hooks List (matchers for tool events, commands for simple events)
|
||||||
if (screen === "matchers" && selectedEvent) {
|
if (screen === "hooks-list" && selectedEvent) {
|
||||||
|
const title = isCurrentToolEvent
|
||||||
|
? ` ${selectedEvent} - Tool Matchers `
|
||||||
|
: ` ${selectedEvent} - Hooks `;
|
||||||
|
const addLabel = isCurrentToolEvent
|
||||||
|
? "+ Add new matcher..."
|
||||||
|
: "+ Add new hook...";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
<Text>{boxTop(boxWidth)}</Text>
|
<Text>{boxTop(boxWidth)}</Text>
|
||||||
<Text>{boxLine(` ${selectedEvent} - Tool Matchers `, boxWidth)}</Text>
|
<Text>{boxLine(title, boxWidth)}</Text>
|
||||||
<Text>{boxBottom(boxWidth)}</Text>
|
<Text>{boxBottom(boxWidth)}</Text>
|
||||||
|
|
||||||
<Text dimColor>Input to command is JSON of tool call arguments.</Text>
|
{isCurrentToolEvent ? (
|
||||||
<Text dimColor>Exit code 0 - stdout/stderr not shown</Text>
|
<>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Exit code 2 - show stderr to model and block tool call
|
Input to command is JSON of tool call arguments.
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>Exit code 0 - stdout/stderr not shown</Text>
|
||||||
Other exit codes - show stderr to user only but continue
|
<Text dimColor>
|
||||||
</Text>
|
Exit code 2 - show stderr to model and block tool call
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Other exit codes - show stderr to user only but continue
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text dimColor>Exit code 0 - success, continue</Text>
|
||||||
|
<Text dimColor>Exit code 2 - show stderr to model and block</Text>
|
||||||
|
<Text dimColor>Other exit codes - show stderr to user only</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
|
|
||||||
{/* Add new matcher option */}
|
{/* Add new hook option */}
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={selectedIndex === 0 ? colors.input.prompt : undefined}>
|
<Text color={selectedIndex === 0 ? colors.input.prompt : undefined}>
|
||||||
{selectedIndex === 0 ? "❯" : " "} 1.{" "}
|
{selectedIndex === 0 ? "❯" : " "} 1.{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="green">+ Add new matcher...</Text>
|
<Text color="green">{addLabel}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Existing matchers */}
|
{/* Existing hooks */}
|
||||||
{matchers.map((matcher, index) => {
|
{hooks.map((hook, index) => {
|
||||||
const isSelected = index + 1 === selectedIndex;
|
const isSelected = index + 1 === selectedIndex;
|
||||||
const prefix = isSelected ? "❯" : " ";
|
const prefix = isSelected ? "❯" : " ";
|
||||||
const sourceLabel = `[${getSourceLabel(matcher.source)}]`;
|
const sourceLabel = `[${getSourceLabel(hook.source)}]`;
|
||||||
const matcherPattern = matcher.matcher || "*";
|
|
||||||
const command = matcher.hooks[0]?.command || "";
|
// Handle both tool matchers (with matcher field) and simple matchers (without)
|
||||||
|
const isToolMatcher = "matcher" in hook;
|
||||||
|
const matcherPattern = isToolMatcher
|
||||||
|
? (hook as HookMatcherWithSource).matcher || "*"
|
||||||
|
: null;
|
||||||
|
// Both types have hooks array
|
||||||
|
const command = "hooks" in hook ? hook.hooks[0]?.command || "" : "";
|
||||||
const truncatedCommand =
|
const truncatedCommand =
|
||||||
command.length > 30 ? `${command.slice(0, 27)}...` : command;
|
command.length > 30 ? `${command.slice(0, 27)}...` : command;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text key={`${matcher.source}-${index}`}>
|
<Text key={`${hook.source}-${index}`}>
|
||||||
<Text color={isSelected ? colors.input.prompt : undefined}>
|
<Text color={isSelected ? colors.input.prompt : undefined}>
|
||||||
{prefix} {index + 2}.{" "}
|
{prefix} {index + 2}.{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="cyan">{sourceLabel}</Text>
|
<Text color="cyan">{sourceLabel}</Text>
|
||||||
<Text> {matcherPattern.padEnd(12)} </Text>
|
{matcherPattern !== null && (
|
||||||
|
<Text> {matcherPattern.padEnd(12)} </Text>
|
||||||
|
)}
|
||||||
<Text dimColor>{truncatedCommand}</Text>
|
<Text dimColor>{truncatedCommand}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -440,18 +502,20 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Add Matcher - Command Input
|
// Render Add Command Input
|
||||||
if (screen === "add-command" && selectedEvent) {
|
if (screen === "add-command" && selectedEvent) {
|
||||||
|
const title = isCurrentToolEvent
|
||||||
|
? ` Add new matcher for ${selectedEvent} `
|
||||||
|
: ` Add new hook for ${selectedEvent} `;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
<Text>{boxTop(boxWidth)}</Text>
|
<Text>{boxTop(boxWidth)}</Text>
|
||||||
<Text>
|
<Text>{boxLine(title, boxWidth)}</Text>
|
||||||
{boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)}
|
|
||||||
</Text>
|
|
||||||
<Text>{boxBottom(boxWidth)}</Text>
|
<Text>{boxBottom(boxWidth)}</Text>
|
||||||
|
|
||||||
<Text>Matcher: {newMatcher || "*"}</Text>
|
{isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
|
||||||
<Text> </Text>
|
{isCurrentToolEvent && <Text> </Text>}
|
||||||
|
|
||||||
<Text>Command:</Text>
|
<Text>Command:</Text>
|
||||||
<Text>{boxTop(boxWidth - 2)}</Text>
|
<Text>{boxTop(boxWidth - 2)}</Text>
|
||||||
@@ -481,7 +545,7 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
|
|
||||||
<Text>Event: {selectedEvent}</Text>
|
<Text>Event: {selectedEvent}</Text>
|
||||||
<Text>Matcher: {newMatcher || "*"}</Text>
|
{isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
|
||||||
<Text>Command: {newCommand}</Text>
|
<Text>Command: {newCommand}</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
|
|
||||||
@@ -509,8 +573,14 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render Delete Confirmation
|
// Render Delete Confirmation
|
||||||
if (screen === "delete-confirm" && deleteMatcherIndex >= 0) {
|
if (screen === "delete-confirm" && deleteHookIndex >= 0) {
|
||||||
const matcher = matchers[deleteMatcherIndex];
|
const hook = hooks[deleteHookIndex];
|
||||||
|
const isToolMatcher = hook && "matcher" in hook;
|
||||||
|
const matcherPattern = isToolMatcher
|
||||||
|
? (hook as HookMatcherWithSource).matcher || "*"
|
||||||
|
: null;
|
||||||
|
// Both types have hooks array
|
||||||
|
const command = hook && "hooks" in hook ? hook.hooks[0]?.command : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -519,9 +589,9 @@ export const HooksManager = memo(function HooksManager({
|
|||||||
<Text>{boxBottom(boxWidth)}</Text>
|
<Text>{boxBottom(boxWidth)}</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
|
|
||||||
<Text>Matcher: {matcher?.matcher || "*"}</Text>
|
{matcherPattern !== null && <Text>Matcher: {matcherPattern}</Text>}
|
||||||
<Text>Command: {matcher?.hooks[0]?.command}</Text>
|
<Text>Command: {command}</Text>
|
||||||
<Text>Source: {matcher ? getSourceLabel(matcher.source) : ""}</Text>
|
<Text>Source: {hook ? getSourceLabel(hook.source) : ""}</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
// Loads and matches hooks from settings-manager
|
// Loads and matches hooks from settings-manager
|
||||||
|
|
||||||
import { settingsManager } from "../settings-manager";
|
import { settingsManager } from "../settings-manager";
|
||||||
import type { HookCommand, HookEvent, HooksConfig } from "./types";
|
import {
|
||||||
|
type HookCommand,
|
||||||
|
type HookEvent,
|
||||||
|
type HookMatcher,
|
||||||
|
type HooksConfig,
|
||||||
|
isToolEvent,
|
||||||
|
type SimpleHookEvent,
|
||||||
|
type SimpleHookMatcher,
|
||||||
|
type ToolHookEvent,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear hooks cache - kept for API compatibility with existing callers.
|
* Clear hooks cache - kept for API compatibility with existing callers.
|
||||||
@@ -71,7 +80,7 @@ export async function loadProjectLocalHooks(
|
|||||||
/**
|
/**
|
||||||
* Merge hooks configurations
|
* Merge hooks configurations
|
||||||
* Priority order: project-local > project > global
|
* Priority order: project-local > project > global
|
||||||
* For each event, matchers are ordered by priority (local first, global last)
|
* For each event, hooks are ordered by priority (local first, global last)
|
||||||
*/
|
*/
|
||||||
export function mergeHooksConfigs(
|
export function mergeHooksConfigs(
|
||||||
global: HooksConfig,
|
global: HooksConfig,
|
||||||
@@ -86,15 +95,34 @@ export function mergeHooksConfigs(
|
|||||||
]) as Set<HookEvent>;
|
]) as Set<HookEvent>;
|
||||||
|
|
||||||
for (const event of allEvents) {
|
for (const event of allEvents) {
|
||||||
const globalMatchers = global[event] || [];
|
if (isToolEvent(event)) {
|
||||||
const projectMatchers = project[event] || [];
|
// Tool events use HookMatcher[]
|
||||||
const projectLocalMatchers = projectLocal[event] || [];
|
const toolEvent = event as ToolHookEvent;
|
||||||
// Project-local matchers run first, then project, then global
|
const globalMatchers = (global[toolEvent] || []) as HookMatcher[];
|
||||||
merged[event] = [
|
const projectMatchers = (project[toolEvent] || []) as HookMatcher[];
|
||||||
...projectLocalMatchers,
|
const projectLocalMatchers = (projectLocal[toolEvent] ||
|
||||||
...projectMatchers,
|
[]) as HookMatcher[];
|
||||||
...globalMatchers,
|
// Project-local runs first, then project, then global
|
||||||
];
|
(merged as Record<ToolHookEvent, HookMatcher[]>)[toolEvent] = [
|
||||||
|
...projectLocalMatchers,
|
||||||
|
...projectMatchers,
|
||||||
|
...globalMatchers,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Simple events use SimpleHookMatcher[] (same as HookMatcher but without matcher field)
|
||||||
|
const simpleEvent = event as SimpleHookEvent;
|
||||||
|
const globalMatchers = (global[simpleEvent] || []) as SimpleHookMatcher[];
|
||||||
|
const projectMatchers = (project[simpleEvent] ||
|
||||||
|
[]) as SimpleHookMatcher[];
|
||||||
|
const projectLocalMatchers = (projectLocal[simpleEvent] ||
|
||||||
|
[]) as SimpleHookMatcher[];
|
||||||
|
// Project-local runs first, then project, then global
|
||||||
|
(merged as Record<SimpleHookEvent, SimpleHookMatcher[]>)[simpleEvent] = [
|
||||||
|
...projectLocalMatchers,
|
||||||
|
...projectMatchers,
|
||||||
|
...globalMatchers,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
@@ -146,22 +174,37 @@ export function getMatchingHooks(
|
|||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
): HookCommand[] {
|
): HookCommand[] {
|
||||||
const matchers = config[event];
|
if (isToolEvent(event)) {
|
||||||
if (!matchers || matchers.length === 0) {
|
// Tool events use HookMatcher[] - need to match against tool name
|
||||||
return [];
|
const matchers = config[event as ToolHookEvent] as
|
||||||
}
|
| HookMatcher[]
|
||||||
|
| undefined;
|
||||||
|
if (!matchers || matchers.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const hooks: HookCommand[] = [];
|
const hooks: HookCommand[] = [];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
if (!toolName || matchesTool(matcher.matcher, toolName)) {
|
||||||
|
hooks.push(...matcher.hooks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hooks;
|
||||||
|
} else {
|
||||||
|
// Simple events use SimpleHookMatcher[] - extract hooks from each matcher
|
||||||
|
const matchers = config[event as SimpleHookEvent] as
|
||||||
|
| SimpleHookMatcher[]
|
||||||
|
| undefined;
|
||||||
|
if (!matchers || matchers.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
for (const matcher of matchers) {
|
const hooks: HookCommand[] = [];
|
||||||
// For non-tool events, matcher is usually empty/"*"
|
for (const matcher of matchers) {
|
||||||
// For tool events, check if the tool matches
|
|
||||||
if (!toolName || matchesTool(matcher.matcher, toolName)) {
|
|
||||||
hooks.push(...matcher.hooks);
|
hooks.push(...matcher.hooks);
|
||||||
}
|
}
|
||||||
|
return hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hooks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,13 +214,27 @@ export function hasHooksForEvent(
|
|||||||
config: HooksConfig,
|
config: HooksConfig,
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
): boolean {
|
): boolean {
|
||||||
const matchers = config[event];
|
if (isToolEvent(event)) {
|
||||||
if (!matchers || matchers.length === 0) {
|
// Tool events use HookMatcher[]
|
||||||
return false;
|
const matchers = config[event as ToolHookEvent] as
|
||||||
|
| HookMatcher[]
|
||||||
|
| undefined;
|
||||||
|
if (!matchers || matchers.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if any matcher has hooks
|
||||||
|
return matchers.some((m) => m.hooks && m.hooks.length > 0);
|
||||||
|
} else {
|
||||||
|
// Simple events use SimpleHookMatcher[]
|
||||||
|
const matchers = config[event as SimpleHookEvent] as
|
||||||
|
| SimpleHookMatcher[]
|
||||||
|
| undefined;
|
||||||
|
if (!matchers || matchers.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if any matcher has hooks
|
||||||
|
return matchers.some((m) => m.hooks && m.hooks.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any matcher has hooks
|
|
||||||
return matchers.some((m) => m.hooks && m.hooks.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
// Types for Letta Code hooks system (Claude Code-compatible)
|
// Types for Letta Code hooks system (Claude Code-compatible)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook event types that can trigger hooks
|
* Tool-related hook events that require matchers to specify which tools to match
|
||||||
*/
|
*/
|
||||||
export type HookEvent =
|
export type ToolHookEvent =
|
||||||
| "PreToolUse" // Runs before tool calls (can block them)
|
| "PreToolUse" // Runs before tool calls (can block them)
|
||||||
| "PostToolUse" // Runs after tool calls complete (cannot block)
|
| "PostToolUse" // Runs after tool calls complete (cannot block)
|
||||||
| "PermissionRequest" // Runs when a permission dialog is shown (can allow or deny)
|
| "PermissionRequest"; // Runs when a permission dialog is shown (can allow or deny)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple hook events that don't require matchers
|
||||||
|
*/
|
||||||
|
export type SimpleHookEvent =
|
||||||
| "UserPromptSubmit" // Runs when the user submits a prompt (can block)
|
| "UserPromptSubmit" // Runs when the user submits a prompt (can block)
|
||||||
| "Notification" // Runs when a notification is sent (cannot block)
|
| "Notification" // Runs when a notification is sent (cannot block)
|
||||||
| "Stop" // Runs when the agent finishes responding (can block)
|
| "Stop" // Runs when the agent finishes responding (can block)
|
||||||
@@ -17,6 +22,11 @@ export type HookEvent =
|
|||||||
| "SessionStart" // Runs when a new session starts or is resumed
|
| "SessionStart" // Runs when a new session starts or is resumed
|
||||||
| "SessionEnd"; // Runs when session ends (cannot block)
|
| "SessionEnd"; // Runs when session ends (cannot block)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All hook event types
|
||||||
|
*/
|
||||||
|
export type HookEvent = ToolHookEvent | SimpleHookEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual hook command configuration
|
* Individual hook command configuration
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +40,7 @@ export interface HookCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook matcher configuration - matches hooks to specific tools/events
|
* Hook matcher configuration for tool events - matches hooks to specific tools
|
||||||
*/
|
*/
|
||||||
export interface HookMatcher {
|
export interface HookMatcher {
|
||||||
/**
|
/**
|
||||||
@@ -44,13 +54,41 @@ export interface HookMatcher {
|
|||||||
hooks: HookCommand[];
|
hooks: HookCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple hook matcher for non-tool events - no matcher needed, just hooks
|
||||||
|
*/
|
||||||
|
export interface SimpleHookMatcher {
|
||||||
|
/** List of hooks to run */
|
||||||
|
hooks: HookCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full hooks configuration stored in settings
|
* Full hooks configuration stored in settings
|
||||||
|
* - Tool events (PreToolUse, PostToolUse, PermissionRequest) use HookMatcher[] with matcher patterns
|
||||||
|
* - Simple events use SimpleHookMatcher[] (same structure, just no matcher field)
|
||||||
*/
|
*/
|
||||||
export type HooksConfig = {
|
export type HooksConfig = {
|
||||||
[K in HookEvent]?: HookMatcher[];
|
[K in ToolHookEvent]?: HookMatcher[];
|
||||||
|
} & {
|
||||||
|
[K in SimpleHookEvent]?: SimpleHookMatcher[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of tool events that require matchers
|
||||||
|
*/
|
||||||
|
export const TOOL_EVENTS: Set<HookEvent> = new Set([
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an event is a tool event
|
||||||
|
*/
|
||||||
|
export function isToolEvent(event: HookEvent): event is ToolHookEvent {
|
||||||
|
return TOOL_EVENTS.has(event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exit codes from hook execution
|
* Exit codes from hook execution
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
// Functions to write hooks to settings files via settings-manager
|
// Functions to write hooks to settings files via settings-manager
|
||||||
|
|
||||||
import { settingsManager } from "../settings-manager";
|
import { settingsManager } from "../settings-manager";
|
||||||
import type { HookEvent, HookMatcher, HooksConfig } from "./types";
|
import {
|
||||||
|
type HookEvent,
|
||||||
|
type HookMatcher,
|
||||||
|
type HooksConfig,
|
||||||
|
isToolEvent,
|
||||||
|
type SimpleHookEvent,
|
||||||
|
type SimpleHookMatcher,
|
||||||
|
type ToolHookEvent,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save location for hooks
|
* Save location for hooks
|
||||||
@@ -71,10 +79,10 @@ export async function saveHooksToLocation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new hook matcher to an event
|
* Add a new hook matcher to a tool event (PreToolUse, PostToolUse, PermissionRequest)
|
||||||
*/
|
*/
|
||||||
export async function addHookMatcher(
|
export async function addHookMatcher(
|
||||||
event: HookEvent,
|
event: ToolHookEvent,
|
||||||
matcher: HookMatcher,
|
matcher: HookMatcher,
|
||||||
location: SaveLocation,
|
location: SaveLocation,
|
||||||
workingDirectory: string = process.cwd(),
|
workingDirectory: string = process.cwd(),
|
||||||
@@ -83,61 +91,91 @@ export async function addHookMatcher(
|
|||||||
|
|
||||||
// Initialize event array if needed
|
// Initialize event array if needed
|
||||||
if (!hooks[event]) {
|
if (!hooks[event]) {
|
||||||
hooks[event] = [];
|
(hooks as Record<ToolHookEvent, HookMatcher[]>)[event] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the new matcher
|
// Add the new matcher
|
||||||
const eventMatchers = hooks[event];
|
const eventMatchers = hooks[event] as HookMatcher[];
|
||||||
if (eventMatchers) {
|
eventMatchers.push(matcher);
|
||||||
eventMatchers.push(matcher);
|
|
||||||
|
await saveHooksToLocation(hooks, location, workingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new hook matcher to a simple event (non-tool events)
|
||||||
|
* Simple events use the same structure as tool events but without the matcher field
|
||||||
|
*/
|
||||||
|
export async function addSimpleHookMatcher(
|
||||||
|
event: SimpleHookEvent,
|
||||||
|
matcher: SimpleHookMatcher,
|
||||||
|
location: SaveLocation,
|
||||||
|
workingDirectory: string = process.cwd(),
|
||||||
|
): Promise<void> {
|
||||||
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
|
|
||||||
|
// Initialize event array if needed
|
||||||
|
if (!hooks[event]) {
|
||||||
|
(hooks as Record<SimpleHookEvent, SimpleHookMatcher[]>)[event] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the new matcher
|
||||||
|
const eventMatchers = hooks[event] as SimpleHookMatcher[];
|
||||||
|
eventMatchers.push(matcher);
|
||||||
|
|
||||||
await saveHooksToLocation(hooks, location, workingDirectory);
|
await saveHooksToLocation(hooks, location, workingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a hook matcher from an event by index
|
* Remove a hook matcher from an event by index
|
||||||
|
* Works for both tool events (HookMatcher) and simple events (SimpleHookMatcher)
|
||||||
*/
|
*/
|
||||||
export async function removeHookMatcher(
|
export async function removeHook(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
matcherIndex: number,
|
index: number,
|
||||||
location: SaveLocation,
|
location: SaveLocation,
|
||||||
workingDirectory: string = process.cwd(),
|
workingDirectory: string = process.cwd(),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hooks = loadHooksFromLocation(location, workingDirectory);
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
const eventMatchers = hooks[event];
|
|
||||||
|
|
||||||
if (
|
if (isToolEvent(event)) {
|
||||||
!eventMatchers ||
|
const eventMatchers = hooks[event as ToolHookEvent] as
|
||||||
matcherIndex < 0 ||
|
| HookMatcher[]
|
||||||
matcherIndex >= eventMatchers.length
|
| undefined;
|
||||||
) {
|
if (!eventMatchers || index < 0 || index >= eventMatchers.length) {
|
||||||
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
|
throw new Error(`Invalid matcher index ${index} for event ${event}`);
|
||||||
}
|
}
|
||||||
|
eventMatchers.splice(index, 1);
|
||||||
// Remove the matcher at the given index
|
if (eventMatchers.length === 0) {
|
||||||
eventMatchers.splice(matcherIndex, 1);
|
delete hooks[event as ToolHookEvent];
|
||||||
|
}
|
||||||
// Clean up empty arrays
|
} else {
|
||||||
if (eventMatchers.length === 0) {
|
const eventMatchers = hooks[event as SimpleHookEvent] as
|
||||||
delete hooks[event];
|
| SimpleHookMatcher[]
|
||||||
|
| undefined;
|
||||||
|
if (!eventMatchers || index < 0 || index >= eventMatchers.length) {
|
||||||
|
throw new Error(`Invalid matcher index ${index} for event ${event}`);
|
||||||
|
}
|
||||||
|
eventMatchers.splice(index, 1);
|
||||||
|
if (eventMatchers.length === 0) {
|
||||||
|
delete hooks[event as SimpleHookEvent];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveHooksToLocation(hooks, location, workingDirectory);
|
await saveHooksToLocation(hooks, location, workingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a hook matcher at a specific index
|
* Update a hook matcher at a specific index (tool events only)
|
||||||
*/
|
*/
|
||||||
export async function updateHookMatcher(
|
export async function updateHookMatcher(
|
||||||
event: HookEvent,
|
event: ToolHookEvent,
|
||||||
matcherIndex: number,
|
matcherIndex: number,
|
||||||
matcher: HookMatcher,
|
matcher: HookMatcher,
|
||||||
location: SaveLocation,
|
location: SaveLocation,
|
||||||
workingDirectory: string = process.cwd(),
|
workingDirectory: string = process.cwd(),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hooks = loadHooksFromLocation(location, workingDirectory);
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
const eventMatchers = hooks[event];
|
const eventMatchers = hooks[event] as HookMatcher[] | undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!eventMatchers ||
|
!eventMatchers ||
|
||||||
@@ -147,14 +185,39 @@ export async function updateHookMatcher(
|
|||||||
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
|
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the matcher at the given index
|
|
||||||
eventMatchers[matcherIndex] = matcher;
|
eventMatchers[matcherIndex] = matcher;
|
||||||
|
|
||||||
await saveHooksToLocation(hooks, location, workingDirectory);
|
await saveHooksToLocation(hooks, location, workingDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook matcher with source tracking for display
|
* Update a hook matcher at a specific index (simple events only)
|
||||||
|
*/
|
||||||
|
export async function updateSimpleHookMatcher(
|
||||||
|
event: SimpleHookEvent,
|
||||||
|
matcherIndex: number,
|
||||||
|
matcher: SimpleHookMatcher,
|
||||||
|
location: SaveLocation,
|
||||||
|
workingDirectory: string = process.cwd(),
|
||||||
|
): Promise<void> {
|
||||||
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
|
const eventMatchers = hooks[event] as SimpleHookMatcher[] | undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!eventMatchers ||
|
||||||
|
matcherIndex < 0 ||
|
||||||
|
matcherIndex >= eventMatchers.length
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid matcher index ${matcherIndex} for event ${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventMatchers[matcherIndex] = matcher;
|
||||||
|
|
||||||
|
await saveHooksToLocation(hooks, location, workingDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook matcher with source tracking for display (tool events)
|
||||||
*/
|
*/
|
||||||
export interface HookMatcherWithSource extends HookMatcher {
|
export interface HookMatcherWithSource extends HookMatcher {
|
||||||
source: SaveLocation;
|
source: SaveLocation;
|
||||||
@@ -162,21 +225,62 @@ export interface HookMatcherWithSource extends HookMatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all hooks for an event with source tracking
|
* Simple hook matcher with source tracking for display (simple events)
|
||||||
* Returns matchers tagged with their source location
|
|
||||||
*/
|
*/
|
||||||
export function loadHooksWithSource(
|
export interface SimpleHookMatcherWithSource extends SimpleHookMatcher {
|
||||||
event: HookEvent,
|
source: SaveLocation;
|
||||||
|
sourceIndex: number; // Index within that source file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for hooks with source tracking
|
||||||
|
*/
|
||||||
|
export type HookWithSource =
|
||||||
|
| HookMatcherWithSource
|
||||||
|
| SimpleHookMatcherWithSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all hook matchers for a tool event with source tracking
|
||||||
|
*/
|
||||||
|
export function loadMatchersWithSource(
|
||||||
|
event: ToolHookEvent,
|
||||||
workingDirectory: string = process.cwd(),
|
workingDirectory: string = process.cwd(),
|
||||||
): HookMatcherWithSource[] {
|
): HookMatcherWithSource[] {
|
||||||
const result: HookMatcherWithSource[] = [];
|
const result: HookMatcherWithSource[] = [];
|
||||||
|
|
||||||
// Load from each location and tag with source
|
|
||||||
const locations: SaveLocation[] = ["project-local", "project", "user"];
|
const locations: SaveLocation[] = ["project-local", "project", "user"];
|
||||||
|
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const hooks = loadHooksFromLocation(location, workingDirectory);
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
const matchers = hooks[event] || [];
|
const matchers = (hooks[event] || []) as HookMatcher[];
|
||||||
|
|
||||||
|
for (let i = 0; i < matchers.length; i++) {
|
||||||
|
const matcher = matchers[i];
|
||||||
|
if (matcher) {
|
||||||
|
result.push({
|
||||||
|
...matcher,
|
||||||
|
source: location,
|
||||||
|
sourceIndex: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all hook matchers for a simple event with source tracking
|
||||||
|
*/
|
||||||
|
export function loadSimpleMatchersWithSource(
|
||||||
|
event: SimpleHookEvent,
|
||||||
|
workingDirectory: string = process.cwd(),
|
||||||
|
): SimpleHookMatcherWithSource[] {
|
||||||
|
const result: SimpleHookMatcherWithSource[] = [];
|
||||||
|
const locations: SaveLocation[] = ["project-local", "project", "user"];
|
||||||
|
|
||||||
|
for (const location of locations) {
|
||||||
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
|
const matchers = (hooks[event] || []) as SimpleHookMatcher[];
|
||||||
|
|
||||||
for (let i = 0; i < matchers.length; i++) {
|
for (let i = 0; i < matchers.length; i++) {
|
||||||
const matcher = matchers[i];
|
const matcher = matchers[i];
|
||||||
@@ -206,9 +310,19 @@ export function countTotalHooks(
|
|||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const hooks = loadHooksFromLocation(location, workingDirectory);
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
for (const event of Object.keys(hooks) as HookEvent[]) {
|
for (const event of Object.keys(hooks) as HookEvent[]) {
|
||||||
const matchers = hooks[event] || [];
|
if (isToolEvent(event)) {
|
||||||
for (const matcher of matchers) {
|
// Tool events have HookMatcher[] with nested hooks
|
||||||
count += matcher.hooks.length;
|
const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
count += matcher.hooks.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple events have SimpleHookMatcher[] with nested hooks
|
||||||
|
const matchers = (hooks[event as SimpleHookEvent] ||
|
||||||
|
[]) as SimpleHookMatcher[];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
count += matcher.hooks.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,9 +343,19 @@ export function countHooksForEvent(
|
|||||||
|
|
||||||
for (const location of locations) {
|
for (const location of locations) {
|
||||||
const hooks = loadHooksFromLocation(location, workingDirectory);
|
const hooks = loadHooksFromLocation(location, workingDirectory);
|
||||||
const matchers = hooks[event] || [];
|
if (isToolEvent(event)) {
|
||||||
for (const matcher of matchers) {
|
// Tool events have HookMatcher[] with nested hooks
|
||||||
count += matcher.hooks.length;
|
const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
count += matcher.hooks.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple events have SimpleHookMatcher[] with nested hooks
|
||||||
|
const matchers = (hooks[event as SimpleHookEvent] ||
|
||||||
|
[]) as SimpleHookMatcher[];
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
count += matcher.hooks.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
matchesTool,
|
matchesTool,
|
||||||
mergeHooksConfigs,
|
mergeHooksConfigs,
|
||||||
} from "../../hooks/loader";
|
} from "../../hooks/loader";
|
||||||
import type { HookEvent, HooksConfig } from "../../hooks/types";
|
import {
|
||||||
|
type HookEvent,
|
||||||
|
type HooksConfig,
|
||||||
|
isToolEvent,
|
||||||
|
type SimpleHookEvent,
|
||||||
|
type ToolHookEvent,
|
||||||
|
} from "../../hooks/types";
|
||||||
import { settingsManager } from "../../settings-manager";
|
import { settingsManager } from "../../settings-manager";
|
||||||
|
|
||||||
describe("Hooks Loader", () => {
|
describe("Hooks Loader", () => {
|
||||||
@@ -251,11 +257,9 @@ describe("Hooks Loader", () => {
|
|||||||
|
|
||||||
test("handles undefined tool name (for non-tool events)", () => {
|
test("handles undefined tool name (for non-tool events)", () => {
|
||||||
const config: HooksConfig = {
|
const config: HooksConfig = {
|
||||||
|
// Simple events use SimpleHookMatcher[] (hooks wrapper, no matcher)
|
||||||
SessionStart: [
|
SessionStart: [
|
||||||
{
|
{ hooks: [{ type: "command", command: "session hook" }] },
|
||||||
matcher: "*",
|
|
||||||
hooks: [{ type: "command", command: "session hook" }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -367,12 +371,20 @@ describe("Hooks Loader", () => {
|
|||||||
test("config can have all 11 event types", () => {
|
test("config can have all 11 event types", () => {
|
||||||
const config: HooksConfig = {};
|
const config: HooksConfig = {};
|
||||||
for (const event of allEvents) {
|
for (const event of allEvents) {
|
||||||
config[event] = [
|
if (isToolEvent(event)) {
|
||||||
{
|
// Tool events use HookMatcher[]
|
||||||
matcher: "*",
|
(config as Record<ToolHookEvent, unknown>)[event as ToolHookEvent] = [
|
||||||
hooks: [{ type: "command", command: `echo ${event}` }],
|
{
|
||||||
},
|
matcher: "*",
|
||||||
];
|
hooks: [{ type: "command", command: `echo ${event}` }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Simple events use SimpleHookMatcher[] (hooks wrapper)
|
||||||
|
(config as Record<SimpleHookEvent, unknown>)[
|
||||||
|
event as SimpleHookEvent
|
||||||
|
] = [{ hooks: [{ type: "command", command: `echo ${event}` }] }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of allEvents) {
|
for (const event of allEvents) {
|
||||||
@@ -384,21 +396,21 @@ describe("Hooks Loader", () => {
|
|||||||
|
|
||||||
test("merging preserves all event types", () => {
|
test("merging preserves all event types", () => {
|
||||||
const global: HooksConfig = {
|
const global: HooksConfig = {
|
||||||
|
// Tool events use HookMatcher[]
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "g1" }] },
|
{ matcher: "*", hooks: [{ type: "command", command: "g1" }] },
|
||||||
],
|
],
|
||||||
SessionStart: [
|
// Simple events use SimpleHookMatcher[] (hooks wrapper)
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "g2" }] },
|
SessionStart: [{ hooks: [{ type: "command", command: "g2" }] }],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const project: HooksConfig = {
|
const project: HooksConfig = {
|
||||||
|
// Tool events use HookMatcher[]
|
||||||
PostToolUse: [
|
PostToolUse: [
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "p1" }] },
|
{ matcher: "*", hooks: [{ type: "command", command: "p1" }] },
|
||||||
],
|
],
|
||||||
SessionEnd: [
|
// Simple events use SimpleHookMatcher[] (hooks wrapper)
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "p2" }] },
|
SessionEnd: [{ hooks: [{ type: "command", command: "p2" }] }],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const merged = mergeHooksConfigs(global, project);
|
const merged = mergeHooksConfigs(global, project);
|
||||||
@@ -503,14 +515,15 @@ describe("Hooks Loader", () => {
|
|||||||
|
|
||||||
test("all three levels merge correctly", () => {
|
test("all three levels merge correctly", () => {
|
||||||
const global: HooksConfig = {
|
const global: HooksConfig = {
|
||||||
|
// Tool event with HookMatcher[]
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "global" }] },
|
{ matcher: "*", hooks: [{ type: "command", command: "global" }] },
|
||||||
],
|
],
|
||||||
SessionEnd: [
|
// Simple event with SimpleHookMatcher[]
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "global-end" }] },
|
SessionEnd: [{ hooks: [{ type: "command", command: "global-end" }] }],
|
||||||
],
|
|
||||||
};
|
};
|
||||||
const project: HooksConfig = {
|
const project: HooksConfig = {
|
||||||
|
// Tool event with HookMatcher[]
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "project" }] },
|
{ matcher: "*", hooks: [{ type: "command", command: "project" }] },
|
||||||
],
|
],
|
||||||
@@ -522,14 +535,13 @@ describe("Hooks Loader", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const projectLocal: HooksConfig = {
|
const projectLocal: HooksConfig = {
|
||||||
|
// Tool event with HookMatcher[]
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{ matcher: "*", hooks: [{ type: "command", command: "local" }] },
|
{ matcher: "*", hooks: [{ type: "command", command: "local" }] },
|
||||||
],
|
],
|
||||||
|
// Simple event with SimpleHookMatcher[]
|
||||||
SessionStart: [
|
SessionStart: [
|
||||||
{
|
{ hooks: [{ type: "command", command: "local-start" }] },
|
||||||
matcher: "*",
|
|
||||||
hooks: [{ type: "command", command: "local-start" }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -535,17 +535,16 @@ describe("Settings Manager - Hooks", () => {
|
|||||||
test("Hooks configuration persists to disk", async () => {
|
test("Hooks configuration persists to disk", async () => {
|
||||||
settingsManager.updateSettings({
|
settingsManager.updateSettings({
|
||||||
hooks: {
|
hooks: {
|
||||||
|
// Tool event with HookMatcher[]
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: "*",
|
matcher: "*",
|
||||||
hooks: [{ type: "command", command: "echo persisted" }],
|
hooks: [{ type: "command", command: "echo persisted" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Simple event with SimpleHookMatcher[]
|
||||||
SessionStart: [
|
SessionStart: [
|
||||||
{
|
{ hooks: [{ type: "command", command: "echo session" }] },
|
||||||
matcher: "*",
|
|
||||||
hooks: [{ type: "command", command: "echo session" }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -594,11 +593,9 @@ describe("Settings Manager - Hooks", () => {
|
|||||||
settingsManager.updateLocalProjectSettings(
|
settingsManager.updateLocalProjectSettings(
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
|
// Simple event uses SimpleHookMatcher[] (hooks wrapper)
|
||||||
UserPromptSubmit: [
|
UserPromptSubmit: [
|
||||||
{
|
{ hooks: [{ type: "command", command: "echo local-hook" }] },
|
||||||
matcher: "*",
|
|
||||||
hooks: [{ type: "command", command: "echo local-hook" }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -616,12 +613,8 @@ describe("Settings Manager - Hooks", () => {
|
|||||||
settingsManager.updateLocalProjectSettings(
|
settingsManager.updateLocalProjectSettings(
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
Stop: [
|
// Simple event uses SimpleHookMatcher[] (hooks wrapper)
|
||||||
{
|
Stop: [{ hooks: [{ type: "command", command: "echo stop-hook" }] }],
|
||||||
matcher: "*",
|
|
||||||
hooks: [{ type: "command", command: "echo stop-hook" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
testProjectDir,
|
testProjectDir,
|
||||||
@@ -637,6 +630,7 @@ describe("Settings Manager - Hooks", () => {
|
|||||||
await settingsManager.loadLocalProjectSettings(testProjectDir);
|
await settingsManager.loadLocalProjectSettings(testProjectDir);
|
||||||
|
|
||||||
expect(reloaded.hooks?.Stop).toHaveLength(1);
|
expect(reloaded.hooks?.Stop).toHaveLength(1);
|
||||||
|
// Simple event hooks are in SimpleHookMatcher format with hooks array
|
||||||
expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook");
|
expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user