1
/**2
* Mod registration: run a mod's `register(on, options)` and keep every3
* `on(...)` registration in one ordered registry that dispatch selects from.4
* @module5
*/7
import type { LoadedMod, RegisteredHook } from './chain.ts'8
import { messageOf } from './values.ts'9
import { describeMatcher, ENGINE_EVENTS, eventMatches, isEventPattern } from './matcher.ts'10
import type { AnyHook, HookMatcher, ModDefinition, ModOn, HookRegistration } from './types.ts'12
/** Plugin names Claude Code accepts: letters, digits, `_` and `-`. */13
const PLUGIN_NAME = /^[A-Za-z0-9_-]{1,64}$/u15
/** Mods ordered by load; hooks ordered by mod, then by registration. */16
export class HookRegistry {17
private readonly hooks: RegisteredHook[] = []18
private readonly mods: LoadedMod[] = []20
/**21
* The loaded mods.22
* @returns the mods in load order.23
*/24
list(): readonly LoadedMod[] {25
return this.mods26
}28
/**29
* Add one mod and its registrations; a mod of the same name must be removed first.30
* @param mod - the mod the hooks belong to.31
* @param hooks - its registrations in `on` order.32
*/33
add(mod: LoadedMod, hooks: readonly RegisteredHook[]): void {34
if (this.mods.some(loaded => loaded.name === mod.name)) {35
throw new Error(`hooks module ${mod.name} not loaded: another plugin of that name loads first`)36
}37
this.mods.push(mod)38
this.hooks.push(...hooks)39
}41
/**42
* Drop one mod and its hooks.43
* @param name - the plugin name.44
*/45
remove(name: string): void {46
const index = this.mods.findIndex(mod => mod.name === name)47
if (index === -1) return48
const [mod] = this.mods.splice(index, 1)49
for (let i = this.hooks.length - 1; i >= 0; i -= 1) {50
if (this.hooks[i]?.mod === mod) this.hooks.splice(i, 1)51
}52
}54
/**55
* Hooks whose pattern selects `event`, outermost first. A mods API call a mod56
* raised is seen only by the mods loaded before it.57
* @param event - the event name.58
* @param raisedBy - the mod whose `$` call became the event, or undefined for the engine.59
* @returns the ordered hooks; matchers are evaluated at run time.60
*/61
select(event: string, raisedBy?: LoadedMod): RegisteredHook[] {62
return this.hooks63
.filter(hook => (raisedBy === undefined || hook.mod.order < raisedBy.order) && eventMatches(hook.event, event))64
.sort((left, right) => left.mod.order - right.mod.order)65
}67
/**68
* The engine events one mod hooks by exact name that the host never raises.69
* @param mod - the loaded mod.70
* @param served - the engine events the host raises.71
* @returns the unserved event names in registration order, each once.72
*/73
unserved(mod: LoadedMod, served: ReadonlySet<string>): string[] {74
const names: string[] = []75
for (const hook of this.hooks) {76
if (hook.mod !== mod || !ENGINE_EVENTS.has(hook.event) || served.has(hook.event) || names.includes(hook.event)) continue77
names.push(hook.event)78
}79
return names80
}82
/**83
* Event names with matchers, as `claude plugin validate` prints the `hooks:` line.84
* @param mod - the loaded mod.85
* @returns its events with matchers, comma-separated.86
*/87
describe(mod: LoadedMod): string {88
return this.hooks89
.filter(hook => hook.mod === mod)90
.map(hook => `${hook.event}${describeMatcher(hook.matcher)}`)91
.join(', ')92
}93
}95
function isMatcher(value: unknown): value is HookMatcher {96
return typeof value === 'object' && value !== null && !Array.isArray(value)97
}99
/**100
* Build the `on` function one `register` call receives and collect its101
* registrations. Validation follows Claude Code: the event name must be a102
* known name or glob, and one event may be registered without a matcher only103
* once.104
* @param mod - the mod registering.105
* @returns `on` and the list it appends to.106
*/107
export function createOn(mod: LoadedMod): { on: ModOn; hooks: RegisteredHook[] } {108
const hooks: RegisteredHook[] = []109
const unmatched = new Set<string>()110
const on = ((event: unknown, matcherOrHook: unknown, maybeHook?: unknown): HookRegistration => {111
if (typeof event !== 'string') throw new TypeError(`${mod.name}: the event name passed to on() is not a string literal`)112
if (!isEventPattern(event)) throw new Error(`${mod.name}: "${event}" is not an event`)113
const hook = maybeHook ?? matcherOrHook114
const matcher = maybeHook === undefined ? undefined : matcherOrHook115
if (typeof hook !== 'function') throw new TypeError(`${mod.name}: on("${event}") needs a hook function`)116
if (matcher !== undefined && !isMatcher(matcher)) throw new TypeError(`${mod.name}: on("${event}") matcher must be an object`)117
if (matcher === undefined) {118
if (unmatched.has(event)) throw new Error(`${mod.name}: on("${event}") is registered twice without a matcher`)119
unmatched.add(event)120
}121
const registered: RegisteredHook = {122
mod, event, matcher, hook: hook as AnyHook, catchHandler: undefined, reported: new Set(),123
}124
hooks.push(registered)125
return {126
catch(handler: AnyHook): void {127
if (typeof handler !== 'function') throw new TypeError(`${mod.name}: on("${event}").catch needs a handler function`)128
registered.catchHandler = handler129
},130
}131
}) as ModOn132
return { on, hooks }133
}135
/**136
* Run one mod's `register` and collect its hooks. A `register` that throws137
* fails the mod with Claude Code's wording; the caller decides whether the138
* session continues without it.139
* @param definition - the mod as its plugin defined it.140
* @param order - the mod's position in the chain; earlier mods run outside later ones.141
* @returns the loaded mod and its registrations.142
*/143
export async function registerMod(definition: ModDefinition, order: number): Promise<{ mod: LoadedMod; hooks: RegisteredHook[] }> {144
if (!PLUGIN_NAME.test(definition.name)) {145
throw new Error(`mod "${definition.name}" not loaded: a plugin name uses letters, digits, _ and - only`)146
}147
const mod: LoadedMod = Object.freeze({148
name: definition.name,149
version: definition.version,150
root: definition.root ?? process.cwd(),151
options: Object.freeze({ ...definition.options }),152
order,153
})154
const { on, hooks } = createOn(mod)155
try {156
await definition.register(on, mod.options)157
} catch (error: unknown) {158
throw new Error(`${definition.name}: hooks module did not load: register threw ${messageOf(error)}`, { cause: error })159
}160
return { mod, hooks }161
}