feat(hooks): add PostToolUse hook executor
- 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)
This commit is contained in:
104
notepad.md
104
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분
|
||||
|
||||
---
|
||||
|
||||
|
||||
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
@@ -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<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostToolUseContext {
|
||||
sessionId: string
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
toolOutput: Record<string, unknown>
|
||||
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<PostToolUseResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user