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
This commit is contained in:
parent
866bd50dca
commit
43dfdb2380
@ -81,10 +81,14 @@ export interface PluginManifest {
|
|||||||
* Hooks configuration
|
* Hooks configuration
|
||||||
*/
|
*/
|
||||||
export interface HookEntry {
|
export interface HookEntry {
|
||||||
type: "command" | "prompt" | "agent"
|
type: "command" | "prompt" | "agent" | "http"
|
||||||
command?: string
|
command?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
agent?: string
|
agent?: string
|
||||||
|
url?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
allowedEnvVars?: string[]
|
||||||
|
timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HookMatcher {
|
export interface HookMatcher {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { getClaudeConfigDir } from "../../shared"
|
import { getClaudeConfigDir } from "../../shared"
|
||||||
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
|
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
||||||
|
|
||||||
interface RawHookMatcher {
|
interface RawHookMatcher {
|
||||||
matcher?: string
|
matcher?: string
|
||||||
pattern?: string
|
pattern?: string
|
||||||
hooks: HookCommand[]
|
hooks: HookAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawClaudeHooksConfig {
|
interface RawClaudeHooksConfig {
|
||||||
|
|||||||
27
src/hooks/claude-code-hooks/dispatch-hook.ts
Normal file
27
src/hooks/claude-code-hooks/dispatch-hook.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
237
src/hooks/claude-code-hooks/execute-http-hook.test.ts
Normal file
237
src/hooks/claude-code-hooks/execute-http-hook.test.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
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 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 ")
|
||||||
|
})
|
||||||
|
})
|
||||||
87
src/hooks/claude-code-hooks/execute-http-hook.ts
Normal file
87
src/hooks/claude-code-hooks/execute-http-hook.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
let result = value.replace(/\$\{(\w+)\}/g, (_match, varName: string) => {
|
||||||
|
if (allowedSet.has(varName)) {
|
||||||
|
return process.env[varName] ?? ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
result = result.replace(/\$(\w+)/g, (_match, varName: string) => {
|
||||||
|
if (allowedSet.has(varName)) {
|
||||||
|
return process.env[varName] ?? ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,8 +3,8 @@ import type {
|
|||||||
PostToolUseOutput,
|
PostToolUseOutput,
|
||||||
ClaudeHooksConfig,
|
ClaudeHooksConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
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"
|
||||||
|
|
||||||
@ -94,22 +94,17 @@ export async function executePostToolUseHooks(
|
|||||||
for (const matcher of matchers) {
|
for (const matcher of matchers) {
|
||||||
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
||||||
for (const hook of matcher.hooks) {
|
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 })
|
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookName = hook.command.split("/").pop() || hook.command
|
const hookName = getHookIdentifier(hook)
|
||||||
if (!firstHookName) firstHookName = hookName
|
if (!firstHookName) firstHookName = hookName
|
||||||
|
|
||||||
const result = await executeHookCommand(
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||||
hook.command,
|
|
||||||
JSON.stringify(stdinData),
|
|
||||||
ctx.cwd,
|
|
||||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.stdout) {
|
if (result.stdout) {
|
||||||
messages.push(result.stdout)
|
messages.push(result.stdout)
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import type {
|
|||||||
PreCompactOutput,
|
PreCompactOutput,
|
||||||
ClaudeHooksConfig,
|
ClaudeHooksConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
import { findMatchingHooks, log } from "../../shared"
|
||||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||||
|
|
||||||
export interface PreCompactContext {
|
export interface PreCompactContext {
|
||||||
@ -50,22 +50,17 @@ export async function executePreCompactHooks(
|
|||||||
for (const matcher of matchers) {
|
for (const matcher of matchers) {
|
||||||
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
||||||
for (const hook of matcher.hooks) {
|
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 })
|
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookName = hook.command.split("/").pop() || hook.command
|
const hookName = getHookIdentifier(hook)
|
||||||
if (!firstHookName) firstHookName = hookName
|
if (!firstHookName) firstHookName = hookName
|
||||||
|
|
||||||
const result = await executeHookCommand(
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||||
hook.command,
|
|
||||||
JSON.stringify(stdinData),
|
|
||||||
ctx.cwd,
|
|
||||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.exitCode === 2) {
|
if (result.exitCode === 2) {
|
||||||
log("PreCompact hook blocked", { hookName, stderr: result.stderr })
|
log("PreCompact hook blocked", { hookName, stderr: result.stderr })
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import type {
|
|||||||
PermissionDecision,
|
PermissionDecision,
|
||||||
ClaudeHooksConfig,
|
ClaudeHooksConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
|
||||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||||
|
|
||||||
export interface PreToolUseContext {
|
export interface PreToolUseContext {
|
||||||
@ -77,22 +77,17 @@ export async function executePreToolUseHooks(
|
|||||||
for (const matcher of matchers) {
|
for (const matcher of matchers) {
|
||||||
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
||||||
for (const hook of matcher.hooks) {
|
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 })
|
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookName = hook.command.split("/").pop() || hook.command
|
const hookName = getHookIdentifier(hook)
|
||||||
if (!firstHookName) firstHookName = hookName
|
if (!firstHookName) firstHookName = hookName
|
||||||
|
|
||||||
const result = await executeHookCommand(
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||||
hook.command,
|
|
||||||
JSON.stringify(stdinData),
|
|
||||||
ctx.cwd,
|
|
||||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.exitCode === 2) {
|
if (result.exitCode === 2) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import type {
|
|||||||
StopOutput,
|
StopOutput,
|
||||||
ClaudeHooksConfig,
|
ClaudeHooksConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
import { findMatchingHooks, log } from "../../shared"
|
||||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
import { dispatchHook } 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"
|
||||||
|
|
||||||
@ -68,19 +68,14 @@ export async function executeStopHooks(
|
|||||||
for (const matcher of matchers) {
|
for (const matcher of matchers) {
|
||||||
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
||||||
for (const hook of matcher.hooks) {
|
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 })
|
log("Stop hook command skipped (disabled by config)", { command: hook.command })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeHookCommand(
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||||
hook.command,
|
|
||||||
JSON.stringify(stdinData),
|
|
||||||
ctx.cwd,
|
|
||||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type ClaudeHookEvent =
|
|||||||
|
|
||||||
export interface HookMatcher {
|
export interface HookMatcher {
|
||||||
matcher: string
|
matcher: string
|
||||||
hooks: HookCommand[]
|
hooks: HookAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HookCommand {
|
export interface HookCommand {
|
||||||
@ -20,6 +20,16 @@ export interface HookCommand {
|
|||||||
command: string
|
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 {
|
export interface ClaudeHooksConfig {
|
||||||
PreToolUse?: HookMatcher[]
|
PreToolUse?: HookMatcher[]
|
||||||
PostToolUse?: HookMatcher[]
|
PostToolUse?: HookMatcher[]
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import type {
|
|||||||
PostToolUseOutput,
|
PostToolUseOutput,
|
||||||
ClaudeHooksConfig,
|
ClaudeHooksConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
import { findMatchingHooks, log } from "../../shared"
|
||||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
import { dispatchHook } from "./dispatch-hook"
|
||||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||||
|
|
||||||
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
||||||
@ -80,19 +80,14 @@ export async function executeUserPromptSubmitHooks(
|
|||||||
for (const matcher of matchers) {
|
for (const matcher of matchers) {
|
||||||
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
||||||
for (const hook of matcher.hooks) {
|
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 })
|
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeHookCommand(
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
||||||
hook.command,
|
|
||||||
JSON.stringify(stdinData),
|
|
||||||
ctx.cwd,
|
|
||||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.stdout) {
|
if (result.stdout) {
|
||||||
const output = result.stdout.trim()
|
const output = result.stdout.trim()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user