| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- /**
- * Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
- * Only command hooks run; other hook types are returned as skipped so the
- * bridge can warn. Plugin-root and project-directory substitutions are applied
- * to commands at parse time.
- * @module @deepseek-ai/dsh-hooks-claude-code/config
- */
- import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
- const CLAUDE_EVENTS = [
- 'SessionStart',
- 'UserPromptSubmit',
- 'PreToolUse',
- 'PostToolUse',
- 'Stop',
- 'SubagentStart',
- 'SubagentStop',
- ] as const
- /** A parsed CC config: event name → its matcher groups (command hooks only). */
- export type ClaudeCodeHookConfig = Record<string, MatcherGroup[]>
- /** A skipped non-command hook, surfaced so the bridge can warn about it. */
- export interface SkippedHook {
- event: string
- type: string
- }
- /** The outcome of parsing one config file: the runnable groups + what was skipped. */
- export interface ParsedClaudeConfig {
- config: ClaudeCodeHookConfig
- skipped: SkippedHook[]
- }
- /** Substitution variables applied to each `command` string at parse time. */
- export interface SubstitutionVars {
- /** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */
- pluginRoot?: string
- /** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */
- projectDir?: string
- }
- /** A plain (non-null, non-array) object, else undefined. */
- function asObject(value: unknown): Record<string, unknown> | undefined {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
- ? value as Record<string, unknown>
- : undefined
- }
- /**
- * Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
- * @param command - the raw command from config.
- * @param vars - the substitution values; a token whose variable is unset stays verbatim.
- * @returns the command with every occurrence of each set token replaced.
- */
- export function substituteCommand(command: string, vars: SubstitutionVars): string {
- let out = command
- if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
- if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
- return out
- }
- /**
- * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
- * ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
- * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
- * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
- * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
- * `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
- *
- * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
- * event map.
- * @param vars - substitution values applied to every surviving `command` (defaults to
- * none).
- * @returns the runnable per-event groups plus the skipped non-command hooks.
- */
- export function parseClaudeCodeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
- const config: ClaudeCodeHookConfig = {}
- const skipped: SkippedHook[] = []
- // Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
- const root = asObject(raw)
- const hooksMap = root ? asObject(root.hooks) ?? root : undefined
- if (!hooksMap) return { config, skipped }
- for (const event of CLAUDE_EVENTS) {
- const rawGroups = hooksMap[event]
- if (!Array.isArray(rawGroups)) continue
- const groups: MatcherGroup[] = []
- for (const rawGroup of rawGroups) {
- const group = asObject(rawGroup)
- if (!group || !Array.isArray(group.hooks)) continue
- const commands: MatcherGroup['hooks'] = []
- for (const rawHook of group.hooks) {
- const hook = asObject(rawHook)
- if (!hook) continue
- const type = typeof hook.type === 'string' ? hook.type : 'command'
- if (type !== 'command') {
- skipped.push({ event, type })
- continue
- }
- if (typeof hook.command !== 'string') continue
- commands.push({
- command: substituteCommand(hook.command, vars),
- ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
- })
- }
- if (commands.length === 0) continue
- const matcher = event === 'UserPromptSubmit' || event === 'Stop'
- ? undefined
- : typeof group.matcher === 'string' ? group.matcher : undefined
- const diagnostic = matcherDiagnostic(matcher, 'claude-code')
- if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
- groups.push({
- ...matcher !== undefined ? { matcher } : {},
- hooks: commands,
- })
- }
- if (groups.length > 0) config[event] = groups
- }
- return { config, skipped }
- }
|