From 3fcfedcec08ea63016a026e26e0f9bb73c93f32b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 9 Dec 2025 17:55:06 +0900 Subject: [PATCH] feat(hooks): add PostToolUse hook executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port post-tool-use.ts from opencode-cc-plugin (200 lines) - Implement executePostToolUseHooks() with full transcript support - Include temp file cleanup in finally block - Preserve all exit code handling and output fields - Update notepad.md with Task 5 completion log πŸ€– GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- notepad.md | 104 ++++++++++ src/hooks/claude-code-hooks/post-tool-use.ts | 199 +++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 src/hooks/claude-code-hooks/post-tool-use.ts diff --git a/notepad.md b/notepad.md index 64b5cfc..4cacfda 100644 --- a/notepad.md +++ b/notepad.md @@ -538,3 +538,107 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025 --- +## [2025-12-09 17:48] - Task 4: pre-tool-use.ts ν¬νŒ… (+ plugin-config.ts, config-loader.ts) + +### DISCOVERED ISSUES +- pre-tool-use.ts depends on DEFAULT_CONFIG and isHookCommandDisabled which weren't created yet +- Plan document listed plugin-config.ts and config-loader.ts as separate task (Section 4), but not mentioned in Task 4 instructions +- These dependency files needed to be created before pre-tool-use.ts could compile + +### IMPLEMENTATION DECISIONS +- Created plugin-config.ts (9 lines) with DEFAULT_CONFIG containing forceZsh and zshPath settings + * Minimal version - only fields used by pre-tool-use.ts (not full opencode-cc-plugin config) + * forceZsh: true, zshPath: "/bin/zsh" +- Created config-loader.ts (105 lines) - full copy from opencode-cc-plugin + * Changed import: `../claude-compat/types` β†’ `./types` + * Changed import: `../shared/logger` β†’ `../../shared/logger` + * Functions: loadPluginExtendedConfig(), isHookCommandDisabled() + * Supports regex patterns for disabling specific hook commands +- Created pre-tool-use.ts (172 lines) - full copy with adjusted imports: + * `../types` β†’ `./types` + * `../../shared` β†’ `../../shared` (unchanged) + * `../../config` β†’ `./plugin-config` (NEW file) + * `../../config-loader` β†’ `./config-loader` (NEW file) +- Preserved ALL exit code logic: + * exitCode === 2 β†’ decision = "deny" + * exitCode === 1 β†’ decision = "ask" + * exitCode === 0 β†’ parse JSON for decision +- Preserved ALL deprecated field support: + * decision: "approve" β†’ "allow" + * decision: "block" β†’ "deny" +- Original comments from source preserved (backward compat, spec references) + +### PROBLEMS FOR NEXT TASKS +- Task 5 (post-tool-use.ts) can now import executePreToolUseHooks if needed +- plugin-config.ts and config-loader.ts are now available for all subsequent hook implementations +- isHookCommandDisabled pattern can be reused in PostToolUse, UserPromptSubmit, Stop hooks + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` β†’ exit 0, no errors +- Ran: `bun run build` β†’ exit 0, successful +- Committed: 530c4d6 "feat(hooks): add PreToolUse hook executor" + * 4 files: tool-input-cache.ts (Task 3), plugin-config.ts, config-loader.ts, pre-tool-use.ts (Task 4) + * 333 insertions total +- Functions available: executePreToolUseHooks(), isHookCommandDisabled(), loadPluginExtendedConfig() +- Exit code mapping verified: lines 96-116 check exitCode === 2/1/0 +- Deprecated field mapping verified: lines 132-141 check decision === "approve"/"block" + +### LEARNINGS +- Pre-tool-use.ts depends on plugin configuration that wasn't part of oh-my-opencode's original structure +- plugin-config.ts only needs subset of opencode-cc-plugin's config.ts (forceZsh, zshPath for executeHookCommand) +- config-loader.ts provides hook command filtering via regex patterns (disabledHooks config) +- executeHookCommand from shared/ accepts ExecuteHookOptions{ forceZsh, zshPath } parameter +- Task 3 + Task 4 grouped in single commit per plan requirement +- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/pre-tool-use.ts` (173 lines) + +μ†Œμš” μ‹œκ°„: ~5λΆ„ + +--- + +## [2025-12-09 17:52] - Task 5: post-tool-use.ts ν¬νŒ… + +### DISCOVERED ISSUES +- None - straightforward file copy with import path adjustments + +### IMPLEMENTATION DECISIONS +- Copied post-tool-use.ts (200 lines) from opencode-cc-plugin β†’ oh-my-opencode +- Import path adjustments: + * `../types` β†’ `./types` + * `../../shared` β†’ `../../shared` (unchanged) + * `../../config` β†’ `./plugin-config` + * `../transcript` β†’ `./transcript` + * `../../config-loader` β†’ `./config-loader` +- Preserved ALL transcript logic: + * buildTranscriptFromSession() call with client.session.messages() API + * Temp file creation in try block + * deleteTempTranscript() cleanup in finally block +- Preserved ALL exit code handling: + * exitCode === 2 β†’ warning (continue) + * exitCode === 0 β†’ parse JSON for decision: "block" + * Non-zero, non-2 β†’ parse JSON for decision: "block" +- Preserved ALL output fields: block, reason, message, warnings, elapsedMs, additionalContext, continue, stopReason, suppressOutput, systemMessage +- Original comments from source preserved (PORT FROM DISABLED, cleanup explanation) + +### PROBLEMS FOR NEXT TASKS +- Task 6 (user-prompt-submit.ts, stop.ts) can use similar pattern for hook execution +- plugin-config.ts, config-loader.ts, transcript.ts dependencies already in place + +### VERIFICATION RESULTS +- Ran: `bun run typecheck` β†’ exit 0, no errors +- Ran: `bun run build` β†’ exit 0, successful +- File created: `src/hooks/claude-code-hooks/post-tool-use.ts` (200 lines) +- Functions available: executePostToolUseHooks() +- Transcript integration verified: buildTranscriptFromSession() imported from ./transcript +- Cleanup mechanism verified: deleteTempTranscript() in finally block (line 196) + +### LEARNINGS +- PostToolUse differs from PreToolUse: no permission decision (allow/deny/ask), only block/continue +- PostToolUse provides hook results via message/warnings/additionalContext (observability, not control) +- Exit code 2 in PostToolUse = warning (not block), collected in warnings array +- Transcript temp file pattern: create in try, cleanup in finally (prevents disk accumulation) +- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/post-tool-use.ts` (200 lines) + +μ†Œμš” μ‹œκ°„: ~5λΆ„ + +--- + diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts new file mode 100644 index 0000000..d7caf4c --- /dev/null +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -0,0 +1,199 @@ +import type { + PostToolUseInput, + PostToolUseOutput, + ClaudeHooksConfig, +} from "./types" +import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" +import { DEFAULT_CONFIG } from "./plugin-config" +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 + } +} + +export interface PostToolUseContext { + sessionId: string + toolName: string + toolInput: Record + toolOutput: Record + 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 { + 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) { + for (const hook of matcher.hooks) { + if (hook.type !== "command") continue + + if (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 + 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.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) + } +}