feat(shared): add Claude hooks command executor and utilities
- Add snake-case.ts: objectToSnakeCase, objectToCamelCase utilities - Add tool-name.ts: transformToolName with PascalCase conversion - Add pattern-matcher.ts: findMatchingHooks for hook config matching - Add hook-disabled.ts: isHookDisabled for hook config validation - Add temporary stub types at src/hooks/claude-code-hooks/types.ts - Export all new utilities from src/shared/index.ts Stub types will be replaced with full implementation in Task 1. Import paths adjusted from opencode-cc-plugin structure to oh-my-opencode. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
35
notepad.md
35
notepad.md
@@ -396,3 +396,38 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2025-12-09 17:24] - Task 0: Shared Utilities 포팅
|
||||||
|
|
||||||
|
### DISCOVERED ISSUES
|
||||||
|
- command-executor.ts already existed but had minor whitespace differences (indentation inconsistency)
|
||||||
|
- pattern-matcher.ts and hook-disabled.ts import from `../claude-compat/types` which doesn't exist yet in oh-my-opencode
|
||||||
|
- Types will be created in Task 1 at `src/hooks/claude-code-hooks/types.ts`
|
||||||
|
|
||||||
|
### IMPLEMENTATION DECISIONS
|
||||||
|
- Created snake-case.ts and tool-name.ts (no dependencies) - exact copy from source
|
||||||
|
- Created temporary stub types at `src/hooks/claude-code-hooks/types.ts` with minimal definitions needed for shared utilities
|
||||||
|
- Created pattern-matcher.ts with adjusted import: `../claude-compat/types` → `../hooks/claude-code-hooks/types`
|
||||||
|
- Created hook-disabled.ts with adjusted import to point to stub types
|
||||||
|
- Added all new utilities to `src/shared/index.ts` using barrel export pattern
|
||||||
|
- Stub types include: HookCommand, HookMatcher, ClaudeHooksConfig, ClaudeHookEvent, PluginConfig
|
||||||
|
|
||||||
|
### PROBLEMS FOR NEXT TASKS
|
||||||
|
- Task 1 will replace stub types with full implementation from opencode-cc-plugin
|
||||||
|
- Stub types in `src/hooks/claude-code-hooks/types.ts` are marked with comments indicating they're temporary
|
||||||
|
- The real PluginConfig will likely be different - current stub only supports `disabledHooks` field
|
||||||
|
|
||||||
|
### VERIFICATION RESULTS
|
||||||
|
- Ran: `bun run typecheck` → exit 0, no errors
|
||||||
|
- All 5 functions exported: executeHookCommand, objectToSnakeCase, transformToolName, findMatchingHooks, isHookDisabled
|
||||||
|
- Import paths verified: pattern-matcher.ts and hook-disabled.ts successfully import from stub types
|
||||||
|
|
||||||
|
### LEARNINGS
|
||||||
|
- Import paths must be adjusted when porting between different project structures
|
||||||
|
- opencode-cc-plugin structure: `src/claude-compat/` → oh-my-opencode structure: `src/hooks/claude-code-hooks/`
|
||||||
|
- Stub types strategy allows Task 0 to complete and typecheck to pass before Task 1 implements full types
|
||||||
|
- command-executor.ts in oh-my-opencode had indentation inconsistency (not 100% identical to source)
|
||||||
|
|
||||||
|
소요 시간: ~5분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|||||||
25
src/hooks/claude-code-hooks/types.ts
Normal file
25
src/hooks/claude-code-hooks/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Temporary stub types for Task 0 - will be fully implemented in Task 1
|
||||||
|
// These are minimal definitions to allow shared utilities to type-check
|
||||||
|
|
||||||
|
export interface HookCommand {
|
||||||
|
type: string
|
||||||
|
command: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookMatcher {
|
||||||
|
matcher: string
|
||||||
|
hooks: HookCommand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeHooksConfig {
|
||||||
|
PreToolUse?: HookMatcher[]
|
||||||
|
PostToolUse?: HookMatcher[]
|
||||||
|
UserPromptSubmit?: HookMatcher[]
|
||||||
|
Stop?: HookMatcher[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClaudeHookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop"
|
||||||
|
|
||||||
|
export interface PluginConfig {
|
||||||
|
disabledHooks?: boolean | ClaudeHookEvent[]
|
||||||
|
}
|
||||||
22
src/shared/hook-disabled.ts
Normal file
22
src/shared/hook-disabled.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ClaudeHookEvent, PluginConfig } from "../hooks/claude-code-hooks/types"
|
||||||
|
|
||||||
|
export function isHookDisabled(
|
||||||
|
config: PluginConfig,
|
||||||
|
hookType: ClaudeHookEvent
|
||||||
|
): boolean {
|
||||||
|
const { disabledHooks } = config
|
||||||
|
|
||||||
|
if (disabledHooks === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabledHooks === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(disabledHooks)) {
|
||||||
|
return disabledHooks.includes(hookType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,3 +3,7 @@ export * from "./command-executor"
|
|||||||
export * from "./file-reference-resolver"
|
export * from "./file-reference-resolver"
|
||||||
export * from "./model-sanitizer"
|
export * from "./model-sanitizer"
|
||||||
export * from "./logger"
|
export * from "./logger"
|
||||||
|
export * from "./snake-case"
|
||||||
|
export * from "./tool-name"
|
||||||
|
export * from "./pattern-matcher"
|
||||||
|
export * from "./hook-disabled"
|
||||||
|
|||||||
29
src/shared/pattern-matcher.ts
Normal file
29
src/shared/pattern-matcher.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ClaudeHooksConfig, HookMatcher } from "../hooks/claude-code-hooks/types"
|
||||||
|
|
||||||
|
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||||
|
if (!matcher) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const patterns = matcher.split("|").map((p) => p.trim())
|
||||||
|
return patterns.some((p) => {
|
||||||
|
if (p.includes("*")) {
|
||||||
|
const regex = new RegExp(`^${p.replace(/\*/g, ".*")}$`, "i")
|
||||||
|
return regex.test(toolName)
|
||||||
|
}
|
||||||
|
return p.toLowerCase() === toolName.toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMatchingHooks(
|
||||||
|
config: ClaudeHooksConfig,
|
||||||
|
eventName: keyof ClaudeHooksConfig,
|
||||||
|
toolName?: string
|
||||||
|
): HookMatcher[] {
|
||||||
|
const hookMatchers = config[eventName]
|
||||||
|
if (!hookMatchers) return []
|
||||||
|
|
||||||
|
return hookMatchers.filter((hookMatcher) => {
|
||||||
|
if (!toolName) return true
|
||||||
|
return matchesToolMatcher(toolName, hookMatcher.matcher)
|
||||||
|
})
|
||||||
|
}
|
||||||
51
src/shared/snake-case.ts
Normal file
51
src/shared/snake-case.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export function camelToSnake(str: string): string {
|
||||||
|
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snakeToCamel(str: string): string {
|
||||||
|
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectToSnakeCase(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
deep: boolean = true
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const snakeKey = camelToSnake(key)
|
||||||
|
if (deep && isPlainObject(value)) {
|
||||||
|
result[snakeKey] = objectToSnakeCase(value, true)
|
||||||
|
} else if (deep && Array.isArray(value)) {
|
||||||
|
result[snakeKey] = value.map((item) =>
|
||||||
|
isPlainObject(item) ? objectToSnakeCase(item, true) : item
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result[snakeKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectToCamelCase(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
deep: boolean = true
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const camelKey = snakeToCamel(key)
|
||||||
|
if (deep && isPlainObject(value)) {
|
||||||
|
result[camelKey] = objectToCamelCase(value, true)
|
||||||
|
} else if (deep && Array.isArray(value)) {
|
||||||
|
result[camelKey] = value.map((item) =>
|
||||||
|
isPlainObject(item) ? objectToCamelCase(item, true) : item
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result[camelKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
26
src/shared/tool-name.ts
Normal file
26
src/shared/tool-name.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
|
||||||
|
webfetch: "WebFetch",
|
||||||
|
websearch: "WebSearch",
|
||||||
|
todoread: "TodoRead",
|
||||||
|
todowrite: "TodoWrite",
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPascalCase(str: string): string {
|
||||||
|
return str
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformToolName(toolName: string): string {
|
||||||
|
const lower = toolName.toLowerCase()
|
||||||
|
if (lower in SPECIAL_TOOL_MAPPINGS) {
|
||||||
|
return SPECIAL_TOOL_MAPPINGS[lower]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName.includes("-") || toolName.includes("_")) {
|
||||||
|
return toPascalCase(toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolName.charAt(0).toUpperCase() + toolName.slice(1)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user