config.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. /**
  2. * Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
  3. * Only command hooks run; other hook types are returned as skipped so the
  4. * bridge can warn. Plugin-root and project-directory substitutions are applied
  5. * to commands at parse time.
  6. * @module @deepseek-ai/dsh-hooks-claude-code/config
  7. */
  8. import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
  9. const CLAUDE_EVENTS = [
  10. 'SessionStart',
  11. 'UserPromptSubmit',
  12. 'PreToolUse',
  13. 'PostToolUse',
  14. 'Stop',
  15. 'SubagentStart',
  16. 'SubagentStop',
  17. ] as const
  18. /** A parsed CC config: event name → its matcher groups (command hooks only). */
  19. export type ClaudeCodeHookConfig = Record<string, MatcherGroup[]>
  20. /** A skipped non-command hook, surfaced so the bridge can warn about it. */
  21. export interface SkippedHook {
  22. event: string
  23. type: string
  24. }
  25. /** The outcome of parsing one config file: the runnable groups + what was skipped. */
  26. export interface ParsedClaudeConfig {
  27. config: ClaudeCodeHookConfig
  28. skipped: SkippedHook[]
  29. }
  30. /** Substitution variables applied to each `command` string at parse time. */
  31. export interface SubstitutionVars {
  32. /** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */
  33. pluginRoot?: string
  34. /** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */
  35. projectDir?: string
  36. }
  37. /** A plain (non-null, non-array) object, else undefined. */
  38. function asObject(value: unknown): Record<string, unknown> | undefined {
  39. return typeof value === 'object' && value !== null && !Array.isArray(value)
  40. ? value as Record<string, unknown>
  41. : undefined
  42. }
  43. /**
  44. * Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
  45. * @param command - the raw command from config.
  46. * @param vars - the substitution values; a token whose variable is unset stays verbatim.
  47. * @returns the command with every occurrence of each set token replaced.
  48. */
  49. export function substituteCommand(command: string, vars: SubstitutionVars): string {
  50. let out = command
  51. if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
  52. if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
  53. return out
  54. }
  55. /**
  56. * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
  57. * ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
  58. * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
  59. * command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
  60. * matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
  61. * `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
  62. *
  63. * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
  64. * event map.
  65. * @param vars - substitution values applied to every surviving `command` (defaults to
  66. * none).
  67. * @returns the runnable per-event groups plus the skipped non-command hooks.
  68. */
  69. export function parseClaudeCodeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
  70. const config: ClaudeCodeHookConfig = {}
  71. const skipped: SkippedHook[] = []
  72. // Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
  73. const root = asObject(raw)
  74. const hooksMap = root ? asObject(root.hooks) ?? root : undefined
  75. if (!hooksMap) return { config, skipped }
  76. for (const event of CLAUDE_EVENTS) {
  77. const rawGroups = hooksMap[event]
  78. if (!Array.isArray(rawGroups)) continue
  79. const groups: MatcherGroup[] = []
  80. for (const rawGroup of rawGroups) {
  81. const group = asObject(rawGroup)
  82. if (!group || !Array.isArray(group.hooks)) continue
  83. const commands: MatcherGroup['hooks'] = []
  84. for (const rawHook of group.hooks) {
  85. const hook = asObject(rawHook)
  86. if (!hook) continue
  87. const type = typeof hook.type === 'string' ? hook.type : 'command'
  88. if (type !== 'command') {
  89. skipped.push({ event, type })
  90. continue
  91. }
  92. if (typeof hook.command !== 'string') continue
  93. commands.push({
  94. command: substituteCommand(hook.command, vars),
  95. ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
  96. })
  97. }
  98. if (commands.length === 0) continue
  99. const matcher = event === 'UserPromptSubmit' || event === 'Stop'
  100. ? undefined
  101. : typeof group.matcher === 'string' ? group.matcher : undefined
  102. const diagnostic = matcherDiagnostic(matcher, 'claude-code')
  103. if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
  104. groups.push({
  105. ...matcher !== undefined ? { matcher } : {},
  106. hooks: commands,
  107. })
  108. }
  109. if (groups.length > 0) config[event] = groups
  110. }
  111. return { config, skipped }
  112. }