test(claude-code-hooks): batch 78 (8 files)
This commit is contained in:
@@ -7,6 +7,7 @@ import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "..
|
||||
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
import { normalizeHookText } from "./hook-text"
|
||||
|
||||
export interface PostToolUseClient {
|
||||
session: {
|
||||
@@ -41,6 +42,10 @@ export interface PostToolUseResult {
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
function joinedMessages(messages: readonly string[]): string | undefined {
|
||||
return messages.length > 0 ? messages.join("\n\n") : undefined
|
||||
}
|
||||
|
||||
export async function executePostToolUseHooks(
|
||||
ctx: PostToolUseContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
@@ -106,13 +111,10 @@ export async function executePostToolUseHooks(
|
||||
|
||||
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||
|
||||
if (result.stdout) {
|
||||
messages.push(result.stdout)
|
||||
}
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
if (result.stderr) {
|
||||
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
|
||||
const stderr = normalizeHookText(result.stderr)
|
||||
if (stderr !== undefined) {
|
||||
warnings.push(`[${hookName}]\n${stderr}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -120,59 +122,69 @@ export async function executePostToolUseHooks(
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext)
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
|
||||
message: joinedMessages(messages),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
stopReason: normalizeHookText(output.stopReason),
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
systemMessage: normalizeHookText(output.systemMessage),
|
||||
}
|
||||
}
|
||||
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
|
||||
if (additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
|
||||
return {
|
||||
block: false,
|
||||
message: messages.join("\n"),
|
||||
message: joinedMessages(messages),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
stopReason: normalizeHookText(output.stopReason),
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
systemMessage: normalizeHookText(output.systemMessage),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const stdout = normalizeHookText(result.stdout)
|
||||
if (stdout !== undefined) {
|
||||
messages.push(stdout)
|
||||
}
|
||||
}
|
||||
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext)
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
|
||||
message: joinedMessages(messages),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
stopReason: normalizeHookText(output.stopReason),
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
systemMessage: normalizeHookText(output.systemMessage),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const stdout = normalizeHookText(result.stdout)
|
||||
if (stdout !== undefined) {
|
||||
messages.push(stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +194,7 @@ export async function executePostToolUseHooks(
|
||||
|
||||
return {
|
||||
block: false,
|
||||
message: messages.length > 0 ? messages.join("\n") : undefined,
|
||||
message: joinedMessages(messages),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs,
|
||||
hookName: firstHookName,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { findMatchingHooks, log } from "../../shared"
|
||||
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
import { normalizeHookText } from "./hook-text"
|
||||
|
||||
export interface PreCompactContext {
|
||||
sessionId: string
|
||||
@@ -22,6 +23,13 @@ export interface PreCompactResult {
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
function appendContext(context: string[], value: string): void {
|
||||
const normalized = normalizeHookText(value)
|
||||
if (normalized !== undefined) {
|
||||
context.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
export async function executePreCompactHooks(
|
||||
ctx: PreCompactContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
@@ -72,9 +80,13 @@ export async function executePreCompactHooks(
|
||||
const output = JSON.parse(result.stdout || "{}") as PreCompactOutput
|
||||
|
||||
if (output.hookSpecificOutput?.additionalContext) {
|
||||
collectedContext.push(...output.hookSpecificOutput.additionalContext)
|
||||
for (const context of output.hookSpecificOutput.additionalContext) {
|
||||
appendContext(collectedContext, context)
|
||||
}
|
||||
} else if (output.context) {
|
||||
collectedContext.push(...output.context)
|
||||
for (const context of output.context) {
|
||||
appendContext(collectedContext, context)
|
||||
}
|
||||
}
|
||||
|
||||
if (output.continue === false) {
|
||||
@@ -83,15 +95,13 @@ export async function executePreCompactHooks(
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
stopReason: normalizeHookText(output.stopReason),
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
systemMessage: normalizeHookText(output.systemMessage),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (result.stdout.trim()) {
|
||||
collectedContext.push(result.stdout.trim())
|
||||
}
|
||||
appendContext(collectedContext, result.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ describe("executePreToolUseHooks", () => {
|
||||
expect(result.reason).toBe("blocked")
|
||||
})
|
||||
|
||||
it("#given hook deny reason with CRLF and bare CR #when called #then returns normalized reason", async () => {
|
||||
dispatchSpy.mockResolvedValue({
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "\r\nblocked line\r\n detail\rfinal line\r\n",
|
||||
})
|
||||
|
||||
const config = createConfig([
|
||||
{ matcher: "Write", hooks: [{ type: "command", command: "echo deny" }] },
|
||||
])
|
||||
const result = await executePreToolUseHooks(createContext(), config)
|
||||
|
||||
expect(result.decision).toBe("deny")
|
||||
expect(result.reason).toBe("blocked line\n detail\nfinal line")
|
||||
})
|
||||
|
||||
it("#given hook returns exit code 1 #when called #then returns ask", async () => {
|
||||
dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" })
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
import { normalizeHookText } from "./hook-text"
|
||||
|
||||
export interface PreToolUseContext {
|
||||
sessionId: string
|
||||
@@ -74,7 +75,7 @@ export async function executePreToolUseHooks(
|
||||
let firstHookName: string | undefined
|
||||
const inputLines = buildInputLines(ctx.toolInput)
|
||||
let accumulatedModifiedInput: Record<string, unknown> | undefined
|
||||
let accumulatedCommonFields: {
|
||||
const accumulatedCommonFields: {
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
@@ -99,7 +100,7 @@ export async function executePreToolUseHooks(
|
||||
if (result.exitCode === 2) {
|
||||
return {
|
||||
decision: "deny",
|
||||
reason: result.stderr || result.stdout || "Hook blocked the operation",
|
||||
reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout) ?? "Hook blocked the operation",
|
||||
modifiedInput: accumulatedModifiedInput,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
@@ -112,7 +113,7 @@ export async function executePreToolUseHooks(
|
||||
if (result.exitCode === 1) {
|
||||
return {
|
||||
decision: "ask",
|
||||
reason: result.stderr || result.stdout,
|
||||
reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout),
|
||||
modifiedInput: accumulatedModifiedInput,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
@@ -133,7 +134,7 @@ export async function executePreToolUseHooks(
|
||||
|
||||
if (output.hookSpecificOutput?.permissionDecision) {
|
||||
decision = output.hookSpecificOutput.permissionDecision
|
||||
reason = output.hookSpecificOutput.permissionDecisionReason
|
||||
reason = normalizeHookText(output.hookSpecificOutput.permissionDecisionReason)
|
||||
modifiedInput = output.hookSpecificOutput.updatedInput
|
||||
} else if (output.decision) {
|
||||
// Map deprecated values: approve->allow, block->deny, ask->ask
|
||||
@@ -145,15 +146,9 @@ export async function executePreToolUseHooks(
|
||||
} else if (legacyDecision === "ask") {
|
||||
decision = "ask"
|
||||
}
|
||||
reason = output.reason
|
||||
reason = normalizeHookText(output.reason)
|
||||
}
|
||||
|
||||
// Return if decision is set OR if any common fields are set (fallback to allow)
|
||||
const hasCommonFields = output.continue !== undefined ||
|
||||
output.stopReason !== undefined ||
|
||||
output.suppressOutput !== undefined ||
|
||||
output.systemMessage !== undefined
|
||||
|
||||
if (decision === "deny" || decision === "ask") {
|
||||
return {
|
||||
decision,
|
||||
@@ -164,9 +159,9 @@ export async function executePreToolUseHooks(
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
continue: output.continue ?? accumulatedCommonFields.continue,
|
||||
stopReason: output.stopReason ?? accumulatedCommonFields.stopReason,
|
||||
stopReason: normalizeHookText(output.stopReason) ?? accumulatedCommonFields.stopReason,
|
||||
suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput,
|
||||
systemMessage: output.systemMessage ?? accumulatedCommonFields.systemMessage,
|
||||
systemMessage: normalizeHookText(output.systemMessage) ?? accumulatedCommonFields.systemMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,10 +171,13 @@ export async function executePreToolUseHooks(
|
||||
Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput))
|
||||
}
|
||||
if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue
|
||||
if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = output.stopReason
|
||||
if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = normalizeHookText(output.stopReason)
|
||||
if (output.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput
|
||||
if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage
|
||||
} catch {
|
||||
if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = normalizeHookText(output.systemMessage)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,24 @@ describe("executeStopHooks", () => {
|
||||
expect(result.reason).toBe("blocked reason")
|
||||
})
|
||||
|
||||
it("#given hook with CRLF block reason #when stop hooks called #then blocks with normalized reason and prompt", async () => {
|
||||
const ctx = createStopContext()
|
||||
const config = createConfig([
|
||||
{ matcher: "*", hooks: [{ type: "command", command: "exit 2" }] },
|
||||
])
|
||||
mockDispatchHook.mockResolvedValueOnce({
|
||||
exitCode: 2,
|
||||
stdout: "",
|
||||
stderr: "\r\nblocked reason\r\n detail\rfinal line\r\n",
|
||||
})
|
||||
|
||||
const result = await executeStopHooks(ctx, config)
|
||||
|
||||
expect(result.block).toBe(true)
|
||||
expect(result.reason).toBe("blocked reason\n detail\nfinal line")
|
||||
expect(result.injectPrompt).toBe("blocked reason\n detail\nfinal line")
|
||||
})
|
||||
|
||||
it("#given hook with decision=block #when stop hooks called #then blocks", async () => {
|
||||
const ctx = createStopContext()
|
||||
const config = createConfig([
|
||||
|
||||
@@ -7,6 +7,7 @@ import { findMatchingHooks, log } from "../../shared"
|
||||
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||
import { getTodoPath } from "./todo"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
import { normalizeHookText } from "./hook-text"
|
||||
|
||||
// Module-level state to track stop_hook_active per session
|
||||
const stopHookActiveState = new Map<string, boolean>()
|
||||
@@ -80,7 +81,7 @@ export async function executeStopHooks(
|
||||
|
||||
// Check exit code first - exit code 2 means block
|
||||
if (result.exitCode === 2) {
|
||||
const reason = result.stderr || result.stdout || "Blocked by stop hook"
|
||||
const reason = normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout) ?? "Blocked by stop hook"
|
||||
return {
|
||||
block: true,
|
||||
reason,
|
||||
@@ -98,17 +99,20 @@ export async function executeStopHooks(
|
||||
// Only return early if the hook explicitly blocks - non-blocking hooks
|
||||
// should not prevent subsequent hooks from executing (matches Claude Code behavior)
|
||||
if (isBlock) {
|
||||
const injectPrompt = output.inject_prompt ?? (output.reason || undefined)
|
||||
const reason = normalizeHookText(output.reason)
|
||||
const injectPrompt = normalizeHookText(output.inject_prompt) ?? reason
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason,
|
||||
reason,
|
||||
stopHookActive: output.stop_hook_active,
|
||||
permissionMode: output.permission_mode,
|
||||
injectPrompt,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors - hook may return non-JSON output
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,34 @@ describe("executeUserPromptSubmitHooks", () => {
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("#given hook stdout with CRLF and bare CR #when prompt submit runs #then injected hook context is normalized", async () => {
|
||||
// given
|
||||
spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stdout: "\r\nfirst line\r\n second line\rthird line\r\n",
|
||||
stderr: "",
|
||||
})
|
||||
const ctx: UserPromptSubmitContext = {
|
||||
sessionId: "test-session-newlines",
|
||||
prompt: "hello",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
cwd: "/tmp",
|
||||
}
|
||||
const config = {
|
||||
UserPromptSubmit: [
|
||||
{ matcher: "*", hooks: [{ type: "command" as const, command: "echo hook" }] },
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await executeUserPromptSubmitHooks(ctx, config)
|
||||
|
||||
// then
|
||||
expect(result.messages).toEqual([
|
||||
"<user-prompt-submit-hook>\nfirst line\n second line\nthird line\n</user-prompt-submit-hook>",
|
||||
])
|
||||
})
|
||||
|
||||
it("#given internal prompt marker only #when prompt submit runs #then hook command is not dispatched", async () => {
|
||||
// given
|
||||
const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { findMatchingHooks, log } from "../../shared"
|
||||
import { isRealUserTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
import { normalizeHookText } from "./hook-text"
|
||||
|
||||
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
||||
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
|
||||
@@ -96,7 +97,10 @@ export async function executeUserPromptSubmitHooks(
|
||||
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||
|
||||
if (result.stdout) {
|
||||
const output = result.stdout.trim()
|
||||
const output = normalizeHookText(result.stdout)
|
||||
if (output === undefined) {
|
||||
continue
|
||||
}
|
||||
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
|
||||
messages.push(output)
|
||||
} else {
|
||||
@@ -110,14 +114,16 @@ export async function executeUserPromptSubmitHooks(
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
|
||||
modifiedParts,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user