Merge pull request #2208 from code-yeongyu/feat/http-hook-support

feat(hooks): add HTTP hook handler support
This commit is contained in:
YeonGyu-Kim
2026-02-28 12:10:45 +09:00
committed by GitHub
11 changed files with 398 additions and 62 deletions

View File

@@ -80,12 +80,11 @@ export interface PluginManifest {
/**
* Hooks configuration
*/
export interface HookEntry {
type: "command" | "prompt" | "agent"
command?: string
prompt?: string
agent?: string
}
export type HookEntry =
| { type: "command"; command?: string }
| { type: "prompt"; prompt?: string }
| { type: "agent"; agent?: string }
| { type: "http"; url: string; headers?: Record<string, string>; allowedEnvVars?: string[]; timeout?: number }
export interface HookMatcher {
matcher?: string

View File

@@ -1,12 +1,12 @@
import { join } from "path"
import { existsSync } from "fs"
import { getClaudeConfigDir } from "../../shared"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
hooks: HookAction[]
}
interface RawClaudeHooksConfig {

View File

@@ -0,0 +1,27 @@
import type { HookAction } from "./types"
import type { CommandResult } from "../../shared/command-executor/execute-hook-command"
import { executeHookCommand } from "../../shared"
import { executeHttpHook } from "./execute-http-hook"
import { DEFAULT_CONFIG } from "./plugin-config"
export function getHookIdentifier(hook: HookAction): string {
if (hook.type === "http") return hook.url
return hook.command.split("/").pop() || hook.command
}
export async function dispatchHook(
hook: HookAction,
stdinJson: string,
cwd: string
): Promise<CommandResult> {
if (hook.type === "http") {
return executeHttpHook(hook, stdinJson)
}
return executeHookCommand(
hook.command,
stdinJson,
cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
}

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
import type { HookHttp } from "./types"
const mockFetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
)
const originalFetch = globalThis.fetch
describe("executeHttpHook", () => {
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch
mockFetch.mockReset()
mockFetch.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
)
})
afterEach(() => {
globalThis.fetch = originalFetch
})
describe("#given a basic HTTP hook", () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks/pre-tool-use",
}
const stdinData = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash" })
it("#when executed #then sends POST request with correct body", async () => {
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, stdinData)
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe("http://localhost:8080/hooks/pre-tool-use")
expect(options.method).toBe("POST")
expect(options.body).toBe(stdinData)
})
it("#when executed #then sets content-type to application/json", async () => {
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, stdinData)
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Content-Type"]).toBe("application/json")
})
})
describe("#given an HTTP hook with headers and env var interpolation", () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv, MY_TOKEN: "secret-123", OTHER_VAR: "other-value" }
})
afterEach(() => {
process.env = originalEnv
})
it("#when allowedEnvVars includes the var #then interpolates env var in headers", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer $MY_TOKEN" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
it("#when env var uses ${VAR} syntax #then interpolates correctly", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer ${MY_TOKEN}" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer secret-123")
})
it("#when env var not in allowedEnvVars #then replaces with empty string", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
headers: { Authorization: "Bearer $OTHER_VAR" },
allowedEnvVars: ["MY_TOKEN"],
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
const headers = options.headers as Record<string, string>
expect(headers["Authorization"]).toBe("Bearer ")
})
})
describe("#given an HTTP hook with timeout", () => {
it("#when timeout specified #then passes AbortSignal with timeout", async () => {
const hook: HookHttp = {
type: "http",
url: "http://localhost:8080/hooks",
timeout: 10,
}
const { executeHttpHook } = await import("./execute-http-hook")
await executeHttpHook(hook, "{}")
const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(options.signal).toBeDefined()
})
})
describe("#given a successful HTTP response", () => {
it("#when response has JSON body #then returns parsed output", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ decision: "allow", reason: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('"decision":"allow"')
})
})
describe("#given a failing HTTP response", () => {
it("#when response status is 4xx #then returns exit code 1", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(new Response("Bad Request", { status: 400 }))
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("400")
})
it("#when fetch throws network error #then returns exit code 1", async () => {
mockFetch.mockImplementation(() => Promise.reject(new Error("ECONNREFUSED")))
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("ECONNREFUSED")
})
})
describe("#given response with exit code in JSON", () => {
it("#when JSON contains exitCode 2 #then uses that exit code", async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ exitCode: 2, stderr: "blocked" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
)
const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" }
const { executeHttpHook } = await import("./execute-http-hook")
const result = await executeHttpHook(hook, "{}")
expect(result.exitCode).toBe(2)
})
})
})
describe("interpolateEnvVars", () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv, TOKEN: "abc", SECRET: "xyz" }
})
afterEach(() => {
process.env = originalEnv
})
it("#given $VAR syntax #when var is allowed #then interpolates", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer $TOKEN", ["TOKEN"])
expect(result).toBe("Bearer abc")
})
it("#given ${VAR} syntax #when var is allowed #then interpolates", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN"])
expect(result).toBe("Bearer abc")
})
it("#given multiple vars #when some not allowed #then only interpolates allowed ones", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("$TOKEN:$SECRET", ["TOKEN"])
expect(result).toBe("abc:")
})
it("#given ${VAR} where value contains $ANOTHER #when both allowed #then does not double-interpolate", async () => {
process.env = { ...process.env, TOKEN: "val$SECRET", SECRET: "oops" }
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN", "SECRET"])
expect(result).toBe("Bearer val$SECRET")
})
it("#given no allowedEnvVars #when called #then replaces all with empty", async () => {
const { interpolateEnvVars } = await import("./execute-http-hook")
const result = interpolateEnvVars("Bearer $TOKEN", [])
expect(result).toBe("Bearer ")
})
})

View File

@@ -0,0 +1,79 @@
import type { HookHttp } from "./types"
import type { CommandResult } from "../../shared/command-executor/execute-hook-command"
const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30
export function interpolateEnvVars(
value: string,
allowedEnvVars: string[]
): string {
const allowedSet = new Set(allowedEnvVars)
return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => {
const varName = (bracedVar ?? bareVar) as string
if (allowedSet.has(varName)) {
return process.env[varName] ?? ""
}
return ""
})
}
function resolveHeaders(
hook: HookHttp
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (!hook.headers) return headers
const allowedEnvVars = hook.allowedEnvVars ?? []
for (const [key, value] of Object.entries(hook.headers)) {
headers[key] = interpolateEnvVars(value, allowedEnvVars)
}
return headers
}
export async function executeHttpHook(
hook: HookHttp,
stdin: string
): Promise<CommandResult> {
const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S
const headers = resolveHeaders(hook)
try {
const response = await fetch(hook.url, {
method: "POST",
headers,
body: stdin,
signal: AbortSignal.timeout(timeoutS * 1000),
})
if (!response.ok) {
return {
exitCode: 1,
stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`,
stdout: await response.text().catch(() => ""),
}
}
const body = await response.text()
if (!body) {
return { exitCode: 0, stdout: "", stderr: "" }
}
try {
const parsed = JSON.parse(body) as { exitCode?: number }
if (typeof parsed.exitCode === "number") {
return { exitCode: parsed.exitCode, stdout: body, stderr: "" }
}
} catch {
}
return { exitCode: 0, stdout: body, stderr: "" }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { exitCode: 1, stderr: `HTTP hook error: ${message}` }
}
}

View File

@@ -3,8 +3,8 @@ import type {
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
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"
@@ -94,22 +94,17 @@ export async function executePostToolUseHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
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 = hook.command.split("/").pop() || hook.command
const hookName = getHookIdentifier(hook)
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) {
messages.push(result.stdout)

View File

@@ -3,8 +3,8 @@ import type {
PreCompactOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreCompactContext {
@@ -50,22 +50,17 @@ export async function executePreCompactHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
if (hook.type === "command" && isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
const hookName = getHookIdentifier(hook)
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.exitCode === 2) {
log("PreCompact hook blocked", { hookName, stderr: result.stderr })

View File

@@ -4,8 +4,8 @@ import type {
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
@@ -77,22 +77,17 @@ export async function executePreToolUseHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
if (hook.type === "command" && isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
const hookName = getHookIdentifier(hook)
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.exitCode === 2) {
return {

View File

@@ -3,8 +3,8 @@ import type {
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { findMatchingHooks, log } from "../../shared"
import { dispatchHook } from "./dispatch-hook"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
@@ -68,19 +68,14 @@ export async function executeStopHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
if (hook.type === "command" && isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {

View File

@@ -12,7 +12,7 @@ export type ClaudeHookEvent =
export interface HookMatcher {
matcher: string
hooks: HookCommand[]
hooks: HookAction[]
}
export interface HookCommand {
@@ -20,6 +20,16 @@ export interface HookCommand {
command: string
}
export interface HookHttp {
type: "http"
url: string
headers?: Record<string, string>
allowedEnvVars?: string[]
timeout?: number
}
export type HookAction = HookCommand | HookHttp
export interface ClaudeHooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]

View File

@@ -3,8 +3,8 @@ import type {
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
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>"
@@ -80,19 +80,14 @@ export async function executeUserPromptSubmitHooks(
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (hook.type !== "command" && hook.type !== "http") continue
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
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 executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) {
const output = result.stdout.trim()