diff --git a/notepad.md b/notepad.md index 463781e..1f63da3 100644 --- a/notepad.md +++ b/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분 + +--- + diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts new file mode 100644 index 0000000..3c517f9 --- /dev/null +++ b/src/hooks/claude-code-hooks/types.ts @@ -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[] +} diff --git a/src/shared/hook-disabled.ts b/src/shared/hook-disabled.ts new file mode 100644 index 0000000..24b37ee --- /dev/null +++ b/src/shared/hook-disabled.ts @@ -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 +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 9ce0414..7e87c2a 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/pattern-matcher.ts b/src/shared/pattern-matcher.ts new file mode 100644 index 0000000..e236736 --- /dev/null +++ b/src/shared/pattern-matcher.ts @@ -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) + }) +} diff --git a/src/shared/snake-case.ts b/src/shared/snake-case.ts new file mode 100644 index 0000000..cec7278 --- /dev/null +++ b/src/shared/snake-case.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function objectToSnakeCase( + obj: Record, + deep: boolean = true +): Record { + const result: Record = {} + 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, + deep: boolean = true +): Record { + const result: Record = {} + 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 + } diff --git a/src/shared/tool-name.ts b/src/shared/tool-name.ts new file mode 100644 index 0000000..d94ede6 --- /dev/null +++ b/src/shared/tool-name.ts @@ -0,0 +1,26 @@ +const SPECIAL_TOOL_MAPPINGS: Record = { + 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) +}