feat(hooks): add PreToolUse hook executor

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-09 17:48:28 +09:00
parent e0b43380cc
commit 530c4d63d5
4 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
export interface DisabledHooksConfig {
Stop?: string[]
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
}
export interface PluginExtendedConfig {
disabledHooks?: DisabledHooksConfig
}
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
function getProjectConfigPath(): string {
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
}
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
if (!existsSync(path)) {
return null
}
try {
const content = await Bun.file(path).text()
return JSON.parse(content) as PluginExtendedConfig
} catch (error) {
log("Failed to load config", { path, error })
return null
}
}
function mergeDisabledHooks(
base: DisabledHooksConfig | undefined,
override: DisabledHooksConfig | undefined
): DisabledHooksConfig {
if (!override) return base ?? {}
if (!base) return override
return {
Stop: override.Stop ?? base.Stop,
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
}
}
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
const merged: PluginExtendedConfig = {
disabledHooks: mergeDisabledHooks(
userConfig?.disabledHooks,
projectConfig?.disabledHooks
),
}
if (userConfig || projectConfig) {
log("Plugin extended config loaded", {
userConfigExists: userConfig !== null,
projectConfigExists: projectConfig !== null,
mergedDisabledHooks: merged.disabledHooks,
})
}
return merged
}
const regexCache = new Map<string, RegExp>()
function getRegex(pattern: string): RegExp {
let regex = regexCache.get(pattern)
if (!regex) {
try {
regex = new RegExp(pattern)
regexCache.set(pattern, regex)
} catch {
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
regexCache.set(pattern, regex)
}
}
return regex
}
export function isHookCommandDisabled(
eventType: ClaudeHookEvent,
command: string,
config: PluginExtendedConfig | null
): boolean {
if (!config?.disabledHooks) return false
const patterns = config.disabledHooks[eventType]
if (!patterns || patterns.length === 0) return false
return patterns.some((pattern) => {
const regex = getRegex(pattern)
return regex.test(command)
})
}

View File

@@ -0,0 +1,9 @@
/**
* Plugin configuration for Claude Code hooks execution
* Contains settings for hook command execution (zsh, etc.)
*/
export const DEFAULT_CONFIG = {
forceZsh: true,
zshPath: "/bin/zsh",
}

View File

@@ -0,0 +1,172 @@
import type {
PreToolUseInput,
PreToolUseOutput,
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
cwd: string
transcriptPath?: string
toolUseId?: string
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PreToolUseResult {
decision: PermissionDecision
reason?: string
modifiedInput?: Record<string, unknown>
elapsedMs?: number
hookName?: string
toolName?: string
inputLines?: string
// Common output fields (Claude Code spec)
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
function buildInputLines(toolInput: Record<string, unknown>): string {
return Object.entries(toolInput)
.slice(0, 3)
.map(([key, val]) => {
const valStr = String(val).slice(0, 40)
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
})
.join("\n")
}
export async function executePreToolUseHooks(
ctx: PreToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreToolUseResult> {
if (!config) {
return { decision: "allow" }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
if (matchers.length === 0) {
return { decision: "allow" }
}
const stdinData: PreToolUseInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PreToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (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
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.exitCode === 1) {
return {
decision: "ask",
reason: result.stderr || result.stdout,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreToolUseOutput
// Handle deprecated decision/reason fields (Claude Code backward compat)
let decision: PermissionDecision | undefined
let reason: string | undefined
let modifiedInput: Record<string, unknown> | undefined
if (output.hookSpecificOutput?.permissionDecision) {
decision = output.hookSpecificOutput.permissionDecision
reason = output.hookSpecificOutput.permissionDecisionReason
modifiedInput = output.hookSpecificOutput.updatedInput
} else if (output.decision) {
// Map deprecated values: approve->allow, block->deny, ask->ask
const legacyDecision = output.decision
if (legacyDecision === "approve" || legacyDecision === "allow") {
decision = "allow"
} else if (legacyDecision === "block" || legacyDecision === "deny") {
decision = "deny"
} else if (legacyDecision === "ask") {
decision = "ask"
}
reason = 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 || hasCommonFields) {
return {
decision: decision ?? "allow",
reason,
modifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
return { decision: "allow" }
}

View File

@@ -0,0 +1,47 @@
/**
* Caches tool_input from PreToolUse for PostToolUse
*/
interface CacheEntry {
toolInput: Record<string, unknown>
timestamp: number
}
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 60000 // 1 minute
export function cacheToolInput(
sessionId: string,
toolName: string,
invocationId: string,
toolInput: Record<string, unknown>
): void {
const key = `${sessionId}:${toolName}:${invocationId}`
cache.set(key, { toolInput, timestamp: Date.now() })
}
export function getToolInput(
sessionId: string,
toolName: string,
invocationId: string
): Record<string, unknown> | null {
const key = `${sessionId}:${toolName}:${invocationId}`
const entry = cache.get(key)
if (!entry) return null
cache.delete(key)
if (Date.now() - entry.timestamp > CACHE_TTL) return null
return entry.toolInput
}
// Periodic cleanup (every minute)
setInterval(() => {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (now - entry.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}, CACHE_TTL)