mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: implement Claude Code-style hooks system
Add lifecycle hooks for tool execution with 12 event types: - PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest - SessionStart, SessionEnd, Stop, SubagentStart, SubagentStop - UserPromptSubmit, Notification, PreCompact Core implementation: - HookManager service with config loading, validation, and execution - HookExecutor for shell command execution with timeout, stdin JSON, env vars - HookConfigLoader with project/global/mode-specific precedence - HookMatcher for pattern matching (exact, regex, glob) - ToolExecutionHooks adapter for pipeline interception Tool pipeline integration: - PreToolUse hooks can block or modify tool input - PostToolUse/PostToolUseFailure for non-blocking notifications - PermissionRequest hooks before approval prompts Webview UI: - Hooks tab in Settings with enable/disable toggles - Hook Activity log with real-time status updates - Reload config and open folder actions Includes comprehensive tests for all components. Relates to: Claude Code hooks feature parity
This commit is contained in:
parent
f48ea389df
commit
22eda99812
23 changed files with 5589 additions and 32 deletions
|
|
@ -95,6 +95,7 @@ export interface ExtensionMessage {
|
|||
| "customToolsResult"
|
||||
| "modes"
|
||||
| "taskWithAggregatedCosts"
|
||||
| "hookExecutionStatus"
|
||||
text?: string
|
||||
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
checkpointWarning?: {
|
||||
|
|
@ -190,6 +191,96 @@ export interface ExtensionMessage {
|
|||
childrenCost: number
|
||||
}
|
||||
historyItem?: HistoryItem
|
||||
hookExecutionStatus?: HookExecutionStatusPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* HookExecutionStatusPayload
|
||||
* Sent when hook execution starts, completes, or fails.
|
||||
*/
|
||||
export interface HookExecutionStatusPayload {
|
||||
/** Status of the hook execution */
|
||||
status: "running" | "completed" | "failed" | "blocked"
|
||||
/** Event type that triggered the hook */
|
||||
event: string
|
||||
/** Tool name if this is a tool-related event */
|
||||
toolName?: string
|
||||
/** Hook ID being executed */
|
||||
hookId?: string
|
||||
/** Duration in milliseconds (only for completed/failed) */
|
||||
duration?: number
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
/** Block message if hook blocked the operation */
|
||||
blockMessage?: string
|
||||
/** Whether tool input was modified */
|
||||
modified?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable hook information for webview display.
|
||||
* This is a subset of ResolvedHook that can be safely serialized to JSON.
|
||||
*/
|
||||
export interface HookInfo {
|
||||
/** Unique identifier for this hook */
|
||||
id: string
|
||||
/** The event type this hook is registered for */
|
||||
event: string
|
||||
/** Tool name filter (regex/glob pattern) */
|
||||
matcher?: string
|
||||
/** Preview of the command (truncated for display) */
|
||||
commandPreview: string
|
||||
/** Whether this hook is enabled */
|
||||
enabled: boolean
|
||||
/** Source of this hook configuration */
|
||||
source: "project" | "mode" | "global"
|
||||
/** Timeout in seconds */
|
||||
timeout: number
|
||||
/** Override shell if specified */
|
||||
shell?: string
|
||||
/** Human-readable description */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable hook execution record for webview display.
|
||||
*/
|
||||
export interface HookExecutionRecord {
|
||||
/** When the hook was executed (ISO string) */
|
||||
timestamp: string
|
||||
/** The hook ID that was executed */
|
||||
hookId: string
|
||||
/** The event that triggered execution */
|
||||
event: string
|
||||
/** Tool name if this was a tool-related event */
|
||||
toolName?: string
|
||||
/** Exit code from the process */
|
||||
exitCode: number | null
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number
|
||||
/** Whether the hook timed out */
|
||||
timedOut: boolean
|
||||
/** Whether the hook blocked execution */
|
||||
blocked: boolean
|
||||
/** Error message if the hook failed */
|
||||
error?: string
|
||||
/** Block message if the hook blocked */
|
||||
blockMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks state for webview display.
|
||||
* Contains all information needed to render the Hooks settings tab.
|
||||
*/
|
||||
export interface HooksState {
|
||||
/** Array of resolved hooks with display information */
|
||||
enabledHooks: HookInfo[]
|
||||
/** Recent execution history (last N records) */
|
||||
executionHistory: HookExecutionRecord[]
|
||||
/** Whether project-level hooks are present (for security warnings) */
|
||||
hasProjectHooks: boolean
|
||||
/** When the config snapshot was last loaded (ISO string) */
|
||||
snapshotTimestamp?: string
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
|
|
@ -335,6 +426,9 @@ export type ExtensionState = Pick<
|
|||
claudeCodeIsAuthenticated?: boolean
|
||||
openAiCodexIsAuthenticated?: boolean
|
||||
debug?: boolean
|
||||
|
||||
/** Hooks configuration and execution state for the Hooks settings tab */
|
||||
hooks?: HooksState
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
|
|
@ -521,6 +615,9 @@ export interface WebviewMessage {
|
|||
| "requestModes"
|
||||
| "switchMode"
|
||||
| "debugSetting"
|
||||
| "hooksReloadConfig"
|
||||
| "hooksSetEnabled"
|
||||
| "hooksOpenConfigFolder"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
|
||||
|
|
@ -576,6 +673,9 @@ export interface WebviewMessage {
|
|||
list?: string[] // For dismissedUpsells response
|
||||
organizationId?: string | null // For organization switching
|
||||
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
|
||||
hookId?: string // For hooksSetEnabled
|
||||
hookEnabled?: boolean // For hooksSetEnabled
|
||||
hooksSource?: "global" | "project" // For hooksOpenConfigFolder
|
||||
codeIndexSettings?: {
|
||||
// Global state settings
|
||||
codebaseIndexEnabled: boolean
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
|
||||
// Mock dependencies that are noisy / unrelated to these tests
|
||||
vi.mock("../../task/Task")
|
||||
vi.mock("../../tools/validateToolUse", () => ({
|
||||
validateToolUse: vi.fn(),
|
||||
}))
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
TelemetryService: {
|
||||
instance: {
|
||||
captureToolUsage: vi.fn(),
|
||||
captureConsecutiveMistakeError: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock a tool that uses askApproval so we can exercise the PermissionRequest integration.
|
||||
vi.mock("../../tools/ListFilesTool", () => ({
|
||||
listFilesTool: {
|
||||
handle: vi.fn(async (_task: any, block: any, callbacks: any) => {
|
||||
// Allow tests to trigger a failure path without real side effects.
|
||||
if (block?.params?.path === "FAIL") {
|
||||
await callbacks.handleError("listing files", new Error("boom"))
|
||||
return
|
||||
}
|
||||
|
||||
const didApprove = await callbacks.askApproval("tool", `list_files:${String(block?.params?.path ?? "")}`)
|
||||
if (!didApprove) {
|
||||
return
|
||||
}
|
||||
callbacks.pushToolResult("ok")
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
let presentAssistantMessage: (task: any) => Promise<void>
|
||||
|
||||
describe("presentAssistantMessage - hooks integration", () => {
|
||||
let mockTask: any
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!presentAssistantMessage) {
|
||||
;({ presentAssistantMessage } = await import("../presentAssistantMessage"))
|
||||
}
|
||||
|
||||
mockTask = {
|
||||
taskId: "test-task-id",
|
||||
instanceId: "test-instance",
|
||||
cwd: "/project",
|
||||
abort: false,
|
||||
presentAssistantMessageLocked: false,
|
||||
presentAssistantMessageHasPendingUpdates: false,
|
||||
currentStreamingContentIndex: 0,
|
||||
assistantMessageContent: [],
|
||||
userMessageContent: [],
|
||||
didCompleteReadingStream: false,
|
||||
didRejectTool: false,
|
||||
didAlreadyUseTool: false,
|
||||
diffEnabled: false,
|
||||
consecutiveMistakeCount: 0,
|
||||
api: {
|
||||
getModel: () => ({ id: "test-model", info: {} }),
|
||||
},
|
||||
browserSession: {
|
||||
closeBrowser: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
recordToolUsage: vi.fn(),
|
||||
toolRepetitionDetector: {
|
||||
check: vi.fn().mockReturnValue({ allowExecution: true }),
|
||||
},
|
||||
toolExecutionHooks: {
|
||||
executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
|
||||
executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
|
||||
executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
|
||||
executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
|
||||
},
|
||||
providerRef: {
|
||||
deref: () => ({
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "code",
|
||||
customModes: [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
say: vi.fn().mockResolvedValue(undefined),
|
||||
// ask() is called by presentAssistantMessage via askApproval
|
||||
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
|
||||
}
|
||||
|
||||
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
|
||||
const existingResult = mockTask.userMessageContent.find(
|
||||
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
|
||||
)
|
||||
if (existingResult) {
|
||||
return false
|
||||
}
|
||||
mockTask.userMessageContent.push(toolResult)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
it("PreToolUse can block execution", async () => {
|
||||
const toolCallId = "tool_call_block"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "." },
|
||||
},
|
||||
]
|
||||
|
||||
mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({
|
||||
proceed: false,
|
||||
blockReason: "nope",
|
||||
hookResult: { results: [], blocked: true, totalDuration: 1 },
|
||||
})
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1)
|
||||
// Should not even attempt to show approval prompt
|
||||
expect(mockTask.toolExecutionHooks.executePermissionRequest).not.toHaveBeenCalled()
|
||||
// Should emit a tool_result (native protocol) with the denial message
|
||||
const toolResult = mockTask.userMessageContent.find(
|
||||
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
|
||||
)
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult.content).toContain("nope")
|
||||
})
|
||||
|
||||
it("PreToolUse can modify tool input", async () => {
|
||||
const toolCallId = "tool_call_modify"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "original" },
|
||||
},
|
||||
]
|
||||
|
||||
mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({
|
||||
proceed: true,
|
||||
modifiedInput: { path: "modified" },
|
||||
hookResult: { results: [], blocked: false, totalDuration: 1 },
|
||||
})
|
||||
|
||||
let askedPartialMessage: string | undefined
|
||||
mockTask.ask = vi.fn().mockImplementation(async (_type: string, partialMessage?: string) => {
|
||||
askedPartialMessage = partialMessage
|
||||
return { response: "yesButtonClicked" }
|
||||
})
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1)
|
||||
// list_files should invoke askApproval via our mock; its message should contain the modified path.
|
||||
expect(askedPartialMessage).toContain("modified")
|
||||
})
|
||||
|
||||
it("PostToolUse is invoked on success", async () => {
|
||||
const toolCallId = "tool_call_post_success"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "." },
|
||||
},
|
||||
]
|
||||
|
||||
mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
expect(mockTask.toolExecutionHooks.executePostToolUse).toHaveBeenCalledTimes(1)
|
||||
expect(mockTask.toolExecutionHooks.executePostToolUseFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("PostToolUseFailure is invoked on tool failure", async () => {
|
||||
const toolCallId = "tool_call_post_failure"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "FAIL" },
|
||||
},
|
||||
]
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
expect(mockTask.toolExecutionHooks.executePostToolUseFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("PermissionRequest hook runs before approval prompt", async () => {
|
||||
const toolCallId = "tool_call_permission_request"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "." },
|
||||
},
|
||||
]
|
||||
|
||||
const calls: string[] = []
|
||||
|
||||
mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockImplementation(async () => {
|
||||
calls.push("permission")
|
||||
return { proceed: true, hookResult: {} }
|
||||
})
|
||||
|
||||
mockTask.ask = vi.fn().mockImplementation(async () => {
|
||||
calls.push("ask")
|
||||
return { response: "yesButtonClicked" }
|
||||
})
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
expect(calls[0]).toBe("permission")
|
||||
expect(calls[1]).toBe("ask")
|
||||
})
|
||||
|
||||
it("PermissionRequest hook can block showing approval prompt", async () => {
|
||||
const toolCallId = "tool_call_permission_block"
|
||||
mockTask.assistantMessageContent = [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "list_files",
|
||||
params: { path: "." },
|
||||
},
|
||||
]
|
||||
|
||||
mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockResolvedValue({
|
||||
proceed: false,
|
||||
blockReason: "blocked by policy",
|
||||
hookResult: { results: [], blocked: true, totalDuration: 1 },
|
||||
})
|
||||
|
||||
await presentAssistantMessage(mockTask)
|
||||
|
||||
// ask() should never be called if hook blocks the permission prompt
|
||||
expect(mockTask.ask).not.toHaveBeenCalled()
|
||||
|
||||
const toolResult = mockTask.userMessageContent.find(
|
||||
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
|
||||
)
|
||||
expect(toolResult).toBeDefined()
|
||||
expect(toolResult.content).toContain("blocked by policy")
|
||||
})
|
||||
})
|
||||
|
|
@ -28,6 +28,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
|
|||
mockTask = {
|
||||
taskId: "test-task-id",
|
||||
instanceId: "test-instance",
|
||||
cwd: "/project",
|
||||
abort: false,
|
||||
presentAssistantMessageLocked: false,
|
||||
presentAssistantMessageHasPendingUpdates: false,
|
||||
|
|
@ -49,6 +50,12 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
|
|||
toolRepetitionDetector: {
|
||||
check: vi.fn().mockReturnValue({ allowExecution: true }),
|
||||
},
|
||||
toolExecutionHooks: {
|
||||
executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
|
||||
executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
|
||||
executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
|
||||
executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
|
||||
},
|
||||
providerRef: {
|
||||
deref: () => ({
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -376,6 +376,25 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
const state = await cline.providerRef.deref()?.getState()
|
||||
const { mode, customModes, experiments: stateExperiments } = state ?? {}
|
||||
|
||||
const buildToolExecutionHookContext = (toolName: string, toolInput: Record<string, unknown>) => {
|
||||
const projectDirectory = cline.cwd
|
||||
const projectName = projectDirectory.split(/[/\\]/).filter(Boolean).pop() ?? projectDirectory
|
||||
|
||||
return {
|
||||
toolName,
|
||||
toolInput,
|
||||
session: {
|
||||
taskId: cline.taskId,
|
||||
sessionId: cline.instanceId,
|
||||
mode: mode ?? defaultModeSlug,
|
||||
},
|
||||
project: {
|
||||
directory: projectDirectory,
|
||||
name: projectName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const toolDescription = (): string => {
|
||||
switch (block.name) {
|
||||
case "execute_command":
|
||||
|
|
@ -509,6 +528,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
|
||||
// Track if we've already pushed a tool result for this tool call (native protocol only)
|
||||
let hasToolResult = false
|
||||
let toolOutputForHooks: ToolResponse | undefined
|
||||
|
||||
// Determine protocol by checking if this tool call has an ID.
|
||||
// Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks).
|
||||
|
|
@ -524,6 +544,13 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
let approvalFeedback: { text: string; images?: string[] } | undefined
|
||||
|
||||
const pushToolResult = (content: ToolResponse) => {
|
||||
// Capture the final tool output for PostToolUse hooks.
|
||||
// Note: This captures the first tool_result emitted by the tool.
|
||||
// Tools generally emit a single tool result; if a tool emits multiple, we only retain the first.
|
||||
if (toolOutputForHooks === undefined) {
|
||||
toolOutputForHooks = content
|
||||
}
|
||||
|
||||
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
|
||||
// For native protocol, only allow ONE tool_result per tool call
|
||||
if (hasToolResult) {
|
||||
|
|
@ -638,12 +665,41 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
// allow multiple tool calls in sequence (don't set didAlreadyUseTool)
|
||||
}
|
||||
|
||||
let toolDeniedByHook = false
|
||||
let toolDeniedByUser = false
|
||||
let toolExecutionHadFailure = false
|
||||
let toolFailureAction: string | undefined
|
||||
let toolFailureMessage: string | undefined
|
||||
let toolInputForHooks = (block.nativeArgs ?? block.params ?? {}) as Record<string, unknown>
|
||||
let blockToExecute: any = block
|
||||
|
||||
const askApproval = async (
|
||||
type: ClineAsk,
|
||||
partialMessage?: string,
|
||||
progressStatus?: ToolProgressStatus,
|
||||
isProtected?: boolean,
|
||||
) => {
|
||||
// Hooks: PermissionRequest (blocking)
|
||||
// This hook is invoked immediately before the user approval prompt is shown.
|
||||
// It must NOT bypass existing approval rules (it can only deny).
|
||||
if (!block.partial && !toolDeniedByHook) {
|
||||
const permissionRequest = await cline.toolExecutionHooks.executePermissionRequest(
|
||||
buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks),
|
||||
)
|
||||
|
||||
if (!permissionRequest.proceed) {
|
||||
toolDeniedByHook = true
|
||||
pushToolResult(
|
||||
formatResponse.toolDeniedWithFeedback(
|
||||
permissionRequest.blockReason ?? "Blocked by PermissionRequest hook",
|
||||
toolProtocol,
|
||||
),
|
||||
)
|
||||
cline.didRejectTool = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const { response, text, images } = await cline.ask(
|
||||
type,
|
||||
partialMessage,
|
||||
|
|
@ -654,6 +710,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// Handle both messageResponse and noButtonClicked with text.
|
||||
toolDeniedByUser = true
|
||||
if (text) {
|
||||
await cline.say("user_feedback", text, images)
|
||||
pushToolResult(
|
||||
|
|
@ -696,6 +753,9 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
return
|
||||
}
|
||||
const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
|
||||
toolExecutionHadFailure = true
|
||||
toolFailureAction = action
|
||||
toolFailureMessage = error.message ?? errorString
|
||||
|
||||
await cline.say(
|
||||
"error",
|
||||
|
|
@ -869,10 +929,39 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
}
|
||||
|
||||
switch (block.name) {
|
||||
// Hooks: PreToolUse (blocking / may modify tool input)
|
||||
if (!block.partial) {
|
||||
const pre = await cline.toolExecutionHooks.executePreToolUse(
|
||||
buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks),
|
||||
)
|
||||
|
||||
if (!pre.proceed) {
|
||||
pushToolResult(
|
||||
formatResponse.toolDeniedWithFeedback(
|
||||
pre.blockReason ?? "Blocked by PreToolUse hook",
|
||||
toolProtocol,
|
||||
),
|
||||
)
|
||||
cline.didRejectTool = true
|
||||
break
|
||||
}
|
||||
|
||||
if (pre.modifiedInput) {
|
||||
toolInputForHooks = pre.modifiedInput
|
||||
blockToExecute = {
|
||||
...block,
|
||||
params: pre.modifiedInput,
|
||||
nativeArgs: block.nativeArgs ? pre.modifiedInput : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolExecutionStart = Date.now()
|
||||
|
||||
switch (blockToExecute.name) {
|
||||
case "write_to_file":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, {
|
||||
await writeToFileTool.handle(cline, blockToExecute as ToolUse<"write_to_file">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -881,7 +970,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "update_todo_list":
|
||||
await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, {
|
||||
await updateTodoListTool.handle(cline, blockToExecute as ToolUse<"update_todo_list">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -895,7 +984,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
// Check if this tool call came from native protocol by checking for ID
|
||||
// Native calls always have IDs, XML calls never do
|
||||
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
|
||||
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
||||
await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -918,9 +1007,16 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
|
||||
if (isMultiFileApplyDiffEnabled) {
|
||||
await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
|
||||
await applyDiffTool(
|
||||
cline,
|
||||
blockToExecute,
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
removeClosingTag,
|
||||
)
|
||||
} else {
|
||||
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
||||
await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -932,7 +1028,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
case "search_and_replace":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
|
||||
await searchAndReplaceTool.handle(cline, blockToExecute as ToolUse<"search_and_replace">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -942,7 +1038,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
case "search_replace":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, {
|
||||
await searchReplaceTool.handle(cline, blockToExecute as ToolUse<"search_replace">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -952,7 +1048,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
case "edit_file":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await editFileTool.handle(cline, block as ToolUse<"edit_file">, {
|
||||
await editFileTool.handle(cline, blockToExecute as ToolUse<"edit_file">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -962,7 +1058,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
case "apply_patch":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, {
|
||||
await applyPatchTool.handle(cline, blockToExecute as ToolUse<"apply_patch">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -972,7 +1068,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
case "read_file":
|
||||
// Type assertion is safe here because we're in the "read_file" case
|
||||
await readFileTool.handle(cline, block as ToolUse<"read_file">, {
|
||||
await readFileTool.handle(cline, blockToExecute as ToolUse<"read_file">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -981,7 +1077,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "fetch_instructions":
|
||||
await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, {
|
||||
await fetchInstructionsTool.handle(cline, blockToExecute as ToolUse<"fetch_instructions">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -990,7 +1086,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "list_files":
|
||||
await listFilesTool.handle(cline, block as ToolUse<"list_files">, {
|
||||
await listFilesTool.handle(cline, blockToExecute as ToolUse<"list_files">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -999,7 +1095,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "codebase_search":
|
||||
await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, {
|
||||
await codebaseSearchTool.handle(cline, blockToExecute as ToolUse<"codebase_search">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1008,7 +1104,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "search_files":
|
||||
await searchFilesTool.handle(cline, block as ToolUse<"search_files">, {
|
||||
await searchFilesTool.handle(cline, blockToExecute as ToolUse<"search_files">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1019,7 +1115,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
case "browser_action":
|
||||
await browserActionTool(
|
||||
cline,
|
||||
block as ToolUse<"browser_action">,
|
||||
blockToExecute as ToolUse<"browser_action">,
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1027,7 +1123,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
)
|
||||
break
|
||||
case "execute_command":
|
||||
await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, {
|
||||
await executeCommandTool.handle(cline, blockToExecute as ToolUse<"execute_command">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1036,7 +1132,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "use_mcp_tool":
|
||||
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
|
||||
await useMcpToolTool.handle(cline, blockToExecute as ToolUse<"use_mcp_tool">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1045,7 +1141,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "access_mcp_resource":
|
||||
await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, {
|
||||
await accessMcpResourceTool.handle(cline, blockToExecute as ToolUse<"access_mcp_resource">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1054,7 +1150,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "ask_followup_question":
|
||||
await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, {
|
||||
await askFollowupQuestionTool.handle(cline, blockToExecute as ToolUse<"ask_followup_question">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1063,7 +1159,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "switch_mode":
|
||||
await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, {
|
||||
await switchModeTool.handle(cline, blockToExecute as ToolUse<"switch_mode">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1072,13 +1168,13 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
})
|
||||
break
|
||||
case "new_task":
|
||||
await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
|
||||
await newTaskTool.handle(cline, blockToExecute as ToolUse<"new_task">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
removeClosingTag,
|
||||
toolProtocol,
|
||||
toolCallId: block.id,
|
||||
toolCallId: blockToExecute.id,
|
||||
})
|
||||
break
|
||||
case "attempt_completion": {
|
||||
|
|
@ -1093,13 +1189,13 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
await attemptCompletionTool.handle(
|
||||
cline,
|
||||
block as ToolUse<"attempt_completion">,
|
||||
blockToExecute as ToolUse<"attempt_completion">,
|
||||
completionCallbacks,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "run_slash_command":
|
||||
await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, {
|
||||
await runSlashCommandTool.handle(cline, blockToExecute as ToolUse<"run_slash_command">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1109,7 +1205,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
case "generate_image":
|
||||
await checkpointSaveAndMark(cline)
|
||||
await generateImageTool.handle(cline, block as ToolUse<"generate_image">, {
|
||||
await generateImageTool.handle(cline, blockToExecute as ToolUse<"generate_image">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
|
|
@ -1128,7 +1224,9 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
}
|
||||
|
||||
const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined
|
||||
const customTool = stateExperiments?.customTools
|
||||
? customToolRegistry.get(blockToExecute.name)
|
||||
: undefined
|
||||
|
||||
if (customTool) {
|
||||
try {
|
||||
|
|
@ -1138,7 +1236,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
try {
|
||||
customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
|
||||
} catch (parseParamsError) {
|
||||
const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
|
||||
const message = `Custom tool "${blockToExecute.name}" argument validation failed: ${parseParamsError.message}`
|
||||
console.error(message)
|
||||
cline.consecutiveMistakeCount++
|
||||
await cline.say("error", message)
|
||||
|
|
@ -1162,17 +1260,17 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
cline.consecutiveMistakeCount++
|
||||
// Record custom tool error with static name
|
||||
cline.recordToolError("custom_tool", executionError.message)
|
||||
await handleError(`executing custom tool "${block.name}"`, executionError)
|
||||
await handleError(`executing custom tool "${blockToExecute.name}"`, executionError)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// Not a custom tool - handle as unknown tool error
|
||||
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
|
||||
const errorMessage = `Unknown tool "${blockToExecute.name}". This tool does not exist. Please use one of the available tools.`
|
||||
cline.consecutiveMistakeCount++
|
||||
cline.recordToolError(block.name as ToolName, errorMessage)
|
||||
await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
|
||||
cline.recordToolError(blockToExecute.name as ToolName, errorMessage)
|
||||
await cline.say("error", t("tools:unknownToolError", { toolName: blockToExecute.name }))
|
||||
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
|
||||
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
|
||||
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
|
||||
|
|
@ -1189,6 +1287,30 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
}
|
||||
|
||||
// Hooks: PostToolUse / PostToolUseFailure (non-blocking)
|
||||
if (!block.partial && !toolDeniedByHook && !toolDeniedByUser) {
|
||||
const duration = Date.now() - toolExecutionStart
|
||||
const finalToolInput = (blockToExecute.nativeArgs ?? blockToExecute.params ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const hookContext = buildToolExecutionHookContext(blockToExecute.name, finalToolInput)
|
||||
|
||||
if (toolExecutionHadFailure) {
|
||||
void cline.toolExecutionHooks
|
||||
.executePostToolUseFailure(
|
||||
hookContext,
|
||||
toolFailureAction ?? "tool_error",
|
||||
toolFailureMessage ?? "Tool execution failed",
|
||||
)
|
||||
.catch(() => {})
|
||||
} else {
|
||||
void cline.toolExecutionHooks
|
||||
.executePostToolUse(hookContext, toolOutputForHooks, duration)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession"
|
|||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
|
||||
import { ToolExecutionHooks, createToolExecutionHooks } from "../../services/hooks"
|
||||
|
||||
// integrations
|
||||
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
|
||||
|
|
@ -334,6 +335,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Computer User
|
||||
browserSession: BrowserSession
|
||||
|
||||
// Hooks
|
||||
toolExecutionHooks: ToolExecutionHooks
|
||||
|
||||
// Editing
|
||||
diffViewProvider: DiffViewProvider
|
||||
diffStrategy?: DiffStrategy
|
||||
|
|
@ -535,6 +539,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize tool execution hooks
|
||||
this.toolExecutionHooks = createToolExecutionHooks(provider.getHookManager() ?? null, (status) =>
|
||||
provider.postHookStatusToWebview(status),
|
||||
)
|
||||
|
||||
this.diffEnabled = enableDiff
|
||||
this.fuzzyMatchThreshold = fuzzyMatchThreshold
|
||||
this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import { CodeIndexManager } from "../../services/code-index/manager"
|
|||
import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
|
||||
import { MdmService } from "../../services/mdm/MdmService"
|
||||
import { SkillsManager } from "../../services/skills/SkillsManager"
|
||||
import { HookManager, createHookManager, type IHookManager } from "../../services/hooks"
|
||||
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
|
||||
|
|
@ -142,6 +143,7 @@ export class ClineProvider
|
|||
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
|
||||
protected mcpHub?: McpHub // Change from private to protected
|
||||
protected skillsManager?: SkillsManager
|
||||
protected hookManager?: IHookManager
|
||||
private marketplaceManager: MarketplaceManager
|
||||
private mdmService?: MdmService
|
||||
private taskCreationCallback: (task: Task) => void
|
||||
|
|
@ -208,6 +210,11 @@ export class ClineProvider
|
|||
this.log(`Failed to initialize Skills Manager: ${error}`)
|
||||
})
|
||||
|
||||
// Initialize Hook Manager for lifecycle hooks
|
||||
this.initializeHookManager().catch((error) => {
|
||||
this.log(`Failed to initialize Hook Manager: ${error}`)
|
||||
})
|
||||
|
||||
this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
|
||||
|
||||
// Forward <most> task events to the provider.
|
||||
|
|
@ -2217,6 +2224,58 @@ export class ClineProvider
|
|||
}
|
||||
})(),
|
||||
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
|
||||
|
||||
// Hooks state for settings tab
|
||||
hooks: this.getHooksStateForWebview(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build hooks state for webview from HookManager.
|
||||
* Converts internal types to serializable HooksState.
|
||||
*/
|
||||
private getHooksStateForWebview(): ExtensionState["hooks"] {
|
||||
if (!this.hookManager) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const snapshot = this.hookManager.getConfigSnapshot()
|
||||
const enabledHooks = this.hookManager.getEnabledHooks()
|
||||
const executionHistory = this.hookManager.getHookExecutionHistory()
|
||||
|
||||
// Convert ResolvedHook[] to HookInfo[]
|
||||
const hookInfos = enabledHooks.map((hook) => ({
|
||||
id: hook.id,
|
||||
event: hook.event,
|
||||
matcher: hook.matcher,
|
||||
commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command,
|
||||
enabled: hook.enabled ?? true,
|
||||
source: hook.source,
|
||||
timeout: hook.timeout ?? 60,
|
||||
shell: hook.shell,
|
||||
description: hook.description,
|
||||
}))
|
||||
|
||||
// Convert HookExecution[] to HookExecutionRecord[]
|
||||
// Limit to last 50 records for UI
|
||||
const executionRecords = executionHistory.slice(-50).map((exec) => ({
|
||||
timestamp: exec.timestamp.toISOString(),
|
||||
hookId: exec.hook.id,
|
||||
event: exec.event,
|
||||
toolName: exec.result.hook.matcher ? undefined : undefined, // Tool name is in context, not easily accessible here
|
||||
exitCode: exec.result.exitCode,
|
||||
duration: exec.result.duration,
|
||||
timedOut: exec.result.timedOut,
|
||||
blocked: exec.result.exitCode === 2,
|
||||
error: exec.result.error?.message,
|
||||
blockMessage: exec.result.exitCode === 2 ? exec.result.stderr : undefined,
|
||||
}))
|
||||
|
||||
return {
|
||||
enabledHooks: hookInfos,
|
||||
executionHistory: executionRecords,
|
||||
hasProjectHooks: snapshot?.hasProjectHooks ?? false,
|
||||
snapshotTimestamp: snapshot?.loadedAt?.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2582,6 +2641,85 @@ export class ClineProvider
|
|||
return this.mcpHub
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Hook Manager for lifecycle hooks.
|
||||
* This loads hooks configuration from project/.roo/hooks/ files.
|
||||
*/
|
||||
private async initializeHookManager(): Promise<void> {
|
||||
const cwd = this.currentWorkspacePath || getWorkspacePath()
|
||||
if (!cwd) {
|
||||
this.log("[HookManager] No workspace path available, hooks disabled")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await this.getState()
|
||||
this.hookManager = createHookManager({
|
||||
cwd,
|
||||
mode: state?.mode,
|
||||
logger: {
|
||||
debug: (msg: string) => this.log(`[Hooks/debug] ${msg}`),
|
||||
info: (msg: string) => this.log(`[Hooks/info] ${msg}`),
|
||||
warn: (msg: string) => this.log(`[Hooks/warn] ${msg}`),
|
||||
error: (msg: string) => this.log(`[Hooks/error] ${msg}`),
|
||||
},
|
||||
})
|
||||
|
||||
// Load hooks configuration
|
||||
await this.hookManager.loadHooksConfig()
|
||||
this.log("[HookManager] Hooks loaded successfully")
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
// Don't throw - hooks are optional
|
||||
this.hookManager = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Hook Manager instance.
|
||||
*/
|
||||
public getHookManager(): IHookManager | undefined {
|
||||
return this.hookManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the Hook Manager configuration.
|
||||
* Call this when hooks configuration files may have changed.
|
||||
*/
|
||||
public async reloadHooksConfig(): Promise<void> {
|
||||
if (this.hookManager) {
|
||||
try {
|
||||
await this.hookManager.reloadHooksConfig()
|
||||
this.log("[HookManager] Hooks reloaded successfully")
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[HookManager] Failed to reload hooks: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post hook execution status to webview.
|
||||
*/
|
||||
public postHookStatusToWebview(status: {
|
||||
status: "running" | "completed" | "failed" | "blocked"
|
||||
event: string
|
||||
toolName?: string
|
||||
hookId?: string
|
||||
duration?: number
|
||||
error?: string
|
||||
blockMessage?: string
|
||||
modified?: boolean
|
||||
}): void {
|
||||
this.postMessageToWebview({
|
||||
type: "hookExecutionStatus",
|
||||
hookExecutionStatus: status,
|
||||
})
|
||||
}
|
||||
|
||||
public getSkillsManager(): SkillsManager | undefined {
|
||||
return this.skillsManager
|
||||
}
|
||||
|
|
|
|||
350
src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts
Normal file
350
src/core/webview/__tests__/webviewMessageHandler.hooks.spec.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
// npx vitest run core/webview/__tests__/webviewMessageHandler.hooks.spec.ts
|
||||
|
||||
import type { IHookManager, ResolvedHook, HookExecution, HooksConfigSnapshot } from "../../../services/hooks/types"
|
||||
|
||||
// Mock vscode before importing webviewMessageHandler
|
||||
vi.mock("vscode", () => {
|
||||
const executeCommand = vi.fn().mockResolvedValue(undefined)
|
||||
const showInformationMessage = vi.fn()
|
||||
const showErrorMessage = vi.fn()
|
||||
|
||||
return {
|
||||
window: {
|
||||
showInformationMessage,
|
||||
showErrorMessage,
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
|
||||
},
|
||||
commands: {
|
||||
executeCommand,
|
||||
},
|
||||
Uri: {
|
||||
file: vi.fn((path: string) => ({ fsPath: path })),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("fs/promises", () => {
|
||||
const mockMkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
return {
|
||||
default: {
|
||||
mkdir: mockMkdir,
|
||||
},
|
||||
mkdir: mockMkdir,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../../../utils/fs", () => ({
|
||||
fileExistsAtPath: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock("../../../api/providers/fetchers/modelCache")
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as fsUtils from "../../../utils/fs"
|
||||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import type { ClineProvider } from "../ClineProvider"
|
||||
|
||||
// Create mock HookManager
|
||||
const createMockHookManager = (): IHookManager => ({
|
||||
loadHooksConfig: vi.fn().mockResolvedValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map(),
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
}),
|
||||
reloadHooksConfig: vi.fn().mockResolvedValue(undefined),
|
||||
getEnabledHooks: vi.fn().mockReturnValue([]),
|
||||
executeHooks: vi.fn().mockResolvedValue({
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}),
|
||||
setHookEnabled: vi.fn().mockResolvedValue(undefined),
|
||||
getHookExecutionHistory: vi.fn().mockReturnValue([]),
|
||||
getConfigSnapshot: vi.fn().mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map(),
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create mock ClineProvider
|
||||
const createMockClineProvider = (hookManager?: IHookManager) => {
|
||||
const mockProvider = {
|
||||
getState: vi.fn(),
|
||||
postMessageToWebview: vi.fn(),
|
||||
postStateToWebview: vi.fn(),
|
||||
getHookManager: vi.fn().mockReturnValue(hookManager),
|
||||
log: vi.fn(),
|
||||
getCurrentTask: vi.fn(),
|
||||
getTaskWithId: vi.fn(),
|
||||
createTaskWithHistoryItem: vi.fn(),
|
||||
cwd: "/mock/workspace",
|
||||
context: {
|
||||
extensionPath: "/mock/extension/path",
|
||||
globalStorageUri: { fsPath: "/mock/global/storage" },
|
||||
},
|
||||
contextProxy: {
|
||||
context: {
|
||||
extensionPath: "/mock/extension/path",
|
||||
globalStorageUri: { fsPath: "/mock/global/storage" },
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
getValue: vi.fn(),
|
||||
},
|
||||
customModesManager: {
|
||||
getCustomModes: vi.fn(),
|
||||
deleteCustomMode: vi.fn(),
|
||||
},
|
||||
} as unknown as ClineProvider
|
||||
|
||||
return mockProvider
|
||||
}
|
||||
|
||||
describe("webviewMessageHandler - hooks commands", () => {
|
||||
let mockHookManager: IHookManager
|
||||
let mockClineProvider: ClineProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHookManager = createMockHookManager()
|
||||
mockClineProvider = createMockClineProvider(mockHookManager)
|
||||
})
|
||||
|
||||
describe("hooksReloadConfig", () => {
|
||||
it("should call reloadHooksConfig and postStateToWebview when hookManager exists", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksReloadConfig",
|
||||
})
|
||||
|
||||
expect(mockHookManager.reloadHooksConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not throw when hookManager is undefined", async () => {
|
||||
const providerWithoutHookManager = createMockClineProvider(undefined)
|
||||
|
||||
await expect(
|
||||
webviewMessageHandler(providerWithoutHookManager, {
|
||||
type: "hooksReloadConfig",
|
||||
}),
|
||||
).resolves.not.toThrow()
|
||||
|
||||
expect(providerWithoutHookManager.postStateToWebview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should show error message when reloadHooksConfig fails", async () => {
|
||||
const error = new Error("Failed to load hooks config")
|
||||
vi.mocked(mockHookManager.reloadHooksConfig).mockRejectedValueOnce(error)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksReloadConfig",
|
||||
})
|
||||
|
||||
expect(mockClineProvider.log).toHaveBeenCalledWith(
|
||||
"Failed to reload hooks config: Failed to load hooks config",
|
||||
)
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to reload hooks configuration")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hooksSetEnabled", () => {
|
||||
it("should call setHookEnabled with correct parameters and postStateToWebview", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "test-hook-id",
|
||||
hookEnabled: false,
|
||||
})
|
||||
|
||||
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", false)
|
||||
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should enable a previously disabled hook", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "test-hook-id",
|
||||
hookEnabled: true,
|
||||
})
|
||||
|
||||
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", true)
|
||||
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not call setHookEnabled when hookId is missing", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookEnabled: true,
|
||||
} as any)
|
||||
|
||||
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
|
||||
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not call setHookEnabled when hookEnabled is not a boolean", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "test-hook-id",
|
||||
hookEnabled: "true", // string, not boolean
|
||||
} as any)
|
||||
|
||||
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
|
||||
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should show error message when setHookEnabled fails", async () => {
|
||||
const error = new Error("Hook not found")
|
||||
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "nonexistent-hook",
|
||||
hookEnabled: true,
|
||||
})
|
||||
|
||||
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to set hook enabled: Hook not found")
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to enable hook")
|
||||
})
|
||||
|
||||
it("should show correct error message when disabling fails", async () => {
|
||||
const error = new Error("Hook not found")
|
||||
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "nonexistent-hook",
|
||||
hookEnabled: false,
|
||||
})
|
||||
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to disable hook")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hooksOpenConfigFolder", () => {
|
||||
it("should open project hooks folder by default", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
})
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks")
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
|
||||
})
|
||||
|
||||
it("should open global hooks folder when source is global", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
hooksSource: "global",
|
||||
})
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith(expect.stringContaining(".roo/hooks"))
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
|
||||
})
|
||||
|
||||
it("should open project hooks folder when source is project", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
hooksSource: "project",
|
||||
})
|
||||
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks")
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
|
||||
})
|
||||
|
||||
it("should create hooks folder if it does not exist", async () => {
|
||||
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(false)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
})
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith("/mock/workspace/.roo/hooks", { recursive: true })
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
|
||||
})
|
||||
|
||||
it("should not create hooks folder if it already exists", async () => {
|
||||
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(true)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
})
|
||||
|
||||
expect(fs.mkdir).not.toHaveBeenCalled()
|
||||
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
|
||||
})
|
||||
|
||||
it("should show error message when open fails", async () => {
|
||||
vi.mocked(vscode.commands.executeCommand).mockRejectedValueOnce(new Error("Failed to open folder"))
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksOpenConfigFolder",
|
||||
})
|
||||
|
||||
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to open hooks folder: Failed to open folder")
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to open hooks configuration folder")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("webviewMessageHandler - hooks state integration", () => {
|
||||
it("should return hooks state when hookManager has data", () => {
|
||||
const mockResolvedHook: ResolvedHook = {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse",
|
||||
matcher: ".*",
|
||||
command: "echo 'hello'",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
shell: "/bin/bash",
|
||||
description: "Test hook",
|
||||
filePath: "/mock/workspace/.roo/hooks/pre-tool-use.json",
|
||||
includeConversationHistory: false,
|
||||
}
|
||||
|
||||
const mockExecutionHistory: HookExecution[] = [
|
||||
{
|
||||
timestamp: new Date(1642000000000),
|
||||
hook: mockResolvedHook,
|
||||
event: "PreToolUse",
|
||||
result: {
|
||||
hook: mockResolvedHook,
|
||||
exitCode: 0,
|
||||
stdout: "hello",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const mockHookManager = createMockHookManager()
|
||||
vi.mocked(mockHookManager.getEnabledHooks).mockReturnValue([mockResolvedHook])
|
||||
vi.mocked(mockHookManager.getHookExecutionHistory).mockReturnValue(mockExecutionHistory)
|
||||
|
||||
const hooksByEvent = new Map()
|
||||
hooksByEvent.set("PreToolUse", [mockResolvedHook])
|
||||
|
||||
const hooksById = new Map()
|
||||
hooksById.set("hook-1", mockResolvedHook)
|
||||
|
||||
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
|
||||
hooksByEvent,
|
||||
hooksById,
|
||||
loadedAt: new Date(1642000000000),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: true,
|
||||
})
|
||||
|
||||
// Verify mock data is correctly formatted
|
||||
expect(mockHookManager.getEnabledHooks()).toHaveLength(1)
|
||||
expect(mockHookManager.getHookExecutionHistory()).toHaveLength(1)
|
||||
expect(mockHookManager.getConfigSnapshot()?.hasProjectHooks).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -3336,6 +3336,74 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Hooks Management Commands
|
||||
// =====================================================================
|
||||
|
||||
case "hooksReloadConfig": {
|
||||
// Reload hooks configuration from all sources
|
||||
const hookManager = provider.getHookManager()
|
||||
if (hookManager) {
|
||||
try {
|
||||
await hookManager.reloadHooksConfig()
|
||||
await provider.postStateToWebview()
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`Failed to reload hooks config: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage("Failed to reload hooks configuration")
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "hooksSetEnabled": {
|
||||
// Enable or disable a specific hook
|
||||
const hookManager = provider.getHookManager()
|
||||
if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") {
|
||||
try {
|
||||
await hookManager.setHookEnabled(message.hookId, message.hookEnabled)
|
||||
await provider.postStateToWebview()
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`Failed to set hook enabled: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(`Failed to ${message.hookEnabled ? "enable" : "disable"} hook`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "hooksOpenConfigFolder": {
|
||||
// Open the hooks configuration folder in VS Code
|
||||
const source = message.hooksSource ?? "project"
|
||||
try {
|
||||
let hooksPath: string
|
||||
if (source === "global") {
|
||||
// Global hooks: ~/.roo/hooks
|
||||
hooksPath = path.join(os.homedir(), ".roo", "hooks")
|
||||
} else {
|
||||
// Project hooks: .roo/hooks in workspace
|
||||
const cwd = provider.cwd
|
||||
hooksPath = path.join(cwd, ".roo", "hooks")
|
||||
}
|
||||
|
||||
// Check if directory exists, create if not
|
||||
const exists = await fileExistsAtPath(hooksPath)
|
||||
if (!exists) {
|
||||
await fs.mkdir(hooksPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Open the folder in VS Code
|
||||
const uri = vscode.Uri.file(hooksPath)
|
||||
await vscode.commands.executeCommand("revealFileInOS", uri)
|
||||
} catch (error) {
|
||||
provider.log(`Failed to open hooks folder: ${error instanceof Error ? error.message : String(error)}`)
|
||||
vscode.window.showErrorMessage("Failed to open hooks configuration folder")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
// console.log(`Unhandled message type: ${message.type}`)
|
||||
//
|
||||
|
|
|
|||
362
src/services/hooks/HookConfigLoader.ts
Normal file
362
src/services/hooks/HookConfigLoader.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Hook Configuration Loader
|
||||
*
|
||||
* Loads and merges hook configurations from:
|
||||
* 1. Project directory: .roo/hooks/*.yaml or *.json (highest priority)
|
||||
* 2. Mode-specific: .roo/hooks-{mode}/*.yaml or *.json (middle priority)
|
||||
* 3. Global directory: ~/.roo/hooks/*.yaml or *.json (lowest priority)
|
||||
*
|
||||
* Files within each directory are processed in alphabetical order.
|
||||
* Same hook ID at higher precedence level overrides lower levels.
|
||||
*/
|
||||
|
||||
import * as path from "path"
|
||||
import fs from "fs/promises"
|
||||
import YAML from "yaml"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
HooksConfigFileSchema,
|
||||
HooksConfigSnapshot,
|
||||
HookDefinition,
|
||||
ResolvedHook,
|
||||
HookSource,
|
||||
HookEventType,
|
||||
} from "./types"
|
||||
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
|
||||
|
||||
/**
|
||||
* Result of loading a single config file.
|
||||
*/
|
||||
interface LoadedConfigFile {
|
||||
filePath: string
|
||||
source: HookSource
|
||||
hooks: Map<HookEventType, HookDefinition[]>
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for loading hooks configuration.
|
||||
*/
|
||||
export interface LoadHooksConfigOptions {
|
||||
/** Project directory (cwd) */
|
||||
cwd: string
|
||||
|
||||
/** Current mode slug (for mode-specific hooks) */
|
||||
mode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading all hooks configuration.
|
||||
*/
|
||||
export interface LoadHooksConfigResult {
|
||||
snapshot: HooksConfigSnapshot
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file has a supported extension (.yaml, .yml, .json).
|
||||
*/
|
||||
function isSupportedConfigFile(filename: string): boolean {
|
||||
const lower = filename.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml") || lower.endsWith(".json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a config file content (YAML or JSON).
|
||||
*/
|
||||
function parseConfigContent(content: string, filePath: string): unknown {
|
||||
const lower = filePath.toLowerCase()
|
||||
|
||||
if (lower.endsWith(".json")) {
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
// Parse as YAML (which also handles plain JSON)
|
||||
return YAML.parse(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parsed config against the schema.
|
||||
*/
|
||||
function validateConfig(
|
||||
parsed: unknown,
|
||||
filePath: string,
|
||||
): { success: true; data: z.infer<typeof HooksConfigFileSchema> } | { success: false; errors: string[] } {
|
||||
const result = HooksConfigFileSchema.safeParse(parsed)
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
// Format Zod errors nicely
|
||||
const errors = result.error.errors.map((err) => {
|
||||
const pathStr = err.path.length > 0 ? err.path.join(".") : "(root)"
|
||||
return `${filePath}: ${pathStr}: ${err.message}`
|
||||
})
|
||||
|
||||
return { success: false, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single config file.
|
||||
*/
|
||||
async function loadConfigFile(filePath: string, source: HookSource): Promise<LoadedConfigFile> {
|
||||
const result: LoadedConfigFile = {
|
||||
filePath,
|
||||
source,
|
||||
hooks: new Map(),
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
const parsed = parseConfigContent(content, filePath)
|
||||
const validated = validateConfig(parsed, filePath)
|
||||
|
||||
if (!validated.success) {
|
||||
result.errors = validated.errors
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert hooks record to Map
|
||||
const hooksRecord = validated.data.hooks || {}
|
||||
for (const [eventStr, definitions] of Object.entries(hooksRecord)) {
|
||||
const event = eventStr as HookEventType
|
||||
if (definitions && definitions.length > 0) {
|
||||
result.hooks.set(event, definitions)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
// File doesn't exist - not an error, just skip
|
||||
return result
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
result.errors.push(`${filePath}: Failed to load: ${message}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* List config files in a directory (sorted alphabetically).
|
||||
*/
|
||||
async function listConfigFiles(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && isSupportedConfigFile(entry.name))
|
||||
.map((entry) => path.join(dirPath, entry.name))
|
||||
.sort() // Alphabetical order
|
||||
|
||||
return files
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
// Directory doesn't exist - not an error
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all config files from a directory.
|
||||
*/
|
||||
async function loadConfigDirectory(dirPath: string, source: HookSource): Promise<LoadedConfigFile[]> {
|
||||
const files = await listConfigFiles(dirPath)
|
||||
const results: LoadedConfigFile[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const loaded = await loadConfigFile(filePath, source)
|
||||
results.push(loaded)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge loaded configs into a snapshot, respecting precedence rules.
|
||||
*
|
||||
* Precedence (highest to lowest):
|
||||
* 1. Project hooks (.roo/hooks/)
|
||||
* 2. Mode-specific hooks (.roo/hooks-{mode}/)
|
||||
* 3. Global hooks (~/.roo/hooks/)
|
||||
*
|
||||
* Within same level: alphabetical file order.
|
||||
* Same hook ID: higher precedence wins (can also disable with enabled: false).
|
||||
*/
|
||||
function mergeConfigs(loadedConfigs: LoadedConfigFile[]): {
|
||||
hooksByEvent: Map<HookEventType, ResolvedHook[]>
|
||||
hooksById: Map<string, ResolvedHook>
|
||||
hasProjectHooks: boolean
|
||||
} {
|
||||
// Track hooks by ID to detect overrides
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
|
||||
// Track hooks by event for efficient lookup
|
||||
const hooksByEvent = new Map<HookEventType, ResolvedHook[]>()
|
||||
|
||||
// Track if we have any project hooks (for security warnings)
|
||||
let hasProjectHooks = false
|
||||
|
||||
// Process configs in reverse precedence order (global -> mode -> project)
|
||||
// so that later (higher precedence) configs override earlier ones
|
||||
const orderedConfigs = [...loadedConfigs].sort((a, b) => {
|
||||
const precedence: Record<HookSource, number> = {
|
||||
global: 0,
|
||||
mode: 1,
|
||||
project: 2,
|
||||
}
|
||||
return precedence[a.source] - precedence[b.source]
|
||||
})
|
||||
|
||||
for (const config of orderedConfigs) {
|
||||
if (config.source === "project" && config.hooks.size > 0) {
|
||||
hasProjectHooks = true
|
||||
}
|
||||
|
||||
for (const [event, definitions] of config.hooks) {
|
||||
for (const def of definitions) {
|
||||
const resolved: ResolvedHook = {
|
||||
...def,
|
||||
source: config.source,
|
||||
event,
|
||||
filePath: config.filePath,
|
||||
}
|
||||
|
||||
// Check for existing hook with same ID
|
||||
const existing = hooksById.get(def.id)
|
||||
if (existing) {
|
||||
// Remove from its event list
|
||||
const eventList = hooksByEvent.get(existing.event)
|
||||
if (eventList) {
|
||||
const idx = eventList.findIndex((h) => h.id === def.id)
|
||||
if (idx !== -1) {
|
||||
eventList.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add/replace in lookup maps
|
||||
hooksById.set(def.id, resolved)
|
||||
|
||||
// Add to event list
|
||||
if (!hooksByEvent.has(event)) {
|
||||
hooksByEvent.set(event, [])
|
||||
}
|
||||
hooksByEvent.get(event)!.push(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { hooksByEvent, hooksById, hasProjectHooks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hooks configuration from all sources.
|
||||
*
|
||||
* @param options - Loading options (cwd, mode)
|
||||
* @returns Loaded configuration snapshot and any errors/warnings
|
||||
*/
|
||||
export async function loadHooksConfig(options: LoadHooksConfigOptions): Promise<LoadHooksConfigResult> {
|
||||
const { cwd, mode } = options
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const loadedConfigs: LoadedConfigFile[] = []
|
||||
|
||||
// 1. Load global hooks (~/.roo/hooks/)
|
||||
const globalDir = path.join(getGlobalRooDirectory(), "hooks")
|
||||
try {
|
||||
const globalConfigs = await loadConfigDirectory(globalDir, "global")
|
||||
loadedConfigs.push(...globalConfigs)
|
||||
for (const config of globalConfigs) {
|
||||
errors.push(...config.errors)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warnings.push(`Failed to load global hooks from ${globalDir}: ${message}`)
|
||||
}
|
||||
|
||||
// 2. Load mode-specific hooks (.roo/hooks-{mode}/)
|
||||
if (mode) {
|
||||
const modeDir = path.join(getProjectRooDirectoryForCwd(cwd), `hooks-${mode}`)
|
||||
try {
|
||||
const modeConfigs = await loadConfigDirectory(modeDir, "mode")
|
||||
loadedConfigs.push(...modeConfigs)
|
||||
for (const config of modeConfigs) {
|
||||
errors.push(...config.errors)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warnings.push(`Failed to load mode-specific hooks from ${modeDir}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load project hooks (.roo/hooks/)
|
||||
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "hooks")
|
||||
try {
|
||||
const projectConfigs = await loadConfigDirectory(projectDir, "project")
|
||||
loadedConfigs.push(...projectConfigs)
|
||||
for (const config of projectConfigs) {
|
||||
errors.push(...config.errors)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warnings.push(`Failed to load project hooks from ${projectDir}: ${message}`)
|
||||
}
|
||||
|
||||
// Merge configs with precedence rules
|
||||
const { hooksByEvent, hooksById, hasProjectHooks } = mergeConfigs(loadedConfigs)
|
||||
|
||||
// Create snapshot
|
||||
const snapshot: HooksConfigSnapshot = {
|
||||
hooksByEvent,
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks,
|
||||
}
|
||||
|
||||
// Security warning for project hooks
|
||||
if (hasProjectHooks) {
|
||||
warnings.push(
|
||||
"⚠️ Project hooks are active: This workspace has hooks defined in .roo/hooks/. " +
|
||||
"These hooks run shell commands when Roo Code performs actions. " +
|
||||
"Only enable project hooks for repositories you trust.",
|
||||
)
|
||||
}
|
||||
|
||||
return { snapshot, errors, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hooks for a specific event from a snapshot.
|
||||
*
|
||||
* @param snapshot - The config snapshot
|
||||
* @param event - The event type
|
||||
* @returns Array of hooks for the event (excluding disabled ones)
|
||||
*/
|
||||
export function getHooksForEvent(snapshot: HooksConfigSnapshot, event: HookEventType): ResolvedHook[] {
|
||||
const hooks = snapshot.hooksByEvent.get(event) || []
|
||||
return hooks.filter((hook) => {
|
||||
// Check if explicitly disabled via setHookEnabled
|
||||
if (snapshot.disabledHookIds.has(hook.id)) {
|
||||
return false
|
||||
}
|
||||
// Check if disabled in config
|
||||
return hook.enabled !== false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific hook by ID from a snapshot.
|
||||
*
|
||||
* @param snapshot - The config snapshot
|
||||
* @param hookId - The hook ID
|
||||
* @returns The hook, or undefined if not found
|
||||
*/
|
||||
export function getHookById(snapshot: HooksConfigSnapshot, hookId: string): ResolvedHook | undefined {
|
||||
return snapshot.hooksById.get(hookId)
|
||||
}
|
||||
351
src/services/hooks/HookExecutor.ts
Normal file
351
src/services/hooks/HookExecutor.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* Hook Executor
|
||||
*
|
||||
* Executes shell commands for hooks with:
|
||||
* - JSON context passed via stdin
|
||||
* - Environment variables set per PRD
|
||||
* - Timeout handling
|
||||
* - Exit code interpretation (0=success, 2=block, other=error)
|
||||
* - stdout/stderr capture
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {
|
||||
ResolvedHook,
|
||||
HookContext,
|
||||
HookExecutionResult,
|
||||
HookExitCode,
|
||||
HookModificationSchema,
|
||||
HookModification,
|
||||
isBlockingEvent,
|
||||
ConversationHistoryEntry,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Default timeout in seconds.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 60
|
||||
|
||||
/**
|
||||
* Get the default shell based on platform.
|
||||
* Windows: PowerShell
|
||||
* Unix: User's shell from SHELL env var, or /bin/sh
|
||||
*/
|
||||
function getDefaultShell(): { shell: string; shellArgs: string[] } {
|
||||
if (os.platform() === "win32") {
|
||||
return {
|
||||
shell: "powershell.exe",
|
||||
shellArgs: ["-NoProfile", "-NonInteractive", "-Command"],
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: try user's shell, fall back to /bin/sh
|
||||
const userShell = process.env.SHELL || "/bin/sh"
|
||||
return {
|
||||
shell: userShell,
|
||||
shellArgs: ["-c"],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a custom shell specification.
|
||||
* Supports "bash", "/bin/bash", "powershell.exe", etc.
|
||||
*/
|
||||
function parseShellSpec(shellSpec: string): { shell: string; shellArgs: string[] } {
|
||||
const lower = shellSpec.toLowerCase()
|
||||
|
||||
// Handle PowerShell variants
|
||||
if (lower.includes("powershell") || lower.includes("pwsh")) {
|
||||
return {
|
||||
shell: shellSpec,
|
||||
shellArgs: ["-NoProfile", "-NonInteractive", "-Command"],
|
||||
}
|
||||
}
|
||||
|
||||
// All other shells use -c
|
||||
return {
|
||||
shell: shellSpec,
|
||||
shellArgs: ["-c"],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for hook execution.
|
||||
*/
|
||||
function buildEnvVars(hook: ResolvedHook, context: HookContext): NodeJS.ProcessEnv {
|
||||
const baseEnv = { ...process.env }
|
||||
|
||||
return {
|
||||
...baseEnv,
|
||||
ROO_PROJECT_DIR: context.project.directory,
|
||||
ROO_TASK_ID: context.session.taskId,
|
||||
ROO_SESSION_ID: context.session.sessionId,
|
||||
ROO_MODE: context.session.mode,
|
||||
ROO_TOOL_NAME: context.tool?.name || "",
|
||||
ROO_EVENT: context.event,
|
||||
ROO_HOOK_ID: hook.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stdin JSON payload for a hook.
|
||||
*/
|
||||
function buildStdinPayload(
|
||||
hook: ResolvedHook,
|
||||
context: HookContext,
|
||||
conversationHistory?: ConversationHistoryEntry[],
|
||||
): string {
|
||||
// Start with the base context
|
||||
const payload: HookContext = { ...context }
|
||||
|
||||
// Only include conversation history if the hook opts in
|
||||
if (hook.includeConversationHistory && conversationHistory) {
|
||||
payload.conversationHistory = conversationHistory
|
||||
}
|
||||
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse stdout as a modification response.
|
||||
* Returns undefined if stdout is empty or not valid modification JSON.
|
||||
*/
|
||||
function parseModificationResponse(stdout: string, hook: ResolvedHook): HookModification | undefined {
|
||||
if (!stdout.trim()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
const result = HookModificationSchema.safeParse(parsed)
|
||||
|
||||
if (result.success) {
|
||||
// Only PreToolUse hooks can modify input
|
||||
if (hook.event !== "PreToolUse") {
|
||||
console.warn(`Hook "${hook.id}" returned modification but is not a PreToolUse hook - ignoring`)
|
||||
return undefined
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Not a valid modification response - that's fine, hooks don't have to return JSON
|
||||
return undefined
|
||||
} catch {
|
||||
// Not valid JSON - that's fine
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook command.
|
||||
*
|
||||
* @param hook - The hook to execute
|
||||
* @param context - The hook context
|
||||
* @param conversationHistory - Optional conversation history (only included if hook opts in)
|
||||
* @returns Execution result
|
||||
*/
|
||||
export async function executeHook(
|
||||
hook: ResolvedHook,
|
||||
context: HookContext,
|
||||
conversationHistory?: ConversationHistoryEntry[],
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now()
|
||||
const timeout = (hook.timeout || DEFAULT_TIMEOUT) * 1000 // Convert to ms
|
||||
|
||||
// Determine shell
|
||||
const shellConfig = hook.shell ? parseShellSpec(hook.shell) : getDefaultShell()
|
||||
|
||||
// Build environment and stdin
|
||||
const env = buildEnvVars(hook, context)
|
||||
const stdin = buildStdinPayload(hook, context, conversationHistory)
|
||||
|
||||
return new Promise<HookExecutionResult>((resolve) => {
|
||||
let child: ChildProcess | null = null
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
let timedOut = false
|
||||
let resolved = false
|
||||
|
||||
const finalize = (exitCode: number | null, error?: Error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Try to parse modification from stdout
|
||||
const modification = parseModificationResponse(stdout, hook)
|
||||
|
||||
resolve({
|
||||
hook,
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
timedOut,
|
||||
error,
|
||||
modification,
|
||||
})
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (child) {
|
||||
// Try graceful kill first (SIGTERM), then force (SIGKILL)
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGKILL")
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
// Spawn the shell process
|
||||
child = spawn(shellConfig.shell, [...shellConfig.shellArgs, hook.command], {
|
||||
cwd: context.project.directory,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
// Don't throw on Windows if shell not found
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
// Write stdin
|
||||
if (child.stdin) {
|
||||
child.stdin.write(stdin)
|
||||
child.stdin.end()
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
}
|
||||
|
||||
// Handle process exit
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeoutHandle)
|
||||
finalize(code)
|
||||
})
|
||||
|
||||
// Handle spawn errors
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeoutHandle)
|
||||
finalize(null, err)
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutHandle)
|
||||
finalize(null, err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the result of a hook execution.
|
||||
*
|
||||
* @param result - The execution result
|
||||
* @returns Object with interpretation flags
|
||||
*/
|
||||
export function interpretResult(result: HookExecutionResult): {
|
||||
success: boolean
|
||||
blocked: boolean
|
||||
blockMessage: string | undefined
|
||||
shouldContinue: boolean
|
||||
} {
|
||||
// Check for execution errors first
|
||||
if (result.error || result.exitCode === null) {
|
||||
return {
|
||||
success: false,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true, // Execution errors don't block, per PRD
|
||||
}
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
success: false,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true, // Timeouts don't block, per PRD
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code 0 = success
|
||||
if (result.exitCode === HookExitCode.Success) {
|
||||
return {
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code 2 = block (only for blocking events)
|
||||
if (result.exitCode === HookExitCode.Block) {
|
||||
if (isBlockingEvent(result.hook.event)) {
|
||||
return {
|
||||
success: false,
|
||||
blocked: true,
|
||||
blockMessage: result.stderr.trim() || `Hook "${result.hook.id}" blocked execution`,
|
||||
shouldContinue: false,
|
||||
}
|
||||
} else {
|
||||
// Non-blocking event with exit code 2 is treated as regular failure
|
||||
console.warn(
|
||||
`Hook "${result.hook.id}" returned exit code 2 (block) but ${result.hook.event} is not a blocking event - treating as error`,
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other non-zero exit codes = error, but don't block
|
||||
return {
|
||||
success: false,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of a hook result for logging.
|
||||
*/
|
||||
export function describeResult(result: HookExecutionResult): string {
|
||||
const hook = result.hook
|
||||
|
||||
if (result.error) {
|
||||
return `Hook "${hook.id}" failed to execute: ${result.error.message}`
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
return `Hook "${hook.id}" timed out after ${hook.timeout || DEFAULT_TIMEOUT}s`
|
||||
}
|
||||
|
||||
if (result.exitCode === HookExitCode.Success) {
|
||||
return `Hook "${hook.id}" completed successfully in ${result.duration}ms`
|
||||
}
|
||||
|
||||
if (result.exitCode === HookExitCode.Block) {
|
||||
return `Hook "${hook.id}" blocked with: ${result.stderr.trim() || "(no message)"}`
|
||||
}
|
||||
|
||||
return `Hook "${hook.id}" returned exit code ${result.exitCode} in ${result.duration}ms`
|
||||
}
|
||||
320
src/services/hooks/HookManager.ts
Normal file
320
src/services/hooks/HookManager.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/**
|
||||
* Hook Manager
|
||||
*
|
||||
* Central service for orchestrating hooks in Roo Code.
|
||||
* Implements the IHookManager interface from the PRD.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Load and maintain a snapshot of hook configuration
|
||||
* - Execute matching hooks sequentially for events
|
||||
* - Manage execution history for debugging
|
||||
* - Provide enable/disable API for individual hooks
|
||||
*/
|
||||
|
||||
import {
|
||||
IHookManager,
|
||||
HookEventType,
|
||||
HooksConfigSnapshot,
|
||||
ResolvedHook,
|
||||
HookExecution,
|
||||
HooksExecutionResult,
|
||||
HookExecutionResult,
|
||||
ExecuteHooksOptions,
|
||||
HookContext,
|
||||
ConversationHistoryEntry,
|
||||
} from "./types"
|
||||
import { loadHooksConfig, getHooksForEvent, LoadHooksConfigOptions } from "./HookConfigLoader"
|
||||
import { filterMatchingHooks } from "./HookMatcher"
|
||||
import { executeHook, interpretResult, describeResult } from "./HookExecutor"
|
||||
|
||||
/**
|
||||
* Default options for the HookManager.
|
||||
*/
|
||||
export interface HookManagerOptions {
|
||||
/** Project directory (cwd) */
|
||||
cwd: string
|
||||
|
||||
/** Current mode slug */
|
||||
mode?: string
|
||||
|
||||
/** Maximum execution history entries to keep (default: 100) */
|
||||
maxHistoryEntries?: number
|
||||
|
||||
/** Optional logger for hook activity */
|
||||
logger?: {
|
||||
debug: (message: string) => void
|
||||
info: (message: string) => void
|
||||
warn: (message: string) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default maximum history entries.
|
||||
*/
|
||||
const DEFAULT_MAX_HISTORY = 100
|
||||
|
||||
/**
|
||||
* HookManager implementation.
|
||||
*
|
||||
* This class maintains an immutable snapshot of hook configuration that is
|
||||
* loaded once and only changes on explicit reload (for security).
|
||||
*/
|
||||
export class HookManager implements IHookManager {
|
||||
private options: HookManagerOptions
|
||||
private snapshot: HooksConfigSnapshot | null = null
|
||||
private executionHistory: HookExecution[] = []
|
||||
private maxHistoryEntries: number
|
||||
|
||||
constructor(options: HookManagerOptions) {
|
||||
this.options = options
|
||||
this.maxHistoryEntries = options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hooks configuration from all sources.
|
||||
* Creates an immutable snapshot that won't change until explicit reload.
|
||||
*/
|
||||
async loadHooksConfig(): Promise<HooksConfigSnapshot> {
|
||||
const loadOptions: LoadHooksConfigOptions = {
|
||||
cwd: this.options.cwd,
|
||||
mode: this.options.mode,
|
||||
}
|
||||
|
||||
const { snapshot, errors, warnings } = await loadHooksConfig(loadOptions)
|
||||
|
||||
// Log errors and warnings
|
||||
if (this.options.logger) {
|
||||
for (const error of errors) {
|
||||
this.options.logger.error(`Hook config error: ${error}`)
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
this.options.logger.warn(warning)
|
||||
}
|
||||
}
|
||||
|
||||
// Store the snapshot
|
||||
this.snapshot = snapshot
|
||||
|
||||
// Log summary
|
||||
const hookCount = Array.from(snapshot.hooksByEvent.values()).reduce((sum, hooks) => sum + hooks.length, 0)
|
||||
this.log("info", `Loaded ${hookCount} hooks from configuration`)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly reload hooks configuration.
|
||||
* Required for security - config changes don't auto-apply.
|
||||
*/
|
||||
async reloadHooksConfig(): Promise<void> {
|
||||
this.log("info", "Reloading hooks configuration...")
|
||||
|
||||
// Preserve disabled hook IDs across reload
|
||||
const previousDisabled = this.snapshot?.disabledHookIds ?? new Set<string>()
|
||||
|
||||
await this.loadHooksConfig()
|
||||
|
||||
// Restore disabled state for hooks that still exist
|
||||
if (this.snapshot) {
|
||||
for (const hookId of previousDisabled) {
|
||||
if (this.snapshot.hooksById.has(hookId)) {
|
||||
this.snapshot.disabledHookIds.add(hookId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.log("info", "Hooks configuration reloaded")
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all matching hooks for an event.
|
||||
* Hooks are executed sequentially in their defined order.
|
||||
* If a blocking event returns exit code 2, subsequent hooks are skipped.
|
||||
*/
|
||||
async executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise<HooksExecutionResult> {
|
||||
const startTime = Date.now()
|
||||
const results: HookExecutionResult[] = []
|
||||
|
||||
// Ensure config is loaded
|
||||
if (!this.snapshot) {
|
||||
await this.loadHooksConfig()
|
||||
}
|
||||
|
||||
// Get enabled hooks for this event
|
||||
const eventHooks = getHooksForEvent(this.snapshot!, event)
|
||||
|
||||
// Filter by tool name if this is a tool-related event
|
||||
let matchingHooks: ResolvedHook[]
|
||||
if (options.context.tool?.name) {
|
||||
matchingHooks = filterMatchingHooks(eventHooks, options.context.tool.name)
|
||||
} else {
|
||||
matchingHooks = eventHooks
|
||||
}
|
||||
|
||||
this.log("debug", `Executing ${matchingHooks.length} hooks for ${event}`)
|
||||
|
||||
let blocked = false
|
||||
let blockMessage: string | undefined
|
||||
let blockingHook: ResolvedHook | undefined
|
||||
let modification: HooksExecutionResult["modification"]
|
||||
|
||||
// Execute hooks sequentially
|
||||
for (const hook of matchingHooks) {
|
||||
this.log("debug", `Executing hook "${hook.id}" for ${event}`)
|
||||
|
||||
// Execute the hook
|
||||
const result = await executeHook(hook, options.context, options.conversationHistory)
|
||||
results.push(result)
|
||||
|
||||
// Record in history
|
||||
this.recordExecution(hook, event, result)
|
||||
|
||||
// Log the result
|
||||
this.log("info", describeResult(result))
|
||||
|
||||
// Interpret the result
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
// Check for blocking
|
||||
if (interpretation.blocked) {
|
||||
blocked = true
|
||||
blockMessage = interpretation.blockMessage
|
||||
blockingHook = hook
|
||||
this.log("warn", `Hook "${hook.id}" blocked ${event}: ${blockMessage}`)
|
||||
break // Stop executing subsequent hooks
|
||||
}
|
||||
|
||||
// Check for modification (only first modification wins)
|
||||
if (!modification && result.modification) {
|
||||
modification = result.modification
|
||||
this.log("info", `Hook "${hook.id}" modified tool input`)
|
||||
}
|
||||
|
||||
// If hook returned an error but we should continue, log it
|
||||
if (!interpretation.success && interpretation.shouldContinue) {
|
||||
this.log("warn", `Hook "${hook.id}" failed but continuing: ${result.error?.message || result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime
|
||||
this.log("debug", `Executed ${results.length} hooks in ${totalDuration}ms`)
|
||||
|
||||
return {
|
||||
results,
|
||||
blocked,
|
||||
blockMessage,
|
||||
blockingHook,
|
||||
modification,
|
||||
totalDuration,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently enabled hooks.
|
||||
*/
|
||||
getEnabledHooks(): ResolvedHook[] {
|
||||
if (!this.snapshot) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allHooks: ResolvedHook[] = []
|
||||
for (const hooks of this.snapshot.hooksByEvent.values()) {
|
||||
for (const hook of hooks) {
|
||||
// Check if enabled in config AND not disabled at runtime
|
||||
if (hook.enabled !== false && !this.snapshot.disabledHookIds.has(hook.id)) {
|
||||
allHooks.push(hook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a specific hook by ID.
|
||||
* This persists until config reload.
|
||||
*/
|
||||
async setHookEnabled(hookId: string, enabled: boolean): Promise<void> {
|
||||
if (!this.snapshot) {
|
||||
await this.loadHooksConfig()
|
||||
}
|
||||
|
||||
const hook = this.snapshot!.hooksById.get(hookId)
|
||||
if (!hook) {
|
||||
throw new Error(`Hook not found: ${hookId}`)
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
this.snapshot!.disabledHookIds.delete(hookId)
|
||||
this.log("info", `Enabled hook "${hookId}"`)
|
||||
} else {
|
||||
this.snapshot!.disabledHookIds.add(hookId)
|
||||
this.log("info", `Disabled hook "${hookId}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution history for debugging.
|
||||
*/
|
||||
getHookExecutionHistory(): HookExecution[] {
|
||||
return [...this.executionHistory]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current config snapshot (or null if not loaded).
|
||||
*/
|
||||
getConfigSnapshot(): HooksConfigSnapshot | null {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current mode (useful when mode changes during session).
|
||||
* Requires reload to take effect.
|
||||
*/
|
||||
setMode(mode: string): void {
|
||||
this.options.mode = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear execution history.
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.executionHistory = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a hook execution in history.
|
||||
*/
|
||||
private recordExecution(hook: ResolvedHook, event: HookEventType, result: HookExecutionResult): void {
|
||||
const entry: HookExecution = {
|
||||
timestamp: new Date(),
|
||||
hook,
|
||||
event,
|
||||
result,
|
||||
}
|
||||
|
||||
this.executionHistory.push(entry)
|
||||
|
||||
// Trim history if needed
|
||||
if (this.executionHistory.length > this.maxHistoryEntries) {
|
||||
this.executionHistory = this.executionHistory.slice(-this.maxHistoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message using the configured logger.
|
||||
*/
|
||||
private log(level: "debug" | "info" | "warn" | "error", message: string): void {
|
||||
if (this.options.logger) {
|
||||
this.options.logger[level](`[Hooks] ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new HookManager instance.
|
||||
*/
|
||||
export function createHookManager(options: HookManagerOptions): IHookManager {
|
||||
return new HookManager(options)
|
||||
}
|
||||
167
src/services/hooks/HookMatcher.ts
Normal file
167
src/services/hooks/HookMatcher.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Hook Matcher
|
||||
*
|
||||
* Provides pattern matching for hooks against tool names.
|
||||
* Supports exact match, regex patterns, glob patterns, and match-all.
|
||||
*/
|
||||
|
||||
import { ResolvedHook } from "./types"
|
||||
|
||||
/**
|
||||
* Result of compiling a matcher pattern.
|
||||
*/
|
||||
interface CompiledMatcher {
|
||||
/** The original pattern string */
|
||||
pattern: string
|
||||
|
||||
/** Type of matching to use */
|
||||
type: "all" | "exact" | "regex" | "glob"
|
||||
|
||||
/** Compiled regex for regex/glob matching */
|
||||
regex?: RegExp
|
||||
|
||||
/** Test if a tool name matches this pattern */
|
||||
matches: (toolName: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a matcher pattern into an efficient matcher function.
|
||||
*
|
||||
* Supports:
|
||||
* - Exact tool name: "Write"
|
||||
* - Regex pattern: "Edit|Write" (contains | or regex metacharacters)
|
||||
* - Glob pattern: "mcp__*" (contains * or ?)
|
||||
* - Match all: "*" or undefined/empty
|
||||
*
|
||||
* @param pattern - The matcher pattern string (or undefined for match-all)
|
||||
* @returns Compiled matcher object
|
||||
*/
|
||||
export function compileMatcher(pattern: string | undefined): CompiledMatcher {
|
||||
// Handle match-all cases
|
||||
if (!pattern || pattern === "*") {
|
||||
return {
|
||||
pattern: pattern || "*",
|
||||
type: "all",
|
||||
matches: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pattern looks like a regex (contains regex metacharacters except * and ?)
|
||||
const regexMetaChars = /[|^$+.()[\]{}\\]/
|
||||
const isRegexPattern = regexMetaChars.test(pattern)
|
||||
|
||||
// Check if pattern looks like a glob (contains * or ?)
|
||||
const isGlobPattern = /[*?]/.test(pattern) && !isRegexPattern
|
||||
|
||||
if (isRegexPattern) {
|
||||
// Treat as regex pattern
|
||||
try {
|
||||
const regex = new RegExp(`^(?:${pattern})$`, "i")
|
||||
return {
|
||||
pattern,
|
||||
type: "regex",
|
||||
regex,
|
||||
matches: (toolName: string) => regex.test(toolName),
|
||||
}
|
||||
} catch (e) {
|
||||
// If regex compilation fails, fall back to exact match
|
||||
console.warn(`Invalid regex pattern "${pattern}", falling back to exact match:`, e)
|
||||
return {
|
||||
pattern,
|
||||
type: "exact",
|
||||
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isGlobPattern) {
|
||||
// Convert glob to regex
|
||||
// * matches any characters, ? matches single character
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ?
|
||||
.replace(/\*/g, ".*") // * -> .*
|
||||
.replace(/\?/g, ".") // ? -> .
|
||||
|
||||
try {
|
||||
const regex = new RegExp(`^${regexPattern}$`, "i")
|
||||
return {
|
||||
pattern,
|
||||
type: "glob",
|
||||
regex,
|
||||
matches: (toolName: string) => regex.test(toolName),
|
||||
}
|
||||
} catch (e) {
|
||||
// If regex compilation fails, fall back to exact match
|
||||
console.warn(`Invalid glob pattern "${pattern}", falling back to exact match:`, e)
|
||||
return {
|
||||
pattern,
|
||||
type: "exact",
|
||||
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact match (case-insensitive)
|
||||
return {
|
||||
pattern,
|
||||
type: "exact",
|
||||
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache of compiled matchers for performance.
|
||||
* Key is the pattern string, value is the compiled matcher.
|
||||
*/
|
||||
const matcherCache = new Map<string, CompiledMatcher>()
|
||||
|
||||
/**
|
||||
* Get a compiled matcher, using cache when possible.
|
||||
*
|
||||
* @param pattern - The matcher pattern string (or undefined for match-all)
|
||||
* @returns Compiled matcher object
|
||||
*/
|
||||
export function getMatcher(pattern: string | undefined): CompiledMatcher {
|
||||
const cacheKey = pattern || "*"
|
||||
|
||||
let matcher = matcherCache.get(cacheKey)
|
||||
if (!matcher) {
|
||||
matcher = compileMatcher(pattern)
|
||||
matcherCache.set(cacheKey, matcher)
|
||||
}
|
||||
|
||||
return matcher
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the matcher cache (useful for testing).
|
||||
*/
|
||||
export function clearMatcherCache(): void {
|
||||
matcherCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter hooks that match a given tool name.
|
||||
*
|
||||
* @param hooks - Array of hooks to filter
|
||||
* @param toolName - Tool name to match against
|
||||
* @returns Hooks that match the tool name
|
||||
*/
|
||||
export function filterMatchingHooks(hooks: ResolvedHook[], toolName: string): ResolvedHook[] {
|
||||
return hooks.filter((hook) => {
|
||||
const matcher = getMatcher(hook.matcher)
|
||||
return matcher.matches(toolName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single hook matches a tool name.
|
||||
*
|
||||
* @param hook - The hook to check
|
||||
* @param toolName - Tool name to match against
|
||||
* @returns Whether the hook matches
|
||||
*/
|
||||
export function hookMatchesTool(hook: ResolvedHook, toolName: string): boolean {
|
||||
const matcher = getMatcher(hook.matcher)
|
||||
return matcher.matches(toolName)
|
||||
}
|
||||
457
src/services/hooks/ToolExecutionHooks.ts
Normal file
457
src/services/hooks/ToolExecutionHooks.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* Tool Execution Hooks Service
|
||||
*
|
||||
* Provides integration between the tool execution pipeline and the hooks system.
|
||||
* Handles PreToolUse, PostToolUse, PostToolUseFailure, and PermissionRequest events.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IHookManager,
|
||||
HookEventType,
|
||||
HookContext,
|
||||
HookSessionContext,
|
||||
HookProjectContext,
|
||||
HookToolContext,
|
||||
HooksExecutionResult,
|
||||
ExecuteHooksOptions,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Tool execution context for hooks.
|
||||
*/
|
||||
export interface ToolExecutionContext {
|
||||
/** Tool name being executed */
|
||||
toolName: string
|
||||
/** Tool input parameters */
|
||||
toolInput: Record<string, unknown>
|
||||
/** Session context */
|
||||
session: HookSessionContext
|
||||
/** Project context */
|
||||
project: HookProjectContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from PreToolUse hook execution.
|
||||
*/
|
||||
export interface PreToolUseResult {
|
||||
/** Whether the tool execution should proceed */
|
||||
proceed: boolean
|
||||
/** If blocked, the reason */
|
||||
blockReason?: string
|
||||
/** Modified tool input (if hooks modified it) */
|
||||
modifiedInput?: Record<string, unknown>
|
||||
/** Hook execution result for debugging */
|
||||
hookResult: HooksExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from PermissionRequest hook execution.
|
||||
*/
|
||||
export interface PermissionRequestResult {
|
||||
/** Whether the permission request should proceed to user */
|
||||
proceed: boolean
|
||||
/** If blocked, the reason */
|
||||
blockReason?: string
|
||||
/** Hook execution result for debugging */
|
||||
hookResult: HooksExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for emitting hook execution status to webview.
|
||||
*/
|
||||
export type HookStatusCallback = (status: {
|
||||
status: "running" | "completed" | "failed" | "blocked"
|
||||
event: HookEventType
|
||||
toolName?: string
|
||||
hookId?: string
|
||||
duration?: number
|
||||
error?: string
|
||||
blockMessage?: string
|
||||
modified?: boolean
|
||||
}) => void
|
||||
|
||||
/**
|
||||
* Tool Execution Hooks Service
|
||||
*
|
||||
* Orchestrates hook execution for tool lifecycle events.
|
||||
*/
|
||||
export class ToolExecutionHooks {
|
||||
private hookManager: IHookManager | null
|
||||
private statusCallback?: HookStatusCallback
|
||||
|
||||
constructor(hookManager: IHookManager | null, statusCallback?: HookStatusCallback) {
|
||||
this.hookManager = hookManager
|
||||
this.statusCallback = statusCallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the hook manager instance.
|
||||
*/
|
||||
setHookManager(hookManager: IHookManager | null): void {
|
||||
this.hookManager = hookManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status callback.
|
||||
*/
|
||||
setStatusCallback(callback: HookStatusCallback | undefined): void {
|
||||
this.statusCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PreToolUse hooks before a tool is executed.
|
||||
*
|
||||
* @returns Result indicating whether to proceed, and optionally modified input
|
||||
*/
|
||||
async executePreToolUse(context: ToolExecutionContext): Promise<PreToolUseResult> {
|
||||
if (!this.hookManager) {
|
||||
// No hooks configured - proceed normally
|
||||
return {
|
||||
proceed: true,
|
||||
hookResult: {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const hookContext = this.buildToolHookContext("PreToolUse", context)
|
||||
|
||||
// Emit running status
|
||||
this.emitStatus({
|
||||
status: "running",
|
||||
event: "PreToolUse",
|
||||
toolName: context.toolName,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PreToolUse", { context: hookContext })
|
||||
|
||||
if (result.blocked) {
|
||||
// Hook blocked the execution
|
||||
this.emitStatus({
|
||||
status: "blocked",
|
||||
event: "PreToolUse",
|
||||
toolName: context.toolName,
|
||||
blockMessage: result.blockMessage,
|
||||
duration: result.totalDuration,
|
||||
})
|
||||
|
||||
return {
|
||||
proceed: false,
|
||||
blockReason: result.blockMessage || "Blocked by PreToolUse hook",
|
||||
hookResult: result,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for modifications
|
||||
const modified = !!result.modification
|
||||
const modifiedInput = result.modification?.toolInput
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
event: "PreToolUse",
|
||||
toolName: context.toolName,
|
||||
duration: result.totalDuration,
|
||||
modified,
|
||||
})
|
||||
|
||||
return {
|
||||
proceed: true,
|
||||
modifiedInput,
|
||||
hookResult: result,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
this.emitStatus({
|
||||
status: "failed",
|
||||
event: "PreToolUse",
|
||||
toolName: context.toolName,
|
||||
error: errorMessage,
|
||||
})
|
||||
|
||||
// On hook execution error, proceed with original execution
|
||||
// (fail-open for safety)
|
||||
return {
|
||||
proceed: true,
|
||||
hookResult: {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PostToolUse hooks after successful tool execution.
|
||||
* This is non-blocking and fire-and-forget.
|
||||
*/
|
||||
async executePostToolUse(
|
||||
context: ToolExecutionContext,
|
||||
output: unknown,
|
||||
duration: number,
|
||||
): Promise<HooksExecutionResult> {
|
||||
if (!this.hookManager) {
|
||||
return {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const hookContext = this.buildToolHookContext("PostToolUse", context, {
|
||||
output,
|
||||
duration,
|
||||
})
|
||||
|
||||
this.emitStatus({
|
||||
status: "running",
|
||||
event: "PostToolUse",
|
||||
toolName: context.toolName,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PostToolUse", { context: hookContext })
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
event: "PostToolUse",
|
||||
toolName: context.toolName,
|
||||
duration: result.totalDuration,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
this.emitStatus({
|
||||
status: "failed",
|
||||
event: "PostToolUse",
|
||||
toolName: context.toolName,
|
||||
error: errorMessage,
|
||||
})
|
||||
|
||||
return {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PostToolUseFailure hooks after failed tool execution.
|
||||
* This is non-blocking and fire-and-forget.
|
||||
*/
|
||||
async executePostToolUseFailure(
|
||||
context: ToolExecutionContext,
|
||||
error: string,
|
||||
errorMessage: string,
|
||||
): Promise<HooksExecutionResult> {
|
||||
if (!this.hookManager) {
|
||||
return {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const hookContext = this.buildToolHookContext("PostToolUseFailure", context, {
|
||||
error,
|
||||
errorMessage,
|
||||
})
|
||||
|
||||
this.emitStatus({
|
||||
status: "running",
|
||||
event: "PostToolUseFailure",
|
||||
toolName: context.toolName,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PostToolUseFailure", { context: hookContext })
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
event: "PostToolUseFailure",
|
||||
toolName: context.toolName,
|
||||
duration: result.totalDuration,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
|
||||
this.emitStatus({
|
||||
status: "failed",
|
||||
event: "PostToolUseFailure",
|
||||
toolName: context.toolName,
|
||||
error: errMsg,
|
||||
})
|
||||
|
||||
return {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute PermissionRequest hooks before showing approval prompt.
|
||||
*
|
||||
* Note: Even if hooks indicate "block", this does NOT auto-approve restricted tools.
|
||||
* The hook can only prevent the approval dialog from appearing (denying the tool),
|
||||
* not bypass the existing approval/auto-approval rules.
|
||||
*
|
||||
* @returns Result indicating whether to proceed with showing the prompt
|
||||
*/
|
||||
async executePermissionRequest(context: ToolExecutionContext): Promise<PermissionRequestResult> {
|
||||
if (!this.hookManager) {
|
||||
return {
|
||||
proceed: true,
|
||||
hookResult: {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const hookContext = this.buildToolHookContext("PermissionRequest", context)
|
||||
|
||||
this.emitStatus({
|
||||
status: "running",
|
||||
event: "PermissionRequest",
|
||||
toolName: context.toolName,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.hookManager.executeHooks("PermissionRequest", { context: hookContext })
|
||||
|
||||
if (result.blocked) {
|
||||
// Hook blocked - do not show approval dialog, deny the tool
|
||||
this.emitStatus({
|
||||
status: "blocked",
|
||||
event: "PermissionRequest",
|
||||
toolName: context.toolName,
|
||||
blockMessage: result.blockMessage,
|
||||
duration: result.totalDuration,
|
||||
})
|
||||
|
||||
return {
|
||||
proceed: false,
|
||||
blockReason: result.blockMessage || "Blocked by PermissionRequest hook",
|
||||
hookResult: result,
|
||||
}
|
||||
}
|
||||
|
||||
this.emitStatus({
|
||||
status: "completed",
|
||||
event: "PermissionRequest",
|
||||
toolName: context.toolName,
|
||||
duration: result.totalDuration,
|
||||
})
|
||||
|
||||
return {
|
||||
proceed: true,
|
||||
hookResult: result,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
this.emitStatus({
|
||||
status: "failed",
|
||||
event: "PermissionRequest",
|
||||
toolName: context.toolName,
|
||||
error: errorMessage,
|
||||
})
|
||||
|
||||
// On hook execution error, proceed with showing approval prompt
|
||||
// (fail-open for safety)
|
||||
return {
|
||||
proceed: true,
|
||||
hookResult: {
|
||||
results: [],
|
||||
blocked: false,
|
||||
totalDuration: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hooks are configured and available.
|
||||
*/
|
||||
hasHooks(): boolean {
|
||||
return this.hookManager !== null && this.hookManager.getConfigSnapshot() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build hook context for tool-related events.
|
||||
*/
|
||||
private buildToolHookContext(
|
||||
event: HookEventType,
|
||||
context: ToolExecutionContext,
|
||||
extra?: {
|
||||
output?: unknown
|
||||
duration?: number
|
||||
error?: string
|
||||
errorMessage?: string
|
||||
},
|
||||
): HookContext {
|
||||
const toolContext: HookToolContext = {
|
||||
name: context.toolName,
|
||||
input: context.toolInput,
|
||||
}
|
||||
|
||||
if (extra?.output !== undefined) {
|
||||
toolContext.output = extra.output
|
||||
}
|
||||
|
||||
if (extra?.duration !== undefined) {
|
||||
toolContext.duration = extra.duration
|
||||
}
|
||||
|
||||
if (extra?.error !== undefined) {
|
||||
toolContext.error = extra.error
|
||||
}
|
||||
|
||||
if (extra?.errorMessage !== undefined) {
|
||||
toolContext.errorMessage = extra.errorMessage
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
session: context.session,
|
||||
project: context.project,
|
||||
tool: toolContext,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit status to webview if callback is set.
|
||||
*/
|
||||
private emitStatus(status: Parameters<HookStatusCallback>[0]): void {
|
||||
if (this.statusCallback) {
|
||||
try {
|
||||
this.statusCallback(status)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ToolExecutionHooks instance.
|
||||
*/
|
||||
export function createToolExecutionHooks(
|
||||
hookManager: IHookManager | null,
|
||||
statusCallback?: HookStatusCallback,
|
||||
): ToolExecutionHooks {
|
||||
return new ToolExecutionHooks(hookManager, statusCallback)
|
||||
}
|
||||
337
src/services/hooks/__tests__/HookConfigLoader.spec.ts
Normal file
337
src/services/hooks/__tests__/HookConfigLoader.spec.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* Tests for HookConfigLoader
|
||||
*
|
||||
* Covers:
|
||||
* - Config parsing (YAML and JSON)
|
||||
* - Zod validation
|
||||
* - Precedence merging (project > mode > global)
|
||||
* - Error handling for invalid configs
|
||||
*/
|
||||
|
||||
import { loadHooksConfig, getHooksForEvent, getHookById } from "../HookConfigLoader"
|
||||
import type { HooksConfigSnapshot, HookEventType } from "../types"
|
||||
|
||||
// Create hoisted mocks
|
||||
const mockFsPromises = vi.hoisted(() => ({
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: mockFsPromises,
|
||||
readdir: mockFsPromises.readdir,
|
||||
readFile: mockFsPromises.readFile,
|
||||
access: mockFsPromises.access,
|
||||
}))
|
||||
|
||||
vi.mock("../../roo-config", () => ({
|
||||
getGlobalRooDirectory: () => "/home/user/.roo",
|
||||
getProjectRooDirectoryForCwd: (cwd: string) => `${cwd}/.roo`,
|
||||
}))
|
||||
|
||||
describe("HookConfigLoader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("loadHooksConfig", () => {
|
||||
it("should return empty snapshot when no config files exist", async () => {
|
||||
// Mock: no directories exist
|
||||
mockFsPromises.readdir.mockRejectedValue({ code: "ENOENT" })
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
expect(result.snapshot.hooksByEvent.size).toBe(0)
|
||||
expect(result.snapshot.hooksById.size).toBe(0)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should parse YAML config files", async () => {
|
||||
const yamlContent = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: lint-check
|
||||
matcher: "Edit|Write"
|
||||
command: "./lint.sh"
|
||||
timeout: 30
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.includes("/.roo/hooks")) {
|
||||
return [{ name: "pre-tool.yaml", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().endsWith("pre-tool.yaml")) {
|
||||
return yamlContent
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true)
|
||||
const hooks = result.snapshot.hooksByEvent.get("PreToolUse")!
|
||||
expect(hooks).toHaveLength(1)
|
||||
expect(hooks[0].id).toBe("lint-check")
|
||||
expect(hooks[0].command).toBe("./lint.sh")
|
||||
expect(hooks[0].timeout).toBe(30)
|
||||
})
|
||||
|
||||
it("should parse JSON config files", async () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
version: "1",
|
||||
hooks: {
|
||||
PostToolUse: [{ id: "notify-slack", command: "./notify.sh" }],
|
||||
},
|
||||
})
|
||||
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.includes("/.roo/hooks")) {
|
||||
return [{ name: "post-tool.json", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().endsWith("post-tool.json")) {
|
||||
return jsonContent
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(result.snapshot.hooksByEvent.has("PostToolUse")).toBe(true)
|
||||
})
|
||||
|
||||
it("should report validation errors for invalid config", async () => {
|
||||
// Missing required 'command' field
|
||||
const invalidYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: bad-hook
|
||||
command: ""
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.includes("/.roo/hooks")) {
|
||||
return [{ name: "invalid.yaml", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().endsWith("invalid.yaml")) {
|
||||
return invalidYaml
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
// Should have validation error (command cannot be empty)
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
expect(result.errors[0]).toContain("command")
|
||||
})
|
||||
|
||||
it("should merge configs with project taking precedence over global", async () => {
|
||||
const globalYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: shared-hook
|
||||
command: "global-command"
|
||||
`
|
||||
const projectYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: shared-hook
|
||||
command: "project-command"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.includes(".roo/hooks")) {
|
||||
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
const path = filePath.toString()
|
||||
if (path.includes("/home/user/.roo") && path.endsWith("hooks.yaml")) {
|
||||
return globalYaml
|
||||
}
|
||||
if (path.includes("/project/.roo") && path.endsWith("hooks.yaml")) {
|
||||
return projectYaml
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
const hooks = result.snapshot.hooksByEvent.get("PreToolUse")!
|
||||
expect(hooks).toHaveLength(1)
|
||||
// Project should take precedence
|
||||
expect(hooks[0].command).toBe("project-command")
|
||||
})
|
||||
|
||||
it("should include mode-specific hooks when mode is provided", async () => {
|
||||
const modeYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: mode-hook
|
||||
command: "./mode-specific.sh"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.includes("hooks-code")) {
|
||||
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().includes("hooks-code")) {
|
||||
return modeYaml
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project", mode: "code" })
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true)
|
||||
})
|
||||
|
||||
it("should set hasProjectHooks flag when project hooks exist", async () => {
|
||||
const projectYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: project-hook
|
||||
command: "./project.sh"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
if (dir.endsWith("/.roo/hooks")) {
|
||||
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
mockFsPromises.readFile.mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().includes("/project/.roo")) {
|
||||
return projectYaml
|
||||
}
|
||||
throw { code: "ENOENT" }
|
||||
})
|
||||
|
||||
const result = await loadHooksConfig({ cwd: "/project" })
|
||||
|
||||
expect(result.snapshot.hasProjectHooks).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHooksForEvent", () => {
|
||||
const createSnapshot = (
|
||||
hooks: Array<{ id: string; event: HookEventType; command: string }>,
|
||||
): HooksConfigSnapshot => {
|
||||
const hooksByEvent = new Map<HookEventType, any[]>()
|
||||
const hooksById = new Map<string, any>()
|
||||
|
||||
for (const h of hooks) {
|
||||
const hook = { ...h, enabled: true, _runtimeDisabled: false }
|
||||
if (!hooksByEvent.has(h.event)) {
|
||||
hooksByEvent.set(h.event, [])
|
||||
}
|
||||
hooksByEvent.get(h.event)!.push(hook)
|
||||
hooksById.set(h.id, hook)
|
||||
}
|
||||
|
||||
return {
|
||||
hooksByEvent,
|
||||
hooksById,
|
||||
hasProjectHooks: false,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
it("should return hooks for specific event", () => {
|
||||
const snapshot = createSnapshot([
|
||||
{ id: "hook1", event: "PreToolUse", command: "./a.sh" },
|
||||
{ id: "hook2", event: "PostToolUse", command: "./b.sh" },
|
||||
])
|
||||
|
||||
const result = getHooksForEvent(snapshot, "PreToolUse")
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("hook1")
|
||||
})
|
||||
|
||||
it("should exclude disabled hooks", () => {
|
||||
const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }])
|
||||
// Manually disable the hook
|
||||
snapshot.hooksByEvent.get("PreToolUse")![0].enabled = false
|
||||
|
||||
const result = getHooksForEvent(snapshot, "PreToolUse")
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should exclude runtime-disabled hooks", () => {
|
||||
const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }])
|
||||
// Add hook ID to disabled set
|
||||
snapshot.disabledHookIds.add("hook1")
|
||||
|
||||
const result = getHooksForEvent(snapshot, "PreToolUse")
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return empty array for events with no hooks", () => {
|
||||
const snapshot = createSnapshot([])
|
||||
|
||||
const result = getHooksForEvent(snapshot, "Notification")
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHookById", () => {
|
||||
it("should return hook by ID", () => {
|
||||
const snapshot: HooksConfigSnapshot = {
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map([["my-hook", { id: "my-hook", command: "./test.sh" } as any]]),
|
||||
hasProjectHooks: false,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set<string>(),
|
||||
}
|
||||
|
||||
const result = getHookById(snapshot, "my-hook")
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.id).toBe("my-hook")
|
||||
})
|
||||
|
||||
it("should return undefined for non-existent hook", () => {
|
||||
const snapshot: HooksConfigSnapshot = {
|
||||
hooksByEvent: new Map(),
|
||||
hooksById: new Map(),
|
||||
hasProjectHooks: false,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set<string>(),
|
||||
}
|
||||
|
||||
const result = getHookById(snapshot, "non-existent")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
542
src/services/hooks/__tests__/HookExecutor.spec.ts
Normal file
542
src/services/hooks/__tests__/HookExecutor.spec.ts
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
/**
|
||||
* Tests for HookExecutor
|
||||
*
|
||||
* Covers:
|
||||
* - Exit code handling (0=success, 2=block, other=error)
|
||||
* - Timeout behavior
|
||||
* - Environment variable setup
|
||||
* - stdin JSON payload
|
||||
* - Blocking vs non-blocking events
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { executeHook, interpretResult, describeResult } from "../HookExecutor"
|
||||
import type { ResolvedHook, HookContext, HookExecutionResult, HookEventType } from "../types"
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockSpawn = vi.mocked(spawn)
|
||||
|
||||
describe("HookExecutor", () => {
|
||||
const createMockHook = (overrides: Partial<ResolvedHook> = {}): ResolvedHook =>
|
||||
({
|
||||
id: "test-hook",
|
||||
matcher: "*",
|
||||
enabled: true,
|
||||
command: "echo test",
|
||||
timeout: 5,
|
||||
source: "project",
|
||||
event: "PreToolUse" as HookEventType,
|
||||
filePath: "/test/hooks.yaml",
|
||||
includeConversationHistory: false,
|
||||
...overrides,
|
||||
}) as ResolvedHook
|
||||
|
||||
const createMockContext = (overrides: Partial<HookContext> = {}): HookContext => ({
|
||||
event: "PreToolUse",
|
||||
timestamp: "2026-01-16T12:00:00Z",
|
||||
session: {
|
||||
taskId: "task_123",
|
||||
sessionId: "session_456",
|
||||
mode: "code",
|
||||
},
|
||||
project: {
|
||||
directory: "/project",
|
||||
name: "test-project",
|
||||
},
|
||||
tool: {
|
||||
name: "Write",
|
||||
input: { filePath: "/src/index.ts", content: "// test" },
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("interpretResult", () => {
|
||||
it("should interpret exit code 0 as success", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook(),
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.success).toBe(true)
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
expect(interpretation.shouldContinue).toBe(true)
|
||||
})
|
||||
|
||||
it("should interpret exit code 2 as block for blocking events", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ event: "PreToolUse" }),
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Lint errors found",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.success).toBe(false)
|
||||
expect(interpretation.blocked).toBe(true)
|
||||
expect(interpretation.blockMessage).toBe("Lint errors found")
|
||||
expect(interpretation.shouldContinue).toBe(false)
|
||||
})
|
||||
|
||||
it("should NOT block for non-blocking events even with exit code 2", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ event: "PostToolUse" }),
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Some error",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
expect(interpretation.shouldContinue).toBe(true)
|
||||
})
|
||||
|
||||
it("should interpret other non-zero codes as error (continue)", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook(),
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "Command failed",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.success).toBe(false)
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
expect(interpretation.shouldContinue).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle timeout (continue)", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook(),
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 5000,
|
||||
timedOut: true,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.success).toBe(false)
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
expect(interpretation.shouldContinue).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle execution error (continue)", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook(),
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 10,
|
||||
timedOut: false,
|
||||
error: new Error("Command not found"),
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
|
||||
expect(interpretation.success).toBe(false)
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
expect(interpretation.shouldContinue).toBe(true)
|
||||
})
|
||||
|
||||
describe("blocking events", () => {
|
||||
const blockingEvents: HookEventType[] = [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
]
|
||||
|
||||
for (const event of blockingEvents) {
|
||||
it(`should allow blocking for ${event}`, () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ event }),
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Blocked",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
expect(interpretation.blocked).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("non-blocking events", () => {
|
||||
const nonBlockingEvents: HookEventType[] = [
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"SubagentStart",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"Notification",
|
||||
"PreCompact",
|
||||
]
|
||||
|
||||
for (const event of nonBlockingEvents) {
|
||||
it(`should NOT block for ${event}`, () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ event }),
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Attempted block",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const interpretation = interpretResult(result)
|
||||
expect(interpretation.blocked).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeResult", () => {
|
||||
it("should describe successful execution", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ id: "my-hook" }),
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 150,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const description = describeResult(result)
|
||||
|
||||
expect(description).toContain("my-hook")
|
||||
expect(description).toContain("successfully")
|
||||
expect(description).toContain("150ms")
|
||||
})
|
||||
|
||||
it("should describe blocked execution", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ id: "blocker" }),
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Policy violation",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
const description = describeResult(result)
|
||||
|
||||
expect(description).toContain("blocker")
|
||||
expect(description).toContain("blocked")
|
||||
expect(description).toContain("Policy violation")
|
||||
})
|
||||
|
||||
it("should describe timeout", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ id: "slow-hook", timeout: 30 }),
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 30000,
|
||||
timedOut: true,
|
||||
}
|
||||
|
||||
const description = describeResult(result)
|
||||
|
||||
expect(description).toContain("slow-hook")
|
||||
expect(description).toContain("timed out")
|
||||
expect(description).toContain("30")
|
||||
})
|
||||
|
||||
it("should describe execution error", () => {
|
||||
const result: HookExecutionResult = {
|
||||
hook: createMockHook({ id: "broken" }),
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 10,
|
||||
timedOut: false,
|
||||
error: new Error("Command not found: badcmd"),
|
||||
}
|
||||
|
||||
const description = describeResult(result)
|
||||
|
||||
expect(description).toContain("broken")
|
||||
expect(description).toContain("failed")
|
||||
expect(description).toContain("Command not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeHook", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should spawn process with correct arguments", async () => {
|
||||
const mockProcess = {
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => cb(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const hook = createMockHook({ command: "./test-script.sh" })
|
||||
const context = createMockContext()
|
||||
|
||||
const resultPromise = executeHook(hook, context)
|
||||
|
||||
// Wait a tick for the spawn to be called
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled()
|
||||
const [shell, args, options] = mockSpawn.mock.calls[0]
|
||||
|
||||
// Should use shell with -c flag (or PowerShell equivalent)
|
||||
expect(args[args.length - 1]).toBe("./test-script.sh")
|
||||
expect(options.cwd).toBe("/project")
|
||||
|
||||
const result = await resultPromise
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("should write JSON context to stdin", async () => {
|
||||
const stdinWrite = vi.fn()
|
||||
const mockProcess = {
|
||||
stdin: { write: stdinWrite, end: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => cb(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const hook = createMockHook()
|
||||
const context = createMockContext()
|
||||
|
||||
await executeHook(hook, context)
|
||||
|
||||
expect(stdinWrite).toHaveBeenCalled()
|
||||
const jsonPayload = stdinWrite.mock.calls[0][0]
|
||||
const parsed = JSON.parse(jsonPayload)
|
||||
|
||||
expect(parsed.event).toBe("PreToolUse")
|
||||
expect(parsed.session.taskId).toBe("task_123")
|
||||
expect(parsed.project.directory).toBe("/project")
|
||||
expect(parsed.tool.name).toBe("Write")
|
||||
})
|
||||
|
||||
it("should set environment variables", async () => {
|
||||
const mockProcess = {
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => cb(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const hook = createMockHook({ id: "env-test" })
|
||||
const context = createMockContext()
|
||||
|
||||
await executeHook(hook, context)
|
||||
|
||||
const options = mockSpawn.mock.calls[0][2]
|
||||
const env = options.env
|
||||
|
||||
expect(env).toBeDefined()
|
||||
if (!env) throw new Error("Expected env to be captured")
|
||||
|
||||
expect(env.ROO_PROJECT_DIR).toBe("/project")
|
||||
expect(env.ROO_TASK_ID).toBe("task_123")
|
||||
expect(env.ROO_SESSION_ID).toBe("session_456")
|
||||
expect(env.ROO_MODE).toBe("code")
|
||||
expect(env.ROO_TOOL_NAME).toBe("Write")
|
||||
expect(env.ROO_EVENT).toBe("PreToolUse")
|
||||
expect(env.ROO_HOOK_ID).toBe("env-test")
|
||||
})
|
||||
|
||||
it("should NOT include conversation history by default", async () => {
|
||||
const stdinWrite = vi.fn()
|
||||
const mockProcess = {
|
||||
stdin: { write: stdinWrite, end: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => cb(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const hook = createMockHook({ includeConversationHistory: false })
|
||||
const context = createMockContext()
|
||||
const history = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
await executeHook(hook, context, history)
|
||||
|
||||
const jsonPayload = stdinWrite.mock.calls[0][0]
|
||||
const parsed = JSON.parse(jsonPayload)
|
||||
|
||||
expect(parsed.conversationHistory).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should include conversation history when opted in", async () => {
|
||||
const stdinWrite = vi.fn()
|
||||
const mockProcess = {
|
||||
stdin: { write: stdinWrite, end: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => cb(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const hook = createMockHook({ includeConversationHistory: true })
|
||||
const context = createMockContext()
|
||||
const history = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
await executeHook(hook, context, history)
|
||||
|
||||
const jsonPayload = stdinWrite.mock.calls[0][0]
|
||||
const parsed = JSON.parse(jsonPayload)
|
||||
|
||||
expect(parsed.conversationHistory).toBeDefined()
|
||||
expect(parsed.conversationHistory).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should capture stdout and stderr", async () => {
|
||||
let stdoutCallback: ((data: Buffer) => void) | undefined
|
||||
let stderrCallback: ((data: Buffer) => void) | undefined
|
||||
|
||||
const mockProcess = {
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
stdout: {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "data") stdoutCallback = cb
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "data") stderrCallback = cb
|
||||
}),
|
||||
},
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => {
|
||||
stdoutCallback?.(Buffer.from("stdout content"))
|
||||
stderrCallback?.(Buffer.from("stderr content"))
|
||||
cb(0)
|
||||
}, 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const result = await executeHook(createMockHook(), createMockContext())
|
||||
|
||||
expect(result.stdout).toContain("stdout content")
|
||||
expect(result.stderr).toContain("stderr content")
|
||||
})
|
||||
|
||||
it("should handle process spawn errors", async () => {
|
||||
const mockProcess = {
|
||||
stdin: null,
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "error") {
|
||||
setTimeout(() => cb(new Error("spawn ENOENT")), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const result = await executeHook(createMockHook(), createMockContext())
|
||||
|
||||
expect(result.error).toBeDefined()
|
||||
expect(result.error?.message).toContain("ENOENT")
|
||||
})
|
||||
|
||||
it("should parse modification JSON from stdout for PreToolUse", async () => {
|
||||
let stdoutCallback: ((data: Buffer) => void) | undefined
|
||||
|
||||
const modificationJson = JSON.stringify({
|
||||
action: "modify",
|
||||
toolInput: { filePath: "/modified/path.ts", content: "// modified" },
|
||||
})
|
||||
|
||||
const mockProcess = {
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
stdout: {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "data") stdoutCallback = cb
|
||||
}),
|
||||
},
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => {
|
||||
stdoutCallback?.(Buffer.from(modificationJson))
|
||||
cb(0)
|
||||
}, 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
const result = await executeHook(createMockHook({ event: "PreToolUse" }), createMockContext())
|
||||
|
||||
expect(result.modification).toBeDefined()
|
||||
expect(result.modification?.action).toBe("modify")
|
||||
expect(result.modification?.toolInput.filePath).toBe("/modified/path.ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
548
src/services/hooks/__tests__/HookManager.spec.ts
Normal file
548
src/services/hooks/__tests__/HookManager.spec.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* Tests for HookManager
|
||||
*
|
||||
* Covers:
|
||||
* - Config loading and snapshot management
|
||||
* - Sequential hook execution
|
||||
* - Enable/disable functionality
|
||||
* - Execution history tracking
|
||||
*/
|
||||
|
||||
import { HookManager, createHookManager } from "../HookManager"
|
||||
import * as HookConfigLoader from "../HookConfigLoader"
|
||||
import * as HookExecutor from "../HookExecutor"
|
||||
import type { HooksConfigSnapshot, ResolvedHook, HookEventType, HookContext } from "../types"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../HookConfigLoader")
|
||||
vi.mock("../HookExecutor")
|
||||
|
||||
const mockLoadHooksConfig = vi.mocked(HookConfigLoader.loadHooksConfig)
|
||||
const mockGetHooksForEvent = vi.mocked(HookConfigLoader.getHooksForEvent)
|
||||
const mockExecuteHook = vi.mocked(HookExecutor.executeHook)
|
||||
const mockInterpretResult = vi.mocked(HookExecutor.interpretResult)
|
||||
const mockDescribeResult = vi.mocked(HookExecutor.describeResult)
|
||||
|
||||
describe("HookManager", () => {
|
||||
const createMockHook = (id: string, event: HookEventType = "PreToolUse"): ResolvedHook =>
|
||||
({
|
||||
id,
|
||||
matcher: "*",
|
||||
enabled: true,
|
||||
command: `echo ${id}`,
|
||||
timeout: 60,
|
||||
source: "project",
|
||||
event,
|
||||
filePath: "/test/hooks.yaml",
|
||||
}) as ResolvedHook
|
||||
|
||||
const createMockSnapshot = (hooks: ResolvedHook[] = []): HooksConfigSnapshot => {
|
||||
const hooksByEvent = new Map<HookEventType, ResolvedHook[]>()
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (!hooksByEvent.has(hook.event)) {
|
||||
hooksByEvent.set(hook.event, [])
|
||||
}
|
||||
hooksByEvent.get(hook.event)!.push(hook)
|
||||
hooksById.set(hook.id, hook)
|
||||
}
|
||||
|
||||
return {
|
||||
hooksByEvent,
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: hooks.some((h) => h.source === "project"),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDescribeResult.mockReturnValue("Hook executed")
|
||||
})
|
||||
|
||||
describe("loadHooksConfig", () => {
|
||||
it("should load config and return snapshot", async () => {
|
||||
const mockSnapshot = createMockSnapshot([createMockHook("hook1")])
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot: mockSnapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
const snapshot = await manager.loadHooksConfig()
|
||||
|
||||
expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: undefined })
|
||||
expect(snapshot).toBe(mockSnapshot)
|
||||
})
|
||||
|
||||
it("should pass mode to config loader", async () => {
|
||||
const mockSnapshot = createMockSnapshot([])
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot: mockSnapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project", mode: "code" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: "code" })
|
||||
})
|
||||
|
||||
it("should log errors and warnings", async () => {
|
||||
const mockSnapshot = createMockSnapshot([])
|
||||
const errorLog: string[] = []
|
||||
const warnLog: string[] = []
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot: mockSnapshot,
|
||||
errors: ["Config error 1"],
|
||||
warnings: ["Warning 1"],
|
||||
})
|
||||
|
||||
const manager = createHookManager({
|
||||
cwd: "/project",
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: (msg) => warnLog.push(msg),
|
||||
error: (msg) => errorLog.push(msg),
|
||||
},
|
||||
})
|
||||
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
expect(errorLog.some((e) => e.includes("Config error 1"))).toBe(true)
|
||||
expect(warnLog.some((w) => w.includes("Warning 1"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reloadHooksConfig", () => {
|
||||
it("should reload config while preserving disabled hooks", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const hook2 = createMockHook("hook2")
|
||||
|
||||
// Initial load
|
||||
const initialSnapshot = createMockSnapshot([hook1, hook2])
|
||||
mockLoadHooksConfig.mockResolvedValueOnce({
|
||||
snapshot: initialSnapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
// Disable hook2
|
||||
await manager.setHookEnabled("hook2", false)
|
||||
|
||||
// Reload with same hooks
|
||||
const reloadedSnapshot = createMockSnapshot([hook1, hook2])
|
||||
mockLoadHooksConfig.mockResolvedValueOnce({
|
||||
snapshot: reloadedSnapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
await manager.reloadHooksConfig()
|
||||
|
||||
const snapshot = manager.getConfigSnapshot()
|
||||
expect(snapshot?.disabledHookIds.has("hook2")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeHooks", () => {
|
||||
const createMockContext = (): HookContext => ({
|
||||
event: "PreToolUse",
|
||||
timestamp: "2026-01-16T12:00:00Z",
|
||||
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
|
||||
project: { directory: "/project", name: "test" },
|
||||
tool: { name: "Write", input: { filePath: "/test.ts", content: "test" } },
|
||||
})
|
||||
|
||||
it("should execute hooks sequentially", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const hook2 = createMockHook("hook2")
|
||||
const snapshot = createMockSnapshot([hook1, hook2])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
|
||||
mockExecuteHook.mockResolvedValue({
|
||||
hook: hook1,
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
})
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
|
||||
|
||||
// Should execute both hooks
|
||||
expect(mockExecuteHook).toHaveBeenCalledTimes(2)
|
||||
expect(result.results).toHaveLength(2)
|
||||
expect(result.blocked).toBe(false)
|
||||
})
|
||||
|
||||
it("should stop execution when a hook blocks", async () => {
|
||||
const hook1 = createMockHook("blocker")
|
||||
const hook2 = createMockHook("after-block")
|
||||
const snapshot = createMockSnapshot([hook1, hook2])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
|
||||
mockExecuteHook.mockResolvedValue({
|
||||
hook: hook1,
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "Blocked!",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
})
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: false,
|
||||
blocked: true,
|
||||
blockMessage: "Blocked!",
|
||||
shouldContinue: false,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
|
||||
|
||||
// Should only execute first hook (the blocker)
|
||||
expect(mockExecuteHook).toHaveBeenCalledTimes(1)
|
||||
expect(result.results).toHaveLength(1)
|
||||
expect(result.blocked).toBe(true)
|
||||
expect(result.blockMessage).toBe("Blocked!")
|
||||
})
|
||||
|
||||
it("should continue on non-blocking failures", async () => {
|
||||
const hook1 = createMockHook("failing")
|
||||
const hook2 = createMockHook("after-fail")
|
||||
const snapshot = createMockSnapshot([hook1, hook2])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
|
||||
|
||||
let callCount = 0
|
||||
mockExecuteHook.mockImplementation(async (hook) => ({
|
||||
hook,
|
||||
exitCode: callCount++ === 0 ? 1 : 0,
|
||||
stdout: "",
|
||||
stderr: callCount === 1 ? "Error" : "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
}))
|
||||
mockInterpretResult.mockImplementation((result) => ({
|
||||
success: result.exitCode === 0,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
}))
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
|
||||
|
||||
// Should execute both hooks despite first failure
|
||||
expect(mockExecuteHook).toHaveBeenCalledTimes(2)
|
||||
expect(result.results).toHaveLength(2)
|
||||
expect(result.blocked).toBe(false)
|
||||
})
|
||||
|
||||
it("should return first modification only", async () => {
|
||||
const hook1 = createMockHook("modifier1")
|
||||
const hook2 = createMockHook("modifier2")
|
||||
const snapshot = createMockSnapshot([hook1, hook2])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
|
||||
|
||||
let callCount = 0
|
||||
mockExecuteHook.mockImplementation(async (hook) => ({
|
||||
hook,
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
modification:
|
||||
callCount++ === 0
|
||||
? { action: "modify" as const, toolInput: { from: "first" } }
|
||||
: { action: "modify" as const, toolInput: { from: "second" } },
|
||||
}))
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
|
||||
|
||||
expect(result.modification?.toolInput).toEqual({ from: "first" })
|
||||
})
|
||||
|
||||
it("should auto-load config if not loaded", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const snapshot = createMockSnapshot([hook1])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1])
|
||||
mockExecuteHook.mockResolvedValue({
|
||||
hook: hook1,
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
})
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
// Don't call loadHooksConfig explicitly
|
||||
|
||||
await manager.executeHooks("PreToolUse", { context: createMockContext() })
|
||||
|
||||
// Should have auto-loaded
|
||||
expect(mockLoadHooksConfig).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEnabledHooks", () => {
|
||||
it("should return all enabled hooks", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const hook2 = createMockHook("hook2")
|
||||
const disabledHook = { ...createMockHook("disabled"), enabled: false } as ResolvedHook
|
||||
const snapshot = createMockSnapshot([hook1, hook2, disabledHook])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
const enabled = manager.getEnabledHooks()
|
||||
|
||||
expect(enabled).toHaveLength(2)
|
||||
expect(enabled.map((h) => h.id).sort()).toEqual(["hook1", "hook2"])
|
||||
})
|
||||
|
||||
it("should exclude runtime-disabled hooks", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const hook2 = createMockHook("hook2")
|
||||
const snapshot = createMockSnapshot([hook1, hook2])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.setHookEnabled("hook2", false)
|
||||
|
||||
const enabled = manager.getEnabledHooks()
|
||||
|
||||
expect(enabled).toHaveLength(1)
|
||||
expect(enabled[0].id).toBe("hook1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("setHookEnabled", () => {
|
||||
it("should disable a hook", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const snapshot = createMockSnapshot([hook1])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.setHookEnabled("hook1", false)
|
||||
|
||||
expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(true)
|
||||
})
|
||||
|
||||
it("should re-enable a disabled hook", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const snapshot = createMockSnapshot([hook1])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.setHookEnabled("hook1", false)
|
||||
await manager.setHookEnabled("hook1", true)
|
||||
|
||||
expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw for non-existent hook", async () => {
|
||||
const snapshot = createMockSnapshot([])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await expect(manager.setHookEnabled("nonexistent", false)).rejects.toThrow("Hook not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHookExecutionHistory", () => {
|
||||
it("should return execution history", async () => {
|
||||
const hook1 = createMockHook("hook1")
|
||||
const snapshot = createMockSnapshot([hook1])
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue([hook1])
|
||||
mockExecuteHook.mockResolvedValue({
|
||||
hook: hook1,
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
})
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.executeHooks("PreToolUse", {
|
||||
context: {
|
||||
event: "PreToolUse",
|
||||
timestamp: "2026-01-16T12:00:00Z",
|
||||
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
|
||||
project: { directory: "/project", name: "test" },
|
||||
},
|
||||
})
|
||||
|
||||
const history = manager.getHookExecutionHistory()
|
||||
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].hook.id).toBe("hook1")
|
||||
expect(history[0].event).toBe("PreToolUse")
|
||||
})
|
||||
|
||||
it("should limit history size", async () => {
|
||||
const hooks = Array.from({ length: 150 }, (_, i) => createMockHook(`hook${i}`))
|
||||
const snapshot = createMockSnapshot(hooks)
|
||||
|
||||
mockLoadHooksConfig.mockResolvedValue({
|
||||
snapshot,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
})
|
||||
mockGetHooksForEvent.mockReturnValue(hooks)
|
||||
mockExecuteHook.mockImplementation(async (hook) => ({
|
||||
hook,
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
duration: 10,
|
||||
timedOut: false,
|
||||
}))
|
||||
mockInterpretResult.mockReturnValue({
|
||||
success: true,
|
||||
blocked: false,
|
||||
blockMessage: undefined,
|
||||
shouldContinue: true,
|
||||
})
|
||||
|
||||
const manager = createHookManager({ cwd: "/project", maxHistoryEntries: 100 })
|
||||
await manager.loadHooksConfig()
|
||||
|
||||
await manager.executeHooks("PreToolUse", {
|
||||
context: {
|
||||
event: "PreToolUse",
|
||||
timestamp: "2026-01-16T12:00:00Z",
|
||||
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
|
||||
project: { directory: "/project", name: "test" },
|
||||
},
|
||||
})
|
||||
|
||||
const history = manager.getHookExecutionHistory()
|
||||
|
||||
expect(history.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createHookManager", () => {
|
||||
it("should create a HookManager instance", () => {
|
||||
const manager = createHookManager({ cwd: "/project" })
|
||||
expect(manager).toBeInstanceOf(HookManager)
|
||||
})
|
||||
})
|
||||
})
|
||||
219
src/services/hooks/__tests__/HookMatcher.spec.ts
Normal file
219
src/services/hooks/__tests__/HookMatcher.spec.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Tests for HookMatcher
|
||||
*
|
||||
* Covers:
|
||||
* - Exact matching
|
||||
* - Regex pattern matching
|
||||
* - Glob pattern matching
|
||||
* - Match-all behavior
|
||||
* - Cache behavior
|
||||
*/
|
||||
|
||||
import { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "../HookMatcher"
|
||||
import type { ResolvedHook } from "../types"
|
||||
|
||||
describe("HookMatcher", () => {
|
||||
beforeEach(() => {
|
||||
clearMatcherCache()
|
||||
})
|
||||
|
||||
describe("compileMatcher", () => {
|
||||
describe("match-all patterns", () => {
|
||||
it('should match all tools with "*" pattern', () => {
|
||||
const matcher = compileMatcher("*")
|
||||
expect(matcher.type).toBe("all")
|
||||
expect(matcher.matches("Write")).toBe(true)
|
||||
expect(matcher.matches("Read")).toBe(true)
|
||||
expect(matcher.matches("Bash")).toBe(true)
|
||||
expect(matcher.matches("anything")).toBe(true)
|
||||
})
|
||||
|
||||
it("should match all tools with undefined pattern", () => {
|
||||
const matcher = compileMatcher(undefined)
|
||||
expect(matcher.type).toBe("all")
|
||||
expect(matcher.matches("Write")).toBe(true)
|
||||
expect(matcher.matches("Read")).toBe(true)
|
||||
})
|
||||
|
||||
it("should match all tools with empty string pattern", () => {
|
||||
const matcher = compileMatcher("")
|
||||
expect(matcher.type).toBe("all")
|
||||
expect(matcher.matches("Write")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("exact matching", () => {
|
||||
it("should match exact tool name (case-insensitive)", () => {
|
||||
const matcher = compileMatcher("Write")
|
||||
expect(matcher.type).toBe("exact")
|
||||
expect(matcher.matches("Write")).toBe(true)
|
||||
expect(matcher.matches("write")).toBe(true)
|
||||
expect(matcher.matches("WRITE")).toBe(true)
|
||||
expect(matcher.matches("Read")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not match partial names", () => {
|
||||
const matcher = compileMatcher("Write")
|
||||
expect(matcher.matches("WriteFile")).toBe(false)
|
||||
expect(matcher.matches("FileWrite")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("regex pattern matching", () => {
|
||||
it("should match regex with pipe (|) alternation", () => {
|
||||
const matcher = compileMatcher("Edit|Write")
|
||||
expect(matcher.type).toBe("regex")
|
||||
expect(matcher.matches("Edit")).toBe(true)
|
||||
expect(matcher.matches("Write")).toBe(true)
|
||||
expect(matcher.matches("Read")).toBe(false)
|
||||
})
|
||||
|
||||
it("should match regex with character classes", () => {
|
||||
const matcher = compileMatcher("File[RW].*")
|
||||
expect(matcher.type).toBe("regex")
|
||||
expect(matcher.matches("FileRead")).toBe(true)
|
||||
expect(matcher.matches("FileWrite")).toBe(true)
|
||||
expect(matcher.matches("FileDelete")).toBe(false)
|
||||
})
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
const matcher = compileMatcher("edit|write")
|
||||
expect(matcher.matches("Edit")).toBe(true)
|
||||
expect(matcher.matches("WRITE")).toBe(true)
|
||||
})
|
||||
|
||||
it("should fall back to exact match on invalid regex", () => {
|
||||
// Unclosed bracket is invalid regex
|
||||
const matcher = compileMatcher("Edit[")
|
||||
expect(matcher.type).toBe("exact")
|
||||
expect(matcher.matches("Edit[")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("glob pattern matching", () => {
|
||||
it("should match glob with * wildcard", () => {
|
||||
const matcher = compileMatcher("mcp__*")
|
||||
expect(matcher.type).toBe("glob")
|
||||
expect(matcher.matches("mcp__tool1")).toBe(true)
|
||||
expect(matcher.matches("mcp__server__action")).toBe(true)
|
||||
expect(matcher.matches("mcp_")).toBe(false)
|
||||
expect(matcher.matches("other_tool")).toBe(false)
|
||||
})
|
||||
|
||||
it("should match glob with ? single-char wildcard", () => {
|
||||
const matcher = compileMatcher("Tool?")
|
||||
expect(matcher.type).toBe("glob")
|
||||
expect(matcher.matches("Tool1")).toBe(true)
|
||||
expect(matcher.matches("ToolA")).toBe(true)
|
||||
expect(matcher.matches("Tool")).toBe(false)
|
||||
expect(matcher.matches("Tool12")).toBe(false)
|
||||
})
|
||||
|
||||
it("should match glob with * in middle", () => {
|
||||
const matcher = compileMatcher("*File*")
|
||||
expect(matcher.type).toBe("glob")
|
||||
expect(matcher.matches("FileRead")).toBe(true)
|
||||
expect(matcher.matches("ReadFile")).toBe(true)
|
||||
expect(matcher.matches("ReadFileNow")).toBe(true)
|
||||
expect(matcher.matches("Tool")).toBe(false)
|
||||
})
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
const matcher = compileMatcher("MCP__*")
|
||||
expect(matcher.matches("mcp__tool")).toBe(true)
|
||||
expect(matcher.matches("MCP__TOOL")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMatcher (caching)", () => {
|
||||
it("should return same matcher for same pattern", () => {
|
||||
const matcher1 = getMatcher("Write")
|
||||
const matcher2 = getMatcher("Write")
|
||||
expect(matcher1).toBe(matcher2)
|
||||
})
|
||||
|
||||
it("should cache undefined as '*'", () => {
|
||||
const matcher1 = getMatcher(undefined)
|
||||
const matcher2 = getMatcher("*")
|
||||
expect(matcher1).toBe(matcher2)
|
||||
})
|
||||
|
||||
it("should return different matchers for different patterns", () => {
|
||||
const matcher1 = getMatcher("Write")
|
||||
const matcher2 = getMatcher("Read")
|
||||
expect(matcher1).not.toBe(matcher2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterMatchingHooks", () => {
|
||||
const createMockHook = (id: string, matcher?: string): ResolvedHook =>
|
||||
({
|
||||
id,
|
||||
matcher,
|
||||
enabled: true,
|
||||
command: "echo test",
|
||||
timeout: 60,
|
||||
source: "project",
|
||||
event: "PreToolUse",
|
||||
filePath: "/test/hooks.yaml",
|
||||
}) as ResolvedHook
|
||||
|
||||
it("should filter hooks by tool name", () => {
|
||||
const hooks = [
|
||||
createMockHook("hook1", "Write"),
|
||||
createMockHook("hook2", "Read"),
|
||||
createMockHook("hook3", "Edit|Write"),
|
||||
]
|
||||
|
||||
const matching = filterMatchingHooks(hooks, "Write")
|
||||
expect(matching).toHaveLength(2)
|
||||
expect(matching.map((h) => h.id)).toEqual(["hook1", "hook3"])
|
||||
})
|
||||
|
||||
it("should include match-all hooks", () => {
|
||||
const hooks = [
|
||||
createMockHook("hook1", "*"),
|
||||
createMockHook("hook2", undefined),
|
||||
createMockHook("hook3", "Read"),
|
||||
]
|
||||
|
||||
const matching = filterMatchingHooks(hooks, "Write")
|
||||
expect(matching).toHaveLength(2)
|
||||
expect(matching.map((h) => h.id)).toEqual(["hook1", "hook2"])
|
||||
})
|
||||
|
||||
it("should return empty array when no hooks match", () => {
|
||||
const hooks = [createMockHook("hook1", "Read"), createMockHook("hook2", "Edit")]
|
||||
|
||||
const matching = filterMatchingHooks(hooks, "Write")
|
||||
expect(matching).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hookMatchesTool", () => {
|
||||
const createMockHook = (matcher?: string): ResolvedHook =>
|
||||
({
|
||||
id: "test",
|
||||
matcher,
|
||||
enabled: true,
|
||||
command: "echo test",
|
||||
timeout: 60,
|
||||
source: "project",
|
||||
event: "PreToolUse",
|
||||
filePath: "/test/hooks.yaml",
|
||||
}) as ResolvedHook
|
||||
|
||||
it("should return true for matching hook", () => {
|
||||
expect(hookMatchesTool(createMockHook("Write"), "Write")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-matching hook", () => {
|
||||
expect(hookMatchesTool(createMockHook("Read"), "Write")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for match-all hook", () => {
|
||||
expect(hookMatchesTool(createMockHook("*"), "AnyTool")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
102
src/services/hooks/index.ts
Normal file
102
src/services/hooks/index.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Hooks Service
|
||||
*
|
||||
* Provides Claude Code-style hooks for Roo Code.
|
||||
* Hooks allow users to run custom shell commands at key lifecycle events.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createHookManager, HookEventType } from './services/hooks'
|
||||
*
|
||||
* const hookManager = createHookManager({
|
||||
* cwd: '/path/to/project',
|
||||
* mode: 'code'
|
||||
* })
|
||||
*
|
||||
* // Load configuration
|
||||
* await hookManager.loadHooksConfig()
|
||||
*
|
||||
* // Execute hooks for an event
|
||||
* const result = await hookManager.executeHooks('PreToolUse', {
|
||||
* context: {
|
||||
* event: 'PreToolUse',
|
||||
* timestamp: new Date().toISOString(),
|
||||
* session: { taskId: 'task_1', sessionId: 'session_1', mode: 'code' },
|
||||
* project: { directory: '/path/to/project', name: 'my-project' },
|
||||
* tool: { name: 'Write', input: { filePath: '/src/index.ts', content: '...' } }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* if (result.blocked) {
|
||||
* console.log('Hook blocked:', result.blockMessage)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
// Event types
|
||||
HookEventType,
|
||||
BLOCKING_EVENTS,
|
||||
isBlockingEvent,
|
||||
|
||||
// Schema types
|
||||
HookDefinitionSchema,
|
||||
HooksConfigFileSchema,
|
||||
HookModificationSchema,
|
||||
|
||||
// Type definitions
|
||||
type HookDefinition,
|
||||
type HooksConfigFile,
|
||||
type HookSource,
|
||||
type ResolvedHook,
|
||||
type HooksConfigSnapshot,
|
||||
|
||||
// Context types
|
||||
type HookContext,
|
||||
type HookSessionContext,
|
||||
type HookProjectContext,
|
||||
type HookToolContext,
|
||||
type HookPromptContext,
|
||||
type HookNotificationContext,
|
||||
type ConversationHistoryEntry,
|
||||
|
||||
// Execution types
|
||||
HookExitCode,
|
||||
type HookExecutionResult,
|
||||
type HooksExecutionResult,
|
||||
type HookModification,
|
||||
type HookExecution,
|
||||
type ExecuteHooksOptions,
|
||||
|
||||
// Manager interface
|
||||
type IHookManager,
|
||||
} from "./types"
|
||||
|
||||
// Config loader
|
||||
export {
|
||||
loadHooksConfig,
|
||||
getHooksForEvent,
|
||||
getHookById,
|
||||
type LoadHooksConfigOptions,
|
||||
type LoadHooksConfigResult,
|
||||
} from "./HookConfigLoader"
|
||||
|
||||
// Matcher
|
||||
export { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "./HookMatcher"
|
||||
|
||||
// Executor
|
||||
export { executeHook, interpretResult, describeResult } from "./HookExecutor"
|
||||
|
||||
// Manager
|
||||
export { HookManager, createHookManager, type HookManagerOptions } from "./HookManager"
|
||||
|
||||
// Tool Execution Integration
|
||||
export {
|
||||
ToolExecutionHooks,
|
||||
createToolExecutionHooks,
|
||||
type ToolExecutionContext,
|
||||
type PreToolUseResult,
|
||||
type PermissionRequestResult,
|
||||
type HookStatusCallback,
|
||||
} from "./ToolExecutionHooks"
|
||||
379
src/services/hooks/types.ts
Normal file
379
src/services/hooks/types.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Hook System Types and Zod Schemas
|
||||
*
|
||||
* This module defines the configuration schema and types for the Roo Code hooks system.
|
||||
* Compatible with Claude Code hook semantics.
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
// ============================================================================
|
||||
// Hook Events
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* All supported hook event types.
|
||||
* Blocking events can halt/modify execution via exit code 2.
|
||||
* Non-blocking events are informational only.
|
||||
*/
|
||||
export const HookEventType = z.enum([
|
||||
"PreToolUse", // Before tool execution (blocking)
|
||||
"PostToolUse", // After successful tool completion
|
||||
"PostToolUseFailure", // After tool execution fails
|
||||
"PermissionRequest", // When tool approval dialog shown (blocking)
|
||||
"UserPromptSubmit", // When user sends message (blocking)
|
||||
"Stop", // When task completes or stops (blocking)
|
||||
"SubagentStop", // When subtask completes (blocking)
|
||||
"SubagentStart", // When subtask begins
|
||||
"SessionStart", // When new task created
|
||||
"SessionEnd", // When task fully ends
|
||||
"Notification", // When status messages sent
|
||||
"PreCompact", // Before context compaction
|
||||
])
|
||||
|
||||
export type HookEventType = z.infer<typeof HookEventType>
|
||||
|
||||
/**
|
||||
* Events that can block execution by returning exit code 2.
|
||||
*/
|
||||
export const BLOCKING_EVENTS: Set<HookEventType> = new Set([
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if an event type supports blocking behavior.
|
||||
*/
|
||||
export function isBlockingEvent(event: HookEventType): boolean {
|
||||
return BLOCKING_EVENTS.has(event)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Definition Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for a single hook definition within a config file.
|
||||
*/
|
||||
export const HookDefinitionSchema = z.object({
|
||||
/** Unique identifier for this hook */
|
||||
id: z.string().min(1, "Hook ID cannot be empty"),
|
||||
|
||||
/** Tool name filter (regex/glob pattern). If omitted, matches all tools. */
|
||||
matcher: z.string().optional(),
|
||||
|
||||
/** Whether this hook is enabled. Defaults to true. */
|
||||
enabled: z.boolean().optional().default(true),
|
||||
|
||||
/** Shell command to execute */
|
||||
command: z.string().min(1, "Command cannot be empty"),
|
||||
|
||||
/** Timeout in seconds. Defaults to 60. */
|
||||
timeout: z.number().positive().optional().default(60),
|
||||
|
||||
/** Human-readable description of what this hook does */
|
||||
description: z.string().optional(),
|
||||
|
||||
/** Override shell (default: user's shell on Unix, PowerShell on Windows) */
|
||||
shell: z.string().optional(),
|
||||
|
||||
/** Opt-in to receive conversation history in stdin. Defaults to false. */
|
||||
includeConversationHistory: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
export type HookDefinition = z.infer<typeof HookDefinitionSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Hook Config File Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for a hooks configuration file (.roo/hooks/*.yaml or *.json).
|
||||
*/
|
||||
export const HooksConfigFileSchema = z.object({
|
||||
/** Config format version */
|
||||
version: z.literal("1"),
|
||||
|
||||
/** Hooks organized by event type */
|
||||
hooks: z.record(HookEventType, z.array(HookDefinitionSchema)).optional().default({}),
|
||||
})
|
||||
|
||||
export type HooksConfigFile = z.infer<typeof HooksConfigFileSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Internal Types (for runtime use)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Source of a hook configuration.
|
||||
*/
|
||||
export type HookSource = "project" | "mode" | "global"
|
||||
|
||||
/**
|
||||
* Extended hook definition with source information.
|
||||
* Used internally after merging configs from multiple sources.
|
||||
*/
|
||||
export interface ResolvedHook extends HookDefinition {
|
||||
/** Which config source this hook came from */
|
||||
source: HookSource
|
||||
|
||||
/** The event type this hook is registered for */
|
||||
event: HookEventType
|
||||
|
||||
/** File path where this hook was defined */
|
||||
filePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory snapshot of all loaded hooks configuration.
|
||||
* This is immutable once created - changes require explicit reload.
|
||||
*/
|
||||
export interface HooksConfigSnapshot {
|
||||
/** All resolved hooks, organized by event type */
|
||||
hooksByEvent: Map<HookEventType, ResolvedHook[]>
|
||||
|
||||
/** Lookup by hook ID for quick access */
|
||||
hooksById: Map<string, ResolvedHook>
|
||||
|
||||
/** When this snapshot was created */
|
||||
loadedAt: Date
|
||||
|
||||
/** IDs of hooks that have been disabled at runtime */
|
||||
disabledHookIds: Set<string>
|
||||
|
||||
/** Whether project hooks are present (for security warnings) */
|
||||
hasProjectHooks: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Context (passed to hooks via stdin)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Session information included in hook context.
|
||||
*/
|
||||
export interface HookSessionContext {
|
||||
taskId: string
|
||||
sessionId: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project information included in hook context.
|
||||
*/
|
||||
export interface HookProjectContext {
|
||||
directory: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool information for tool-related events.
|
||||
*/
|
||||
export interface HookToolContext {
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
/** Only present for PostToolUse */
|
||||
output?: unknown
|
||||
/** Only present for PostToolUse */
|
||||
duration?: number
|
||||
/** Only present for PostToolUseFailure */
|
||||
error?: string
|
||||
/** Only present for PostToolUseFailure */
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User prompt information for UserPromptSubmit event.
|
||||
*/
|
||||
export interface HookPromptContext {
|
||||
text: string
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification information for Notification event.
|
||||
*/
|
||||
export interface HookNotificationContext {
|
||||
message: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation history entry (opt-in via includeConversationHistory).
|
||||
*/
|
||||
export interface ConversationHistoryEntry {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full context passed to hooks via stdin as JSON.
|
||||
*/
|
||||
export interface HookContext {
|
||||
event: HookEventType
|
||||
timestamp: string
|
||||
session: HookSessionContext
|
||||
project: HookProjectContext
|
||||
|
||||
/** Tool context - present for tool-related events */
|
||||
tool?: HookToolContext
|
||||
|
||||
/** Prompt context - present for UserPromptSubmit */
|
||||
prompt?: HookPromptContext
|
||||
|
||||
/** Notification context - present for Notification event */
|
||||
notification?: HookNotificationContext
|
||||
|
||||
/** Stop reason - present for Stop event */
|
||||
reason?: string
|
||||
|
||||
/** Summary - present for Stop event */
|
||||
summary?: string
|
||||
|
||||
/** Conversation history - only if hook opts in via includeConversationHistory */
|
||||
conversationHistory?: ConversationHistoryEntry[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Execution Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exit code semantics for hooks.
|
||||
*/
|
||||
export enum HookExitCode {
|
||||
/** Success - continue execution */
|
||||
Success = 0,
|
||||
|
||||
/** Block/deny - halt execution (only valid for blocking events) */
|
||||
Block = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a single hook.
|
||||
*/
|
||||
export interface HookExecutionResult {
|
||||
/** The hook that was executed */
|
||||
hook: ResolvedHook
|
||||
|
||||
/** Exit code from the process */
|
||||
exitCode: number | null
|
||||
|
||||
/** stdout output (may contain JSON for modification) */
|
||||
stdout: string
|
||||
|
||||
/** stderr output (shown to user on block) */
|
||||
stderr: string
|
||||
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number
|
||||
|
||||
/** Whether the hook timed out */
|
||||
timedOut: boolean
|
||||
|
||||
/** Error if hook failed to execute */
|
||||
error?: Error
|
||||
|
||||
/** Parsed modification request from stdout (PreToolUse only) */
|
||||
modification?: HookModification
|
||||
}
|
||||
|
||||
/**
|
||||
* Modification request from hook stdout (PreToolUse only).
|
||||
*/
|
||||
export interface HookModification {
|
||||
action: "modify"
|
||||
toolInput: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for hook stdout modification response.
|
||||
*/
|
||||
export const HookModificationSchema = z.object({
|
||||
action: z.literal("modify"),
|
||||
toolInput: z.record(z.unknown()),
|
||||
})
|
||||
|
||||
/**
|
||||
* Aggregated result of executing all hooks for an event.
|
||||
*/
|
||||
export interface HooksExecutionResult {
|
||||
/** Results from each hook */
|
||||
results: HookExecutionResult[]
|
||||
|
||||
/** Whether any hook blocked execution (exit code 2) */
|
||||
blocked: boolean
|
||||
|
||||
/** Block message from stderr if blocked */
|
||||
blockMessage?: string
|
||||
|
||||
/** The hook that blocked, if any */
|
||||
blockingHook?: ResolvedHook
|
||||
|
||||
/** Tool input modification, if any hook modified it */
|
||||
modification?: HookModification
|
||||
|
||||
/** Total execution time for all hooks */
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Manager Interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execution history entry for debugging/UI.
|
||||
*/
|
||||
export interface HookExecution {
|
||||
/** When the hook was executed */
|
||||
timestamp: Date
|
||||
|
||||
/** The hook that was executed */
|
||||
hook: ResolvedHook
|
||||
|
||||
/** The event that triggered execution */
|
||||
event: HookEventType
|
||||
|
||||
/** Execution result */
|
||||
result: HookExecutionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for hook execution.
|
||||
*/
|
||||
export interface ExecuteHooksOptions {
|
||||
/** Context to pass to hooks */
|
||||
context: HookContext
|
||||
|
||||
/** Conversation history (will be included only for hooks with includeConversationHistory: true) */
|
||||
conversationHistory?: ConversationHistoryEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook manager service interface (per PRD FR5).
|
||||
*/
|
||||
export interface IHookManager {
|
||||
/** Load hooks configuration from all sources */
|
||||
loadHooksConfig(): Promise<HooksConfigSnapshot>
|
||||
|
||||
/** Explicitly reload hooks configuration */
|
||||
reloadHooksConfig(): Promise<void>
|
||||
|
||||
/** Execute all matching hooks for an event */
|
||||
executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise<HooksExecutionResult>
|
||||
|
||||
/** Get all currently enabled hooks */
|
||||
getEnabledHooks(): ResolvedHook[]
|
||||
|
||||
/** Enable or disable a specific hook by ID */
|
||||
setHookEnabled(hookId: string, enabled: boolean): Promise<void>
|
||||
|
||||
/** Get execution history for debugging */
|
||||
getHookExecutionHistory(): HookExecution[]
|
||||
|
||||
/** Get the current config snapshot (or null if not loaded) */
|
||||
getConfigSnapshot(): HooksConfigSnapshot | null
|
||||
}
|
||||
334
webview-ui/src/components/settings/HooksSettings.tsx
Normal file
334
webview-ui/src/components/settings/HooksSettings.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { RefreshCw, FolderOpen, AlertTriangle, Clock, Zap, X } from "lucide-react"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { Button, StandardTooltip } from "@src/components/ui"
|
||||
import type { HookInfo, HookExecutionRecord, HookExecutionStatusPayload } from "@roo-code/types"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { Section } from "./Section"
|
||||
|
||||
export const HooksSettings: React.FC = () => {
|
||||
const { t } = useAppTranslation()
|
||||
const { hooks } = useExtensionState()
|
||||
const [executionHistory, setExecutionHistory] = useState<HookExecutionRecord[]>(hooks?.executionHistory || [])
|
||||
|
||||
// Listen for realtime hookExecutionStatus messages
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "hookExecutionStatus") {
|
||||
const payload: HookExecutionStatusPayload = message.hookExecutionStatus
|
||||
|
||||
// Convert realtime status to execution record format when completed/failed
|
||||
if (payload.status === "completed" || payload.status === "failed" || payload.status === "blocked") {
|
||||
const record: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: payload.hookId || "unknown",
|
||||
event: payload.event,
|
||||
toolName: payload.toolName,
|
||||
exitCode: payload.status === "completed" ? 0 : 1,
|
||||
duration: payload.duration || 0,
|
||||
timedOut: false,
|
||||
blocked: payload.status === "blocked",
|
||||
error: payload.error,
|
||||
blockMessage: payload.blockMessage,
|
||||
}
|
||||
|
||||
setExecutionHistory((prev) => [record, ...prev].slice(0, 50)) // Keep last 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [])
|
||||
|
||||
// Update local state when extension state changes
|
||||
useEffect(() => {
|
||||
if (hooks?.executionHistory) {
|
||||
setExecutionHistory(hooks.executionHistory)
|
||||
}
|
||||
}, [hooks?.executionHistory])
|
||||
|
||||
const handleReloadConfig = useCallback(() => {
|
||||
vscode.postMessage({ type: "hooksReloadConfig" })
|
||||
}, [])
|
||||
|
||||
const handleOpenConfigFolder = useCallback((source: "global" | "project") => {
|
||||
vscode.postMessage({ type: "hooksOpenConfigFolder", hooksSource: source })
|
||||
}, [])
|
||||
|
||||
const handleToggleHook = useCallback((hookId: string, enabled: boolean) => {
|
||||
vscode.postMessage({ type: "hooksSetEnabled", hookId, hookEnabled: enabled })
|
||||
}, [])
|
||||
|
||||
const enabledHooks = hooks?.enabledHooks || []
|
||||
const hasProjectHooks = hooks?.hasProjectHooks || false
|
||||
const snapshotTimestamp = hooks?.snapshotTimestamp
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>{t("settings:sections.hooks")}</SectionHeader>
|
||||
|
||||
<Section>
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
|
||||
{snapshotTimestamp && (
|
||||
<StandardTooltip
|
||||
content={t("settings:hooks.lastLoadedTooltip", {
|
||||
time: new Date(snapshotTimestamp).toLocaleString(),
|
||||
})}>
|
||||
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
|
||||
<Button variant="ghost" size="sm" onClick={handleReloadConfig}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span className="ml-1">{t("settings:hooks.reload")}</span>
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
<StandardTooltip
|
||||
content={
|
||||
hasProjectHooks
|
||||
? t("settings:hooks.openProjectFolderTooltip")
|
||||
: t("settings:hooks.openGlobalFolderTooltip")
|
||||
}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenConfigFolder(hasProjectHooks ? "project" : "global")}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="ml-1">
|
||||
{hasProjectHooks
|
||||
? t("settings:hooks.openProjectFolder")
|
||||
: t("settings:hooks.openGlobalFolder")}
|
||||
</span>
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security warning for project hooks */}
|
||||
{hasProjectHooks && (
|
||||
<div className="flex items-start gap-2 p-3 mb-4 rounded bg-yellow-500/10 border border-yellow-500/30">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium mb-1">{t("settings:hooks.projectHooksWarningTitle")}</div>
|
||||
<div className="text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.projectHooksWarningMessage")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note about edits requiring reload */}
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-4">{t("settings:hooks.reloadNote")}</div>
|
||||
|
||||
{/* Hooks list */}
|
||||
{enabledHooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-vscode-descriptionForeground">
|
||||
<Zap className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-base mb-2">{t("settings:hooks.noHooksConfigured")}</p>
|
||||
<p className="text-sm">{t("settings:hooks.noHooksHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{enabledHooks.map((hook) => (
|
||||
<HookItem key={hook.id} hook={hook} onToggle={handleToggleHook} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hook Activity Log */}
|
||||
<HookActivityLog executionHistory={executionHistory} />
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HookItemProps {
|
||||
hook: HookInfo
|
||||
onToggle: (hookId: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-vscode-input-border bg-vscode-input-background">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
|
||||
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
|
||||
{hook.matcher && (
|
||||
<>
|
||||
<span className="text-vscode-descriptionForeground">→</span>
|
||||
<code className="text-xs font-mono text-vscode-descriptionForeground">
|
||||
{hook.matcher}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto text-xs px-2 py-0.5 rounded ${
|
||||
hook.source === "project"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: hook.source === "mode"
|
||||
? "bg-blue-500/20 text-blue-500"
|
||||
: "bg-gray-500/20 text-gray-400"
|
||||
}`}>
|
||||
{hook.source}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hook.description && <p className="text-sm text-vscode-foreground mb-2">{hook.description}</p>}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-vscode-descriptionForeground">
|
||||
<code className="font-mono bg-vscode-editor-background px-2 py-1 rounded">
|
||||
{hook.commandPreview}
|
||||
</code>
|
||||
{hook.shell && (
|
||||
<span>
|
||||
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t("settings:hooks.timeout")}: {hook.timeout}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hook.enabled}
|
||||
onChange={(e) => onToggle(hook.id, e.target.checked)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{t("settings:hooks.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HookActivityLogProps {
|
||||
executionHistory: HookExecutionRecord[]
|
||||
}
|
||||
|
||||
const HookActivityLog: React.FC<HookActivityLogProps> = ({ executionHistory }) => {
|
||||
const { t } = useAppTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (executionHistory.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-base font-medium mb-3 hover:text-vscode-textLink-foreground transition-colors w-full text-left">
|
||||
<span
|
||||
className="transform transition-transform"
|
||||
style={{ transform: isExpanded ? "rotate(90deg)" : "" }}>
|
||||
▶
|
||||
</span>
|
||||
{t("settings:hooks.activityLog")} ({executionHistory.length})
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{executionHistory.map((record, index) => (
|
||||
<ActivityLogItem key={`${record.timestamp}-${index}`} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActivityLogItemProps {
|
||||
record: HookExecutionRecord
|
||||
}
|
||||
|
||||
const ActivityLogItem: React.FC<ActivityLogItemProps> = ({ record }) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
if (record.blocked) {
|
||||
return {
|
||||
label: t("settings:hooks.status.blocked"),
|
||||
className: "bg-red-500/20 text-red-500",
|
||||
icon: <X className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
if (record.error || record.exitCode !== 0) {
|
||||
return {
|
||||
label: t("settings:hooks.status.failed"),
|
||||
className: "bg-red-500/20 text-red-500",
|
||||
icon: <X className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
if (record.timedOut) {
|
||||
return {
|
||||
label: t("settings:hooks.status.timeout"),
|
||||
className: "bg-yellow-500/20 text-yellow-500",
|
||||
icon: <Clock className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: t("settings:hooks.status.completed"),
|
||||
className: "bg-green-500/20 text-green-500",
|
||||
icon: <Zap className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
|
||||
const status = getStatusDisplay()
|
||||
const timestamp = new Date(record.timestamp)
|
||||
const timeAgo = getTimeAgo(timestamp)
|
||||
|
||||
return (
|
||||
<div className="p-2 rounded border border-vscode-input-border bg-vscode-input-background text-sm">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-vscode-textLink-foreground truncate">
|
||||
{record.event}
|
||||
{record.toolName && ` (${record.toolName})`}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground flex-shrink-0">
|
||||
<span>{record.duration}ms</span>
|
||||
<StandardTooltip content={timestamp.toLocaleString()}>
|
||||
<span className="cursor-help">{timeAgo}</span>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(record.error || record.blockMessage) && (
|
||||
<div className="mt-2 p-2 rounded bg-vscode-editor-background text-xs font-mono text-red-400 overflow-x-auto">
|
||||
{record.blockMessage || record.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
Server,
|
||||
Users2,
|
||||
ArrowLeft,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
|
|
@ -79,6 +80,7 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings"
|
|||
import { UISettings } from "./UISettings"
|
||||
import ModesView from "../modes/ModesView"
|
||||
import McpView from "../mcp/McpView"
|
||||
import { HooksSettings } from "./HooksSettings"
|
||||
import { SettingsSearch } from "./SettingsSearch"
|
||||
import { useSearchIndexRegistry, SearchIndexProvider } from "./useSettingsSearch"
|
||||
|
||||
|
|
@ -104,6 +106,7 @@ export const sectionNames = [
|
|||
"terminal",
|
||||
"modes",
|
||||
"mcp",
|
||||
"hooks",
|
||||
"prompts",
|
||||
"ui",
|
||||
"experimental",
|
||||
|
|
@ -530,6 +533,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{ id: "notifications", icon: Bell },
|
||||
{ id: "contextManagement", icon: Database },
|
||||
{ id: "terminal", icon: SquareTerminal },
|
||||
{ id: "hooks", icon: Zap },
|
||||
{ id: "prompts", icon: MessageSquare },
|
||||
{ id: "ui", icon: Glasses },
|
||||
{ id: "experimental", icon: FlaskConical },
|
||||
|
|
@ -880,6 +884,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{/* MCP Section */}
|
||||
{renderTab === "mcp" && <McpView />}
|
||||
|
||||
{/* Hooks Section */}
|
||||
{renderTab === "hooks" && <HooksSettings />}
|
||||
|
||||
{/* Prompts Section */}
|
||||
{renderTab === "prompts" && (
|
||||
<PromptsSettings
|
||||
|
|
|
|||
|
|
@ -0,0 +1,353 @@
|
|||
// cd webview-ui && npx vitest run src/components/settings/__tests__/HooksSettings.spec.tsx
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
import { HooksSettings } from "../HooksSettings"
|
||||
import type { HookInfo, HookExecutionRecord, HooksState } from "@roo-code/types"
|
||||
|
||||
// Mock vscode utilities
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("@src/i18n/TranslationContext", () => ({
|
||||
useAppTranslation: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
// Simple mock that returns translation keys
|
||||
if (params) {
|
||||
return key.replace(/\{\{(\w+)\}\}/g, (_, k) => params[k] || "")
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the ExtensionStateContext
|
||||
const mockHooksState: HooksState = {
|
||||
enabledHooks: [],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
snapshotTimestamp: undefined,
|
||||
}
|
||||
|
||||
let currentHooksState = mockHooksState
|
||||
|
||||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
hooks: currentHooksState,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@src/components/ui", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} data-testid={props["data-testid"]} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
|
||||
}))
|
||||
|
||||
// Mock Section components
|
||||
vi.mock("../SectionHeader", () => ({
|
||||
SectionHeader: ({ children }: any) => <h2>{children}</h2>,
|
||||
}))
|
||||
|
||||
vi.mock("../Section", () => ({
|
||||
Section: ({ children }: any) => <div data-testid="section">{children}</div>,
|
||||
}))
|
||||
|
||||
describe("HooksSettings", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// Reset to default state
|
||||
currentHooksState = {
|
||||
enabledHooks: [],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
snapshotTimestamp: undefined,
|
||||
}
|
||||
// Get fresh reference to mocked vscode
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
vi.mocked(vscode.postMessage).mockClear()
|
||||
})
|
||||
|
||||
it("renders with no hooks configured", () => {
|
||||
render(<HooksSettings />)
|
||||
|
||||
expect(screen.getByText("settings:sections.hooks")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.noHooksConfigured")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.noHooksHint")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders hooks list when hooks are configured", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
matcher: "git*",
|
||||
commandPreview: "echo 'Before git command'",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
description: "Test hook",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: true,
|
||||
snapshotTimestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows project hooks warning when hasProjectHooks is true", () => {
|
||||
currentHooksState = {
|
||||
...mockHooksState,
|
||||
hasProjectHooks: true,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
expect(screen.getByText("settings:hooks.projectHooksWarningTitle")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.projectHooksWarningMessage")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sends hooksReloadConfig message when Reload button is clicked", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
render(<HooksSettings />)
|
||||
|
||||
const reloadButton = screen.getByText("settings:hooks.reload")
|
||||
fireEvent.click(reloadButton)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "hooksReloadConfig" })
|
||||
})
|
||||
|
||||
it("sends hooksOpenConfigFolder message when Open Folder button is clicked", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
currentHooksState = {
|
||||
...mockHooksState,
|
||||
hasProjectHooks: true,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const openFolderButton = screen.getByText("settings:hooks.openProjectFolder")
|
||||
fireEvent.click(openFolderButton)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksOpenConfigFolder",
|
||||
hooksSource: "project",
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksSetEnabled message when hook toggle is changed", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const checkbox = screen.getByRole("checkbox")
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "hook-1",
|
||||
hookEnabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("renders execution history when available", () => {
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
exitCode: 0,
|
||||
duration: 150,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [],
|
||||
executionHistory: [mockRecord],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Activity log should be present
|
||||
expect(screen.getByText(/settings:hooks.activityLog/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("updates execution history on realtime hookExecutionStatus message", async () => {
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Simulate receiving a hookExecutionStatus message
|
||||
const event = new MessageEvent("message", {
|
||||
data: {
|
||||
type: "hookExecutionStatus",
|
||||
hookExecutionStatus: {
|
||||
status: "completed",
|
||||
event: "before_execute_command",
|
||||
hookId: "hook-1",
|
||||
duration: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
window.dispatchEvent(event)
|
||||
|
||||
// Wait for state update
|
||||
await waitFor(() => {
|
||||
// The activity log should now show the new execution
|
||||
const activityLog = screen.getByText(/settings:hooks.activityLog/)
|
||||
expect(activityLog).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("expands and collapses activity log on click", () => {
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
exitCode: 0,
|
||||
duration: 150,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [],
|
||||
executionHistory: [mockRecord],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const activityLogButton = screen.getByText(/settings:hooks.activityLog/)
|
||||
|
||||
// Should start collapsed
|
||||
expect(screen.queryByText(mockRecord.event)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(activityLogButton)
|
||||
|
||||
// Should now show the record
|
||||
expect(screen.getByText(mockRecord.event)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("displays different hook sources with appropriate styling", () => {
|
||||
const hooks: HookInfo[] = [
|
||||
{
|
||||
id: "hook-1",
|
||||
event: "event1",
|
||||
commandPreview: "cmd1",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
},
|
||||
{
|
||||
id: "hook-2",
|
||||
event: "event2",
|
||||
commandPreview: "cmd2",
|
||||
enabled: true,
|
||||
source: "mode",
|
||||
timeout: 30,
|
||||
},
|
||||
{
|
||||
id: "hook-3",
|
||||
event: "event3",
|
||||
commandPreview: "cmd3",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
},
|
||||
]
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: hooks,
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// All three source types should be present
|
||||
expect(screen.getByText("project")).toBeInTheDocument()
|
||||
expect(screen.getByText("mode")).toBeInTheDocument()
|
||||
expect(screen.getByText("global")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("displays activity log status pills correctly", () => {
|
||||
const records: HookExecutionRecord[] = [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "event1",
|
||||
exitCode: 0,
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-2",
|
||||
event: "event2",
|
||||
exitCode: 1,
|
||||
duration: 200,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
error: "Command failed",
|
||||
},
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-3",
|
||||
event: "event3",
|
||||
exitCode: null,
|
||||
duration: 300,
|
||||
timedOut: false,
|
||||
blocked: true,
|
||||
blockMessage: "Operation blocked",
|
||||
},
|
||||
]
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [],
|
||||
executionHistory: records,
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand activity log
|
||||
const activityLogButton = screen.getByText(/settings:hooks.activityLog/)
|
||||
fireEvent.click(activityLogButton)
|
||||
|
||||
// Check for status labels
|
||||
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.status.failed")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.status.blocked")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -36,12 +36,39 @@
|
|||
"contextManagement": "Context",
|
||||
"terminal": "Terminal",
|
||||
"slashCommands": "Slash Commands",
|
||||
"hooks": "Hooks",
|
||||
"prompts": "Prompts",
|
||||
"ui": "UI",
|
||||
"experimental": "Experimental",
|
||||
"language": "Language",
|
||||
"about": "About Roo Code"
|
||||
},
|
||||
"hooks": {
|
||||
"configuredHooks": "Configured Hooks",
|
||||
"lastLoadedTooltip": "Last loaded at {{time}}",
|
||||
"reloadTooltip": "Reload hooks configuration from disk",
|
||||
"reload": "Reload",
|
||||
"openProjectFolderTooltip": "Open project hooks configuration folder",
|
||||
"openGlobalFolderTooltip": "Open global hooks configuration folder",
|
||||
"openProjectFolder": "Open Project Folder",
|
||||
"openGlobalFolder": "Open Global Folder",
|
||||
"projectHooksWarningTitle": "Project-level hooks detected",
|
||||
"projectHooksWarningMessage": "This project includes hook configurations that will execute shell commands. Only enable hooks from sources you trust.",
|
||||
"reloadNote": "Changes to hook configuration files require clicking Reload to take effect.",
|
||||
"noHooksConfigured": "No hooks configured",
|
||||
"noHooksHint": "Create hook configuration files to automate actions on tool execution events.",
|
||||
"enabled": "Enabled",
|
||||
"shell": "Shell",
|
||||
"timeout": "Timeout",
|
||||
"activityLog": "Hook Activity",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"blocked": "Blocked",
|
||||
"timeout": "Timeout"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"bugReport": {
|
||||
"label": "Found a bug?",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue