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:
YeonGyu-Kim
2025-12-09 17:31:15 +09:00
parent e147be7ed4
commit fef7f4ca03
7 changed files with 192 additions and 0 deletions

View File

@@ -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분
---

View 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[]
}

View 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
}

View File

@@ -3,3 +3,7 @@ export * from "./command-executor"
export * from "./file-reference-resolver"
export * from "./model-sanitizer"
export * from "./logger"
export * from "./snake-case"
export * from "./tool-name"
export * from "./pattern-matcher"
export * from "./hook-disabled"

View 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
View 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
View 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)
}