feat(hooks): add PreToolUse hook executor
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
105
src/hooks/claude-code-hooks/config-loader.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal 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",
|
||||||
|
}
|
||||||
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal 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" }
|
||||||
|
}
|
||||||
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal 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)
|
||||||
Reference in New Issue
Block a user