| 1 | /** |
| 2 | * Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command |
| 3 | * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no |
| 4 | * command substitution. |
| 5 | * @module @deepseek-ai/dsh-hooks-codex/config |
| 6 | */ |
| 7 | |
| 8 | import { matcherDiagnostic } from '../../hook-protocol/src/matcher.ts' |
| 9 | import type { MatcherGroup } from '../../hook-protocol/src/types.ts' |
| 10 | |
| 11 | /** The five Codex hook points this bridge supports. */ |
| 12 | export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const |
| 13 | |
| 14 | /** A parsed Codex config: event name → its matcher groups (command hooks only). */ |
| 15 | export type CodexHookConfig = Record<string, MatcherGroup[]> |
| 16 | |
| 17 | /** A skipped non-command (or async) hook, surfaced so the bridge can warn. */ |
| 18 | export interface SkippedHook { |
| 19 | event: string |
| 20 | reason: string |
| 21 | } |
| 22 | |
| 23 | /** The outcome of parsing one Codex config file. */ |
| 24 | export interface ParsedCodexConfig { |
| 25 | config: CodexHookConfig |
| 26 | skipped: SkippedHook[] |
| 27 | } |
| 28 | |
| 29 | function asObject(value: unknown): Record<string, unknown> | undefined { |
| 30 | return typeof value === 'object' && value !== null && !Array.isArray(value) |
| 31 | ? value as Record<string, unknown> |
| 32 | : undefined |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather |
| 37 | * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on |
| 38 | * UserPromptSubmit and Stop are discarded because those events have no matcher subject. A |
| 39 | * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge |
| 40 | * to reject the complete config before listener registration. |
| 41 | * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. |
| 42 | * @returns the runnable per-event groups plus the skipped hooks with their reasons. |
| 43 | */ |
| 44 | export function parseCodexConfig(raw: unknown): ParsedCodexConfig { |
| 45 | const config: CodexHookConfig = {} |
| 46 | const skipped: SkippedHook[] = [] |
| 47 | const root = asObject(raw) |
| 48 | const hooksMap = root ? asObject(root.hooks) ?? root : undefined |
| 49 | if (!hooksMap) return { config, skipped } |
| 50 | |
| 51 | for (const event of CODEX_EVENTS) { |
| 52 | const rawGroups = hooksMap[event] |
| 53 | // Matcher-group parsing remains dialect-local because the supported hook |
| 54 | // shapes and skip reasons differ from Claude Code's. |
| 55 | /* jscpd:ignore-start */ |
| 56 | if (!Array.isArray(rawGroups)) continue |
| 57 | const groups: MatcherGroup[] = [] |
| 58 | for (const rawGroup of rawGroups) { |
| 59 | const group = asObject(rawGroup) |
| 60 | if (!group || !Array.isArray(group.hooks)) continue |
| 61 | const commands: MatcherGroup['hooks'] = [] |
| 62 | for (const rawHook of group.hooks) { |
| 63 | const hook = asObject(rawHook) |
| 64 | if (!hook) continue |
| 65 | const type = typeof hook.type === 'string' ? hook.type : 'command' |
| 66 | if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } |
| 67 | /* jscpd:ignore-end */ |
| 68 | if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } |
| 69 | if (typeof hook.command !== 'string') continue |
| 70 | // Codex accepts `timeout` or the `timeoutSec` alias. |
| 71 | const timeout = typeof hook.timeout === 'number' ? hook.timeout |
| 72 | : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined |
| 73 | commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) |
| 74 | } |
| 75 | if (commands.length === 0) continue |
| 76 | const matcher = event === 'UserPromptSubmit' || event === 'Stop' |
| 77 | ? undefined |
| 78 | : typeof group.matcher === 'string' ? group.matcher : undefined |
| 79 | const diagnostic = matcherDiagnostic(matcher, 'codex') |
| 80 | if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) |
| 81 | groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) |
| 82 | } |
| 83 | if (groups.length > 0) config[event] = groups |
| 84 | } |
| 85 | |
| 86 | return { config, skipped } |
| 87 | } |
| 88 |