feat(hooks): integrate Claude Code hooks with plugin system
- Create factory function createClaudeCodeHooksHook() - Wire tool.execute.before → executePreToolUseHooks - Wire tool.execute.after → executePostToolUseHooks - Wire event (session.idle) → executeStopHooks - Register hooks in src/index.ts - Claude hooks execute first in handler chain 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
56
notepad.md
56
notepad.md
@@ -739,3 +739,59 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2025-12-09 18:08] - Task 8: Factory 생성 + 통합
|
||||||
|
|
||||||
|
### DISCOVERED ISSUES
|
||||||
|
- None - final integration task with well-defined hook executors from previous tasks
|
||||||
|
|
||||||
|
### IMPLEMENTATION DECISIONS
|
||||||
|
- Created `src/hooks/claude-code-hooks/index.ts` (146 lines) with createClaudeCodeHooksHook() factory
|
||||||
|
- Factory returns hook handler object with 3 handlers:
|
||||||
|
* `tool.execute.before`: Executes executePreToolUseHooks()
|
||||||
|
- Loads config dynamically (async) on each invocation
|
||||||
|
- Maps OpenCode input → PreToolUseContext
|
||||||
|
- Caches tool input for PostToolUse
|
||||||
|
- Handles deny/ask decisions (deny throws error, ask logs warning)
|
||||||
|
* `tool.execute.after`: Executes executePostToolUseHooks()
|
||||||
|
- Retrieves cached tool input via getToolInput()
|
||||||
|
- Maps OpenCode input → PostToolUseContext with client wrapper
|
||||||
|
- Appends hook message to output if provided
|
||||||
|
- Throws error if block decision returned
|
||||||
|
* `event`: Executes executeStopHooks() for session.idle
|
||||||
|
- Filters event.type === "session.idle"
|
||||||
|
- Maps OpenCode event → StopContext
|
||||||
|
- Injects prompt via ctx.client.session.prompt() if injectPrompt returned
|
||||||
|
- Updated `src/hooks/index.ts`: Added createClaudeCodeHooksHook export
|
||||||
|
- Updated `src/index.ts`:
|
||||||
|
* Imported createClaudeCodeHooksHook
|
||||||
|
* Created claudeCodeHooks instance
|
||||||
|
* Registered handlers in tool.execute.before, tool.execute.after, event hooks
|
||||||
|
* Claude hooks run FIRST in execution order (before other hooks)
|
||||||
|
- Config loading: Async loadClaudeHooksConfig() and loadPluginExtendedConfig() called in each handler (not cached)
|
||||||
|
- Transcript path: Uses getTranscriptPath() function (not buildTranscriptPath which doesn't exist)
|
||||||
|
|
||||||
|
### PROBLEMS FOR NEXT TASKS
|
||||||
|
- None - this is the final task (Task 8)
|
||||||
|
- All Claude Code Hooks now integrated into oh-my-opencode plugin system
|
||||||
|
|
||||||
|
### VERIFICATION RESULTS
|
||||||
|
- Ran: `bun run typecheck` → exit 0, no errors
|
||||||
|
- Ran: `bun run build` → exit 0, successful build
|
||||||
|
- Files modified:
|
||||||
|
* src/hooks/claude-code-hooks/index.ts (created)
|
||||||
|
* src/hooks/index.ts (export added)
|
||||||
|
* src/index.ts (hook registration)
|
||||||
|
- Hook handler registration verified: claudeCodeHooks handlers called in all 3 hook points
|
||||||
|
- Execution order verified: Claude hooks run before existing hooks in tool.execute.*
|
||||||
|
|
||||||
|
### LEARNINGS
|
||||||
|
- OpenCode Plugin API: Factory pattern createXxxHook(ctx: PluginInput) → handlers object
|
||||||
|
- OpenCode does NOT have chat.params hook → UserPromptSubmit not implemented in factory
|
||||||
|
- Config loading must be async → call loadClaudeHooksConfig() in each handler, not once during initialization
|
||||||
|
- Tool input cache is module-level state → cacheToolInput/getToolInput work across handlers
|
||||||
|
- Stop hook only triggers on session.idle event → filter event.type
|
||||||
|
- Import path: getTranscriptPath (exists), not buildTranscriptPath (doesn't exist)
|
||||||
|
|
||||||
|
소요 시간: ~6분
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
145
src/hooks/claude-code-hooks/index.ts
Normal file
145
src/hooks/claude-code-hooks/index.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { loadClaudeHooksConfig } from "./config"
|
||||||
|
import { loadPluginExtendedConfig } from "./config-loader"
|
||||||
|
import {
|
||||||
|
executePreToolUseHooks,
|
||||||
|
type PreToolUseContext,
|
||||||
|
} from "./pre-tool-use"
|
||||||
|
import {
|
||||||
|
executePostToolUseHooks,
|
||||||
|
type PostToolUseContext,
|
||||||
|
type PostToolUseClient,
|
||||||
|
} from "./post-tool-use"
|
||||||
|
import {
|
||||||
|
executeStopHooks,
|
||||||
|
type StopContext,
|
||||||
|
} from "./stop"
|
||||||
|
import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
||||||
|
import { getTranscriptPath } from "./transcript"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
|
||||||
|
export function createClaudeCodeHooksHook(ctx: PluginInput) {
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool.execute.before": async (
|
||||||
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
|
output: { args: Record<string, unknown> }
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const claudeConfig = await loadClaudeHooksConfig()
|
||||||
|
const extendedConfig = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
const preCtx: PreToolUseContext = {
|
||||||
|
sessionId: input.sessionID,
|
||||||
|
toolName: input.tool,
|
||||||
|
toolInput: output.args,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
transcriptPath: getTranscriptPath(input.sessionID),
|
||||||
|
toolUseId: input.callID,
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheToolInput(input.sessionID, input.tool, input.callID, output.args)
|
||||||
|
|
||||||
|
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
|
||||||
|
|
||||||
|
if (result.decision === "deny") {
|
||||||
|
throw new Error(result.reason || "Tool execution denied by PreToolUse hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.decision === "ask") {
|
||||||
|
log(`[Claude Hooks] PreToolUse hook returned "ask" decision, but OpenCode doesn't support interactive prompts. Allowing by default.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.modifiedInput) {
|
||||||
|
output.args = result.modifiedInput
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`[Claude Hooks] PreToolUse error:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"tool.execute.after": async (
|
||||||
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
|
output: { title: string; output: string; metadata: unknown }
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const claudeConfig = await loadClaudeHooksConfig()
|
||||||
|
const extendedConfig = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||||
|
|
||||||
|
const postClient: PostToolUseClient = {
|
||||||
|
session: {
|
||||||
|
messages: (opts) => ctx.client.session.messages(opts),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const postCtx: PostToolUseContext = {
|
||||||
|
sessionId: input.sessionID,
|
||||||
|
toolName: input.tool,
|
||||||
|
toolInput: cachedInput,
|
||||||
|
toolOutput: {
|
||||||
|
title: output.title,
|
||||||
|
output: output.output,
|
||||||
|
metadata: output.metadata,
|
||||||
|
},
|
||||||
|
cwd: ctx.directory,
|
||||||
|
transcriptPath: getTranscriptPath(input.sessionID),
|
||||||
|
toolUseId: input.callID,
|
||||||
|
client: postClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
output.output += `\n\n${result.message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.block) {
|
||||||
|
throw new Error(result.reason || "Tool execution blocked by PostToolUse hook")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`[Claude Hooks] PostToolUse error:`, error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
event: async (input: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
const { event } = input
|
||||||
|
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
try {
|
||||||
|
const claudeConfig = await loadClaudeHooksConfig()
|
||||||
|
const extendedConfig = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const stopCtx: StopContext = {
|
||||||
|
sessionId: sessionID,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
transcriptPath: getTranscriptPath(sessionID),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
|
||||||
|
|
||||||
|
if (result.injectPrompt) {
|
||||||
|
await ctx.client.session.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: "text", text: result.injectPrompt }],
|
||||||
|
},
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
}).catch((err) => {
|
||||||
|
log(`[Claude Hooks] Failed to inject prompt from Stop hook:`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`[Claude Hooks] Stop hook error:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
|||||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||||
export { createThinkModeHook } from "./think-mode";
|
export { createThinkModeHook } from "./think-mode";
|
||||||
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createDirectoryAgentsInjectorHook,
|
createDirectoryAgentsInjectorHook,
|
||||||
createEmptyTaskResponseDetectorHook,
|
createEmptyTaskResponseDetectorHook,
|
||||||
createThinkModeHook,
|
createThinkModeHook,
|
||||||
|
createClaudeCodeHooksHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import {
|
import {
|
||||||
loadUserCommands,
|
loadUserCommands,
|
||||||
@@ -76,6 +77,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||||
const thinkMode = createThinkModeHook();
|
const thinkMode = createThinkModeHook();
|
||||||
|
const claudeCodeHooks = createClaudeCodeHooksHook(ctx);
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
event: async (input) => {
|
event: async (input) => {
|
||||||
|
await claudeCodeHooks.event(input);
|
||||||
await todoContinuationEnforcer(input);
|
await todoContinuationEnforcer(input);
|
||||||
await contextWindowMonitor.event(input);
|
await contextWindowMonitor.event(input);
|
||||||
await directoryAgentsInjector.event(input);
|
await directoryAgentsInjector.event(input);
|
||||||
@@ -229,6 +232,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
|
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||||
await commentChecker["tool.execute.before"](input, output);
|
await commentChecker["tool.execute.before"](input, output);
|
||||||
|
|
||||||
if (input.sessionID === getMainSessionID()) {
|
if (input.sessionID === getMainSessionID()) {
|
||||||
@@ -243,6 +247,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"tool.execute.after": async (input, output) => {
|
"tool.execute.after": async (input, output) => {
|
||||||
|
await claudeCodeHooks["tool.execute.after"](input, output);
|
||||||
await grepOutputTruncator["tool.execute.after"](input, output);
|
await grepOutputTruncator["tool.execute.after"](input, output);
|
||||||
await contextWindowMonitor["tool.execute.after"](input, output);
|
await contextWindowMonitor["tool.execute.after"](input, output);
|
||||||
await commentChecker["tool.execute.after"](input, output);
|
await commentChecker["tool.execute.after"](input, output);
|
||||||
|
|||||||
Reference in New Issue
Block a user