Files
oh-my-opencode/src/hooks/claude-code-hooks/post-tool-use.ts
T
YeonGyu-Kim d960775da3 feat(hooks): add HTTP hook handler support
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
2026-02-28 11:38:34 +09:00

196 lines
6.7 KiB
TypeScript

import type {
PostToolUseInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PostToolUseClient {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
}
export interface PostToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
toolOutput: Record<string, unknown>
cwd: string
transcriptPath?: string // Fallback for append-based transcript
toolUseId?: string
client?: PostToolUseClient
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PostToolUseResult {
block: boolean
reason?: string
message?: string
warnings?: string[]
elapsedMs?: number
hookName?: string
toolName?: string
additionalContext?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePostToolUseHooks(
ctx: PostToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PostToolUseResult> {
if (!config) {
return { block: false }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
if (matchers.length === 0) {
return { block: false }
}
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
let tempTranscriptPath: string | null = null
try {
// Try to build full transcript from API if client available
if (ctx.client) {
tempTranscriptPath = await buildTranscriptFromSession(
ctx.client,
ctx.sessionId,
ctx.cwd,
ctx.toolName,
ctx.toolInput
)
}
const stdinData: PostToolUseInput = {
session_id: ctx.sessionId,
// Use temp transcript if available, otherwise fallback to append-based
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PostToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_response: objectToSnakeCase(ctx.toolOutput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const messages: string[] = []
const warnings: string[] = []
let firstHookName: string | undefined
const startTime = Date.now()
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("PostToolUse", hook.command, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = getHookIdentifier(hook)
if (!firstHookName) firstHookName = hookName
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()}`)
}
continue
}
if (result.exitCode === 0 && result.stdout) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
return {
block: false,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
const elapsedMs = Date.now() - startTime
return {
block: false,
message: messages.length > 0 ? messages.join("\n") : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs,
hookName: firstHookName,
toolName: transformedToolName,
}
} finally {
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
deleteTempTranscript(tempTranscriptPath)
}
}