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