返回源码地图

packages/experimental/claude-code-mods/src/module.ts

main snapshot · da00f7f5358f · 正文引用章节 12;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Mod registration: run a mod's `register(on, options)` and keep every
3 * `on(...)` registration in one ordered registry that dispatch selects from.
4 * @module
5 */
6
7import type { LoadedMod, RegisteredHook } from './chain.ts'
8import { messageOf } from './values.ts'
9import { describeMatcher, ENGINE_EVENTS, eventMatches, isEventPattern } from './matcher.ts'
10import type { AnyHook, HookMatcher, ModDefinition, ModOn, HookRegistration } from './types.ts'
11
12/** Plugin names Claude Code accepts: letters, digits, `_` and `-`. */
13const PLUGIN_NAME = /^[A-Za-z0-9_-]{1,64}$/u
14
15/** Mods ordered by load; hooks ordered by mod, then by registration. */
16export class HookRegistry {
17 private readonly hooks: RegisteredHook[] = []
18 private readonly mods: LoadedMod[] = []
19
20 /**
21 * The loaded mods.
22 * @returns the mods in load order.
23 */
24 list(): readonly LoadedMod[] {
25 return this.mods
26 }
27
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 }
40
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) return
48 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 }
53
54 /**
55 * Hooks whose pattern selects `event`, outermost first. A mods API call a mod
56 * 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.hooks
63 .filter(hook => (raisedBy === undefined || hook.mod.order < raisedBy.order) && eventMatches(hook.event, event))
64 .sort((left, right) => left.mod.order - right.mod.order)
65 }
66
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)) continue
77 names.push(hook.event)
78 }
79 return names
80 }
81
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.hooks
89 .filter(hook => hook.mod === mod)
90 .map(hook => `${hook.event}${describeMatcher(hook.matcher)}`)
91 .join(', ')
92 }
93}
94
95function isMatcher(value: unknown): value is HookMatcher {
96 return typeof value === 'object' && value !== null && !Array.isArray(value)
97}
98
99/**
100 * Build the `on` function one `register` call receives and collect its
101 * registrations. Validation follows Claude Code: the event name must be a
102 * known name or glob, and one event may be registered without a matcher only
103 * once.
104 * @param mod - the mod registering.
105 * @returns `on` and the list it appends to.
106 */
107export 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 ?? matcherOrHook
114 const matcher = maybeHook === undefined ? undefined : matcherOrHook
115 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 = handler
129 },
130 }
131 }) as ModOn
132 return { on, hooks }
133}
134
135/**
136 * Run one mod's `register` and collect its hooks. A `register` that throws
137 * fails the mod with Claude Code's wording; the caller decides whether the
138 * 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 */
143export 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}