Add type: "http" hook support matching Claude Code's HTTP hook specification. HTTP hooks send POST requests with JSON body, support env var interpolation in headers via allowedEnvVars, and configurable timeout. New files: - execute-http-hook.ts: HTTP hook execution with env var interpolation - dispatch-hook.ts: Unified dispatcher for command and HTTP hooks - execute-http-hook.test.ts: 14 tests covering all HTTP hook scenarios Modified files: - types.ts: Added HookHttp interface, HookAction union type - config.ts: Updated to accept HookAction in raw hook matchers - pre-tool-use/post-tool-use/stop/user-prompt-submit/pre-compact: Updated all 5 executors to dispatch HTTP hooks via dispatchHook() - plugin-loader/types.ts: Added "http" to HookEntry type union
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
import type {
|
|
UserPromptSubmitInput,
|
|
PostToolUseOutput,
|
|
ClaudeHooksConfig,
|
|
} from "./types"
|
|
import { findMatchingHooks, log } from "../../shared"
|
|
import { dispatchHook } from "./dispatch-hook"
|
|
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
|
|
|
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
|
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
|
|
|
|
export interface MessagePart {
|
|
type: "text" | "tool_use" | "tool_result"
|
|
text?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export interface UserPromptSubmitContext {
|
|
sessionId: string
|
|
parentSessionId?: string
|
|
prompt: string
|
|
parts: MessagePart[]
|
|
cwd: string
|
|
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
|
}
|
|
|
|
export interface UserPromptSubmitResult {
|
|
block: boolean
|
|
reason?: string
|
|
modifiedParts: MessagePart[]
|
|
messages: string[]
|
|
}
|
|
|
|
export async function executeUserPromptSubmitHooks(
|
|
ctx: UserPromptSubmitContext,
|
|
config: ClaudeHooksConfig | null,
|
|
extendedConfig?: PluginExtendedConfig | null
|
|
): Promise<UserPromptSubmitResult> {
|
|
const modifiedParts = ctx.parts
|
|
const messages: string[] = []
|
|
|
|
if (ctx.parentSessionId) {
|
|
return { block: false, modifiedParts, messages }
|
|
}
|
|
|
|
// Check if hook tags are in the current user input only (not in injected context)
|
|
// by checking only the text parts that were provided in this message
|
|
const userInputText = ctx.parts
|
|
.filter((p) => p.type === "text" && p.text)
|
|
.map((p) => p.text ?? "")
|
|
.join("\n")
|
|
|
|
if (
|
|
userInputText.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
|
|
userInputText.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
|
|
) {
|
|
return { block: false, modifiedParts, messages }
|
|
}
|
|
|
|
if (!config) {
|
|
return { block: false, modifiedParts, messages }
|
|
}
|
|
|
|
const matchers = findMatchingHooks(config, "UserPromptSubmit")
|
|
if (matchers.length === 0) {
|
|
return { block: false, modifiedParts, messages }
|
|
}
|
|
|
|
const stdinData: UserPromptSubmitInput = {
|
|
session_id: ctx.sessionId,
|
|
cwd: ctx.cwd,
|
|
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
|
hook_event_name: "UserPromptSubmit",
|
|
prompt: ctx.prompt,
|
|
session: { id: ctx.sessionId },
|
|
hook_source: "opencode-plugin",
|
|
}
|
|
|
|
for (const matcher of matchers) {
|
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
|
for (const hook of matcher.hooks) {
|
|
if (hook.type !== "command" && hook.type !== "http") continue
|
|
|
|
if (hook.type === "command" && isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
|
|
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
|
|
continue
|
|
}
|
|
|
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
|
|
|
if (result.stdout) {
|
|
const output = result.stdout.trim()
|
|
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
|
|
messages.push(output)
|
|
} else {
|
|
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
|
|
}
|
|
}
|
|
|
|
if (result.exitCode !== 0) {
|
|
try {
|
|
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
|
if (output.decision === "block") {
|
|
return {
|
|
block: true,
|
|
reason: output.reason || result.stderr,
|
|
modifiedParts,
|
|
messages,
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { block: false, modifiedParts, messages }
|
|
}
|