@raidiant/notifai 10.1.0 → 10.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -1,1501 +0,0 @@
1
- import {
2
- existsSync,
3
- lstatSync,
4
- readFileSync,
5
- readdirSync,
6
- realpathSync,
7
- rmdirSync,
8
- rmSync,
9
- statSync,
10
- } from 'node:fs'
11
- import { createHash } from 'node:crypto'
12
- import path from 'node:path'
13
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
14
- import { atomicWriteFileSync } from './atomic-file.js'
15
- import { withTargetFileLock } from './file-lock.js'
16
- import {
17
- hookAdapterPath,
18
- hookHostPlatform,
19
- inspectHookAdapter,
20
- type HookHostPlatform,
21
- } from './hook-adapter.js'
22
- import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
23
- import {
24
- OPENCLAW_EVENTS,
25
- openclawHasGlobalEvidence,
26
- openclawPluginPath,
27
- openclawPluginTarget,
28
- } from './openclaw-plugin.js'
29
- import { HOOK_INSTALLABLE_HARNESSES, type HookInstallableHarness } from './harnesses.js'
30
- import { accountHome } from './platform.js'
31
- import {
32
- NON_ROUTING_STOP_TIMEOUT_SECONDS,
33
- QUESTION_STOP_TIMEOUT_SECONDS,
34
- } from './question-timing.js'
35
-
36
- /**
37
- * The three joints the OpenCode plugin hooks into, as (harness event, the
38
- * `notifai hook` event it runs). The plugin is a module and has no settings
39
- * entries to read, so `findInstallations` reconstructs its handlers from this.
40
- */
41
- const OPENCODE_EVENTS = [
42
- ['UserPromptSubmit', 'user-prompt-submit'],
43
- ['Stop', 'stop'],
44
- ['SessionEnd', 'session-end'],
45
- ] as const
46
-
47
- /**
48
- * Harness hook installation.
49
- *
50
- * Claude Code's `settings.json` and Codex's hook files use the same shape —
51
- * `hooks` maps an event name to matcher groups, each holding command handlers
52
- * with an optional timeout — so one generator serves both. Only the file
53
- * location and the event set differ. Codex supports either a dedicated
54
- * `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
55
- * We default a hook-empty layer to inline config and preserve whichever single
56
- * representation a populated layer already uses. Notifai lives in one of them,
57
- * never both.
58
- *
59
- * Cursor's native format is flat and lower-camel-cased, while OpenCode's
60
- * extension point is a JavaScript plugin module. Each therefore has a bounded
61
- * adapter instead of being forced through this shared document shape.
62
- */
63
-
64
- export interface HookHandler {
65
- type: 'command'
66
- command: string
67
- timeout?: number
68
- async?: boolean
69
- statusMessage?: string
70
- }
71
-
72
- export interface HookGroup {
73
- matcher?: string
74
- hooks: HookHandler[]
75
- }
76
-
77
- export type HookConfig = Record<string, HookGroup[]>
78
-
79
- export interface InstallPlan {
80
- harness: HookInstallableHarness
81
- /** Absolute path of the settings file that will be written. */
82
- file: string
83
- /** True when the file is machine-wide rather than per-project. */
84
- global: boolean
85
- config: HookConfig
86
- }
87
-
88
- export interface HookCommandOptions {
89
- platform?: NodeJS.Platform | HookHostPlatform
90
- /** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
91
- nodePath?: string
92
- }
93
-
94
- /**
95
- * The command each hook runs. Harness definitions know only the stable
96
- * user-level adapter. Mutable Node, package-manager, version, and checkout
97
- * paths live behind that seam and never enter a trusted hook identity —
98
- * except on Windows, where CreateProcess cannot run the adapter without an
99
- * explicit Node executable, so the registered interpreter is named first.
100
- */
101
- export function hookCommand(
102
- adapterPath: string,
103
- event: string,
104
- harness?: HookInstallableHarness,
105
- options: HookCommandOptions = {},
106
- ): string {
107
- return (
108
- `${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
109
- (harness === undefined ? '' : ` --harness ${harness}`)
110
- )
111
- }
112
-
113
- /** The leading argv that every Notifai handler must start with. */
114
- export function hookCommandPrefix(
115
- adapterPath: string,
116
- options: HookCommandOptions = {},
117
- ): string {
118
- const host = hookHostPlatform(options.platform)
119
- if (host === 'win32') {
120
- const nodePath = options.nodePath ?? process.execPath
121
- return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `
122
- }
123
- return `${quote(adapterPath)} `
124
- }
125
-
126
- /**
127
- * Says "Notifai installed this" without saying which checkout did.
128
- *
129
- * Ownership used to be matched on the absolute script path, so installing from
130
- * a second checkout did not recognise the first one's handlers as ours. Both
131
- * stayed, the harness ran both, and one question produced two notifications;
132
- * uninstalling the second silently left the first running. A marker that every
133
- * Notifai build writes and no build's path appears in fixes that by making
134
- * ownership a property of the handler rather than of the machine it was
135
- * installed from.
136
- *
137
- * It is a real (ignored) CLI flag rather than a comment because not every
138
- * harness runs the command through a shell.
139
- */
140
- export const OWNER_MARKER = '--owner notifai'
141
-
142
- /**
143
- * POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
144
- * and backticks, so a checkout path containing shell syntax became command
145
- * execution on every hook event. Single quotes suppress all expansion; the
146
- * only escape needed is for a literal single quote.
147
- */
148
- function quote(value: string): string {
149
- return `'${value.replaceAll("'", `'\\''`)}'`
150
- }
151
-
152
- /**
153
- * Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
154
- * it as a single argument. Always used for the Node and adapter paths, which
155
- * routinely contain spaces (`Program Files`) and must not use POSIX quotes
156
- * inside JSON or TOML harness documents.
157
- */
158
- export function quoteWindowsArg(value: string): string {
159
- let out = '"'
160
- let slashes = 0
161
- for (const ch of value) {
162
- if (ch === '\\') {
163
- slashes += 1
164
- continue
165
- }
166
- if (ch === '"') {
167
- out += '\\'.repeat(slashes * 2 + 1) + '"'
168
- slashes = 0
169
- continue
170
- }
171
- out += '\\'.repeat(slashes) + ch
172
- slashes = 0
173
- }
174
- return `${out}${'\\'.repeat(slashes * 2)}"`
175
- }
176
-
177
- export interface BuildOptions {
178
- /** Stable user-level executable installed by the hook adapter module. */
179
- adapterPath: string
180
- /** The installed adapter stamps its exact harness into project pointers. */
181
- harness?: HookInstallableHarness
182
- platform?: NodeJS.Platform | HookHostPlatform
183
- /** Registered Node named first in Windows hook commands. */
184
- nodePath?: string
185
- }
186
-
187
- /**
188
- * Harnesses without asynchronous Question Routing use this short Stop budget.
189
- * Claude Code and Codex use `QUESTION_STOP_TIMEOUT_SECONDS` instead.
190
- */
191
- export const NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS = NON_ROUTING_STOP_TIMEOUT_SECONDS
192
- export { QUESTION_STOP_TIMEOUT_SECONDS } from './question-timing.js'
193
-
194
- /**
195
- * Whether this harness's Stop handler runs detached from the turn.
196
- *
197
- * The installer and doctor share this predicate so they agree whether Claude
198
- * runs out of band. Owner lifetime is route-neutral and lives in
199
- * `question-timing.ts`.
200
- */
201
- export function stopHandlerIsDetached(
202
- harness: HookInstallableHarness | undefined,
203
- platform?: Parameters<typeof hookHostPlatform>[0],
204
- ): boolean {
205
- return harness === 'claude-code' && hookHostPlatform(platform) === 'posix'
206
- }
207
-
208
- /**
209
- * The turn-end handler, whose shape is the whole per-harness difference.
210
- *
211
- * Claude Code takes the answer over its own inbox socket, so its Stop hook is
212
- * `async: true`: it returns immediately, the terminal is never held, and the
213
- * waiter finishes out of band. Codex and Claude on Windows block and print a
214
- * continuation to stdout. Every Question Routing owner declares the same
215
- * complete-window timeout; changing Codex's definition requires one explicit
216
- * trust approval.
217
- */
218
- function stopHandler(
219
- adapterPath: string,
220
- harness: HookInstallableHarness | undefined,
221
- options: HookCommandOptions,
222
- ): HookHandler {
223
- const command = hookCommand(adapterPath, 'stop', harness, options)
224
- if (stopHandlerIsDetached(harness, options.platform)) {
225
- return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS, async: true }
226
- }
227
- if (harness === 'codex' || harness === 'claude-code') {
228
- return { type: 'command', command, timeout: QUESTION_STOP_TIMEOUT_SECONDS }
229
- }
230
- return { type: 'command', command, timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS }
231
- }
232
-
233
- function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
234
- return {
235
- ...(options.platform === undefined ? {} : { platform: options.platform }),
236
- ...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
237
- }
238
- }
239
-
240
- export function buildHookConfig(options: BuildOptions): HookConfig {
241
- const { adapterPath } = options
242
- const commandOptions = commandOptionsFrom(options)
243
- return {
244
- SessionStart: [
245
- {
246
- hooks: [
247
- {
248
- type: 'command',
249
- command: hookCommand(adapterPath, 'session-start', options.harness, commandOptions),
250
- // Activation is local context only: no config, credentials, or network.
251
- timeout: 5,
252
- },
253
- ],
254
- },
255
- ],
256
- SubagentStart: [
257
- {
258
- hooks: [
259
- {
260
- type: 'command',
261
- command: hookCommand(adapterPath, 'subagent-start', options.harness, commandOptions),
262
- timeout: 5,
263
- },
264
- ],
265
- },
266
- ],
267
- UserPromptSubmit: [
268
- {
269
- hooks: [
270
- {
271
- type: 'command',
272
- command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
273
- // Claude Code defaults UserPromptSubmit to 30s. Choose a shorter
274
- // explicit budget so a slow network cannot delay the user's prompt.
275
- timeout: 15,
276
- },
277
- ],
278
- },
279
- ],
280
- Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
281
- SessionEnd: [
282
- {
283
- hooks: [
284
- {
285
- type: 'command',
286
- command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
287
- // Both harnesses give SessionEnd a ~1-3s budget, so this handler
288
- // only touches local state.
289
- timeout: 3,
290
- },
291
- ],
292
- },
293
- ],
294
- }
295
- }
296
-
297
- export interface CursorHookHandler {
298
- command: string
299
- timeout?: number
300
- loop_limit?: number | null
301
- }
302
-
303
- export type CursorHookConfig = Record<string, CursorHookHandler[]>
304
-
305
- interface CursorSettingsDocument {
306
- version?: number
307
- hooks?: CursorHookConfig
308
- [key: string]: unknown
309
- }
310
-
311
- /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
312
- export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
313
- const commandOptions = commandOptionsFrom(options)
314
- return {
315
- sessionStart: [
316
- {
317
- command: hookCommand(options.adapterPath, 'session-start', 'cursor', commandOptions),
318
- // Activation only emits local context and must not depend on setup.
319
- timeout: 5,
320
- },
321
- ],
322
- beforeSubmitPrompt: [
323
- {
324
- command: hookCommand(
325
- options.adapterPath,
326
- 'user-prompt-submit',
327
- 'cursor',
328
- commandOptions,
329
- ),
330
- timeout: 15,
331
- },
332
- ],
333
- stop: [
334
- {
335
- command: hookCommand(options.adapterPath, 'activation-stop', 'cursor', commandOptions),
336
- timeout: 5,
337
- loop_limit: 1,
338
- },
339
- {
340
- command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
341
- timeout: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
342
- // A continuation may register a real follow-up question. Match the
343
- // session-state cap so those chains are useful but never unbounded.
344
- loop_limit: 3,
345
- },
346
- ],
347
- sessionEnd: [
348
- {
349
- command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
350
- timeout: 3,
351
- },
352
- ],
353
- }
354
- }
355
-
356
- /**
357
- * Where each harness reads hooks from. Project installs deliberately target the
358
- * gitignored file where one exists: which devices a person wants their
359
- * questions pushed to is not a property of the repository.
360
- */
361
- export function settingsFile(
362
- harness: HookInstallableHarness,
363
- global: boolean,
364
- cwd: string,
365
- env: NodeJS.ProcessEnv = process.env,
366
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
367
- ): string {
368
- switch (harness) {
369
- // OpenCode has no settings document to merge into — its adapter is a
370
- // generated plugin module, so it owns a whole file instead.
371
- case 'opencode':
372
- return opencodePluginPath(global, cwd, env, platform)
373
- case 'openclaw':
374
- return openclawPluginPath(global, cwd, env, platform)
375
- case 'cursor': {
376
- const home = harnessAccountHome(env, platform)
377
- return global
378
- ? path.join(home, '.cursor', 'hooks.json')
379
- : path.join(cwd, '.cursor', 'hooks.json')
380
- }
381
- case 'claude-code':
382
- return global
383
- ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
384
- : path.join(cwd, '.claude', 'settings.local.json')
385
- case 'codex':
386
- return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget
387
- default:
388
- return assertNeverHarness(harness)
389
- }
390
- }
391
-
392
- /**
393
- * The account home harness config roots are resolved against.
394
- *
395
- * Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
396
- * Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
397
- */
398
- export function harnessAccountHome(
399
- env: NodeJS.ProcessEnv = process.env,
400
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
401
- ): string {
402
- return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux')
403
- }
404
-
405
- function assertNeverHarness(harness: never): never {
406
- throw new Error(`Unsupported harness: ${String(harness)}`)
407
- }
408
-
409
- /**
410
- * Where Codex looks for a project's hooks — the **main** repository, not the
411
- * working directory.
412
- *
413
- * Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
414
- * (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
415
- * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
416
- * fired and an identical one at the worktree root did not; removing the main
417
- * one left nothing firing at all. Writing to cwd therefore produces a silent
418
- * no-op for anyone whose agent runs in a worktree, which is the normal case
419
- * under tooling like Orca.
420
- *
421
- * Claude Code does not share this behaviour — it reads the worktree's own
422
- * `.claude/settings.local.json` — so this deliberately applies to Codex only.
423
- */
424
- export function codexProjectRoot(cwd: string): string {
425
- const entry = findGitEntry(cwd)
426
- if (entry === null) return cwd
427
- try {
428
- if (statSync(entry).isDirectory()) return path.dirname(entry)
429
- const linked = worktreeGitDir(entry)
430
- return linked === null ? cwd : path.dirname(commonGitDir(linked))
431
- } catch {
432
- // An unreadable or exotic checkout is not worth failing an install over;
433
- // cwd is what we used to do and is right for every non-worktree case.
434
- return cwd
435
- }
436
- }
437
-
438
- /**
439
- * The `.codex` directory that has to exist for Codex to load project hooks at
440
- * all, or null when nothing extra is needed.
441
- *
442
- * Codex splits the two halves of "which project am I in": it discovers the
443
- * project layer by walking up from cwd for a `.codex` directory, but resolves
444
- * the hook file inside that layer against the main repository. In an ordinary
445
- * checkout both land on the same directory and the split is invisible. In a
446
- * worktree they diverge, and writing only the main repository's file leaves it
447
- * unread — Codex never looks, because nothing at or above cwd told it there was
448
- * a project layer to load. Proven 2026-08-03: with cwd in a worktree, an *empty*
449
- * `.codex` directory there was the difference between the main repository's
450
- * handler running and nothing running.
451
- */
452
- export function codexLayerDir(cwd: string): string | null {
453
- const entry = findGitEntry(cwd)
454
- if (entry === null) return null
455
- const worktreeRoot = path.dirname(entry)
456
- if (path.resolve(worktreeRoot) === path.resolve(codexProjectRoot(cwd))) return null
457
- return path.join(worktreeRoot, '.codex')
458
- }
459
-
460
- /** The nearest `.git` at or above `from`, or null outside a repository. */
461
- function findGitEntry(from: string): string | null {
462
- let dir = path.resolve(from)
463
- for (;;) {
464
- const entry = path.join(dir, '.git')
465
- if (existsSync(entry)) return entry
466
- const parent = path.dirname(dir)
467
- if (parent === dir) return null
468
- dir = parent
469
- }
470
- }
471
-
472
- /** `.git` in a linked worktree is a file naming that worktree's git directory. */
473
- function worktreeGitDir(file: string): string | null {
474
- const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(file, 'utf8'))
475
- if (match === null) return null
476
- return path.resolve(path.dirname(file), match[1]!.trim())
477
- }
478
-
479
- /**
480
- * A worktree's git directory records the shared one in `commondir`; that shared
481
- * directory is the main repository's `.git`, whose parent is its root.
482
- */
483
- function commonGitDir(gitDir: string): string {
484
- const marker = path.join(gitDir, 'commondir')
485
- if (!existsSync(marker)) return gitDir
486
- return path.resolve(gitDir, readFileSync(marker, 'utf8').trim())
487
- }
488
-
489
- /**
490
- * Harness-specific home variables relocate the whole active harness home.
491
- * This matters under session managers that give each Codex account its own
492
- * `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
493
- * different Codex installation than the one running the command.
494
- */
495
- export function configHome(
496
- env: NodeJS.ProcessEnv,
497
- variable: string,
498
- fallback: string,
499
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
500
- ): string {
501
- const override = env[variable]
502
- if (override !== undefined && override !== '') return override
503
- const home = harnessAccountHome(env, platform)
504
- return path.join(home, fallback)
505
- }
506
-
507
- /**
508
- * User-global Codex config directory for the *running* Codex installation.
509
- *
510
- * `CODEX_HOME` replaces the home; it does not shadow it. `codex doctor` with
511
- * the variable set reports that directory for every path it resolves and never
512
- * consults `~/.codex` at all. So a session manager that points Codex at its own
513
- * home makes `~/.codex` inert for those sessions, and installing there would
514
- * write hooks the running agent never reads while reporting success.
515
- *
516
- * Following the variable is therefore correct even though it means install and
517
- * doctor resolve different homes when run from different shells. Doctor names
518
- * the home it inspected for exactly that reason — see `codexHomeNote`.
519
- */
520
- export function codexGlobalDir(
521
- env: NodeJS.ProcessEnv = process.env,
522
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
523
- ): string {
524
- return configHome(env, 'CODEX_HOME', '.codex', platform)
525
- }
526
-
527
- export interface CodexLayerPaths {
528
- dir: string
529
- hooksJson: string
530
- configToml: string
531
- }
532
-
533
- /** The two files Codex will look at for one config layer. */
534
- export function codexLayerPaths(
535
- global: boolean,
536
- cwd: string,
537
- env: NodeJS.ProcessEnv = process.env,
538
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
539
- ): CodexLayerPaths {
540
- const dir = global
541
- ? codexGlobalDir(env, platform)
542
- : path.join(codexProjectRoot(cwd), '.codex')
543
- return {
544
- dir,
545
- hooksJson: path.join(dir, 'hooks.json'),
546
- configToml: path.join(dir, 'config.toml'),
547
- }
548
- }
549
-
550
- export interface CodexLayerInspection {
551
- paths: CodexLayerPaths
552
- /** Every event this file defines a handler for, whoever owns it. */
553
- jsonEvents: string[]
554
- tomlEvents: string[]
555
- /** The subset of those events whose handlers are Notifai's own. */
556
- ourJsonEvents: string[]
557
- ourTomlEvents: string[]
558
- writeTarget: string
559
- }
560
-
561
- /**
562
- * Where Notifai writes in this Codex layer: wherever Notifai already is, then
563
- * whichever single hook representation the layer already uses, and inline
564
- * `config.toml` for a hook-empty or already-mixed layer.
565
- *
566
- * Codex supports `hooks.json` and inline `[hooks]` side by side, loads both,
567
- * and runs every matching handler. Reusing the one populated representation is
568
- * the generic layer contract: it avoids creating Codex's dual-representation
569
- * warning and, for a managed layer, writes through the same authority that
570
- * persists the layer rather than a sibling file it may regenerate away.
571
- *
572
- * Staying put where Notifai already is also keeps reinstalls idempotent and
573
- * preserves the `[hooks.state]` trust hash, which Codex keys by file path and
574
- * handler index. `[hooks.state]` is the trust store, not a hook definition, so
575
- * it does not count as a representation.
576
- */
577
- export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
578
- const jsonDocument = tryLoadSettings(paths.hooksJson)
579
- const tomlDocument = tryLoadSettings(paths.configToml)
580
- const jsonEvents = hookEventNames(jsonDocument?.hooks)
581
- const tomlEvents = hookEventNames(tomlDocument?.hooks)
582
- const ourJsonEvents = ourHandlerEvents(jsonDocument)
583
- const ourTomlEvents = ourHandlerEvents(tomlDocument)
584
- const writeTarget =
585
- ourJsonEvents.length > 0 && ourTomlEvents.length === 0
586
- ? paths.hooksJson
587
- : ourTomlEvents.length > 0
588
- ? paths.configToml
589
- : jsonEvents.length > 0 && tomlEvents.length === 0
590
- ? paths.hooksJson
591
- : paths.configToml
592
- return {
593
- paths,
594
- jsonEvents,
595
- tomlEvents,
596
- ourJsonEvents,
597
- ourTomlEvents,
598
- writeTarget,
599
- }
600
- }
601
-
602
- /**
603
- * Serialize one Notifai transaction across both representations in a Codex
604
- * layer, then inspect that layer only after acquiring the shared anchor.
605
- */
606
- export function withCodexLayerTransaction<T>(
607
- paths: CodexLayerPaths,
608
- action: (inspection: CodexLayerInspection) => T,
609
- ): T {
610
- return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)))
611
- }
612
-
613
- /** Every file in this layer Codex might already be reading hooks from. */
614
- export function hookDefinitionFiles(
615
- harness: HookInstallableHarness,
616
- global: boolean,
617
- cwd: string,
618
- env: NodeJS.ProcessEnv = process.env,
619
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
620
- ): string[] {
621
- if (harness !== 'codex') return [settingsFile(harness, global, cwd, env, platform)]
622
- const paths = codexLayerPaths(global, cwd, env, platform)
623
- return [paths.hooksJson, paths.configToml]
624
- }
625
-
626
- /**
627
- * Layers where *Notifai* is installed in both Codex representations.
628
- *
629
- * A layer that uses `hooks.json` and inline `[hooks]` at once is not by itself
630
- * a Notifai fault: Codex supports both, loads both, runs every matching
631
- * handler, and already prints its own "prefer a single representation" warning
632
- * at startup. Foreign handlers in the other file are the user's — often written
633
- * by another tool that manages that file — and repeating Codex's warning as a
634
- * Notifai failure demanded an edit to configuration Notifai does not own, for a
635
- * condition where every handler still fires exactly once.
636
- *
637
- * What is ours to diagnose is Notifai in both files. Then one event really does
638
- * send two Notification Requests per turn, and the copy outside the write
639
- * target keeps firing an older definition after the next install refreshes only
640
- * the other one. Uninstall strips Notifai from both files, so the remedy is
641
- * exact — and it never touches a foreign handler.
642
- */
643
- export function codexRepresentationProblems(
644
- cwd: string,
645
- env: NodeJS.ProcessEnv = process.env,
646
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
647
- ): string[] {
648
- return [false, true].flatMap((global) => {
649
- const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
650
- if (layer.ourJsonEvents.length === 0 || layer.ourTomlEvents.length === 0) return []
651
- const doubled = layer.ourJsonEvents.filter((event) => layer.ourTomlEvents.includes(event))
652
- const consequence =
653
- doubled.length > 0
654
- ? `Codex runs every matching handler, so this layer notifies twice per turn for ${doubled.join(', ')}`
655
- : `Codex runs both files, so this layer's Notifai handlers are split between them (${[...new Set([...layer.ourJsonEvents, ...layer.ourTomlEvents])].join(', ')}) and the next install refreshes only one file`
656
- const scope = global ? ' --global' : ''
657
- return [
658
- `Notifai hooks are installed in both ${layer.paths.hooksJson} and ${layer.paths.configToml}; ${consequence}. Run \`notifai hooks uninstall --harness codex${scope}\` and then \`notifai hooks install --harness codex${scope}\` to leave exactly one copy; foreign hooks in either file are left alone.`,
659
- ]
660
- })
661
- }
662
-
663
- /**
664
- * Layers where Notifai and someone else each own one Codex representation.
665
- *
666
- * This is not a fault and never fails a check. Codex loads `hooks.json` and
667
- * inline `[hooks]` together and runs every matching handler, so both sets fire
668
- * exactly once — but Codex also prints "prefer one representation per layer"
669
- * at startup, and a user who sees that warning deserves to know which file is
670
- * Notifai's, which is not, and that Notifai will not touch the one it does not
671
- * own. Saying nothing here is what made the warning look like Notifai's bug.
672
- */
673
- export function codexCoexistenceNotes(
674
- cwd: string,
675
- env: NodeJS.ProcessEnv = process.env,
676
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
677
- ): string[] {
678
- return [false, true].flatMap((global) => {
679
- const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
680
- const inJson = layer.ourJsonEvents.length > 0
681
- const inToml = layer.ourTomlEvents.length > 0
682
- if (inJson === inToml) return []
683
- const ours = inJson ? layer.paths.hooksJson : layer.paths.configToml
684
- const theirs = inJson ? layer.paths.configToml : layer.paths.hooksJson
685
- const theirEvents = inJson ? layer.tomlEvents : layer.jsonEvents
686
- if (theirEvents.length === 0) return []
687
- const stop = theirEvents.includes('Stop')
688
- ? " That file's Stop handler can end a turn before Notifai's answer arrives, because Codex lets any Stop handler stop continuation."
689
- : ''
690
- return [
691
- `Notifai's Codex hooks are in ${ours}. ${theirs} defines hooks Notifai does not own (${theirEvents.join(', ')}) and Notifai will not modify it. Codex loads both files and runs every matching handler, so each set fires exactly once; its "prefer one representation per layer" startup warning is reporting that, not a Notifai fault.${stop}`,
692
- ]
693
- })
694
- }
695
-
696
- /**
697
- * What `CODEX_HOME` is doing to this shell, when it is doing anything.
698
- *
699
- * Codex replaces its whole home when the variable is set, so hooks installed
700
- * from a shell without it are simply absent here, and its trust store is keyed
701
- * by absolute file path — a config copied between homes arrives untrusted.
702
- * Both facts turn into "Notifai is not wired" reports that look like a bug in
703
- * the installer, so doctor names the home it actually inspected.
704
- */
705
- export function codexHomeNote(
706
- env: NodeJS.ProcessEnv = process.env,
707
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
708
- ): string | null {
709
- const effective = codexGlobalDir(env, platform)
710
- const accountDefault = path.join(harnessAccountHome(env, platform), '.codex')
711
- if (effective === accountDefault) return null
712
- return `CODEX_HOME points Codex at ${effective}, so ${accountDefault} is not read by Codex in this shell at all. Global hooks installed from a shell without CODEX_HOME live there and are invisible here; Codex also keys hook trust by absolute file path, so hooks copied between homes need approving again in each one.`
713
- }
714
-
715
- function tryLoadSettings(file: string): SettingsDocument | null {
716
- if (!existsSync(file)) return null
717
- try {
718
- return loadSettings(file)
719
- } catch {
720
- return null
721
- }
722
- }
723
-
724
- function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
725
- return Object.entries(hooks ?? {})
726
- .filter(([, value]) => Array.isArray(value))
727
- .map(([event]) => event)
728
- }
729
-
730
- /** Events this document defines a Notifai handler for, by their event key. */
731
- function ourHandlerEvents(document: SettingsDocument | null): string[] {
732
- if (document === null) return []
733
- return [...new Set(locateHandlers(document).map((handler) => handler.event))]
734
- }
735
-
736
- /** Best-effort detection so `hooks install` usually needs no flags. */
737
- /**
738
- * Harnesses this *project* shows evidence of, in the working directory only.
739
- *
740
- * `CLAUDE.md` counts because it is Claude Code's own project file and is
741
- * frequently the only marker: a repository can be worked in daily through
742
- * Claude Code and never accumulate a `.claude/` directory.
743
- *
744
- * `AGENTS.md` deliberately counts for nothing. It began as a Codex
745
- * convention and is now read by most agent tooling — including Claude Code,
746
- * and including this repository, where the two filenames are the same
747
- * document. Treating it as evidence of any one harness would be a guess
748
- * dressed up as detection.
749
- */
750
- function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
751
- const found: HookInstallableHarness[] = []
752
- if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
753
- found.push('claude-code')
754
- }
755
- if (existsSync(path.join(cwd, '.codex'))) found.push('codex')
756
- if (existsSync(path.join(cwd, '.cursor'))) found.push('cursor')
757
- if (existsSync(path.join(cwd, '.opencode'))) found.push('opencode')
758
- if (existsSync(path.join(cwd, '.openclaw'))) found.push('openclaw')
759
- return found
760
- }
761
-
762
- /** Harnesses installed anywhere on this machine — a much weaker signal. */
763
- function globalHarnessEvidence(
764
- env: NodeJS.ProcessEnv = process.env,
765
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
766
- ): HookInstallableHarness[] {
767
- const home = harnessAccountHome(env, platform)
768
- const found: HookInstallableHarness[] = []
769
- if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
770
- if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
771
- if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
772
- if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
773
- if (openclawHasGlobalEvidence(existsSync, env, platform)) found.push('openclaw')
774
- return found
775
- }
776
-
777
- /**
778
- * Every supported harness this project or machine shows evidence of, in
779
- * declared order. Project markers come first, then machine installs that the
780
- * project did not already name.
781
- *
782
- * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
783
- */
784
- export function detectedHarnesses(
785
- cwd: string,
786
- env: NodeJS.ProcessEnv = process.env,
787
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
788
- ): HookInstallableHarness[] {
789
- const seen = new Set<HookInstallableHarness>([
790
- ...localHarnessEvidence(cwd),
791
- ...globalHarnessEvidence(env, platform),
792
- ])
793
- return HOOK_INSTALLABLE_HARNESSES.filter((harness) => seen.has(harness))
794
- }
795
-
796
- /**
797
- * Which *single* harness to wire when a caller still needs exactly one, or
798
- * null when it is genuinely unclear.
799
- *
800
- * Project evidence decides, and machine evidence is consulted only when the
801
- * project offers none. Several markers are not a failure of detection — they
802
- * are several harnesses. `detectedHarnesses` is the default for install;
803
- * this helper stays for uninstall and any caller that cannot take a list.
804
- */
805
- export function detectHarness(
806
- cwd: string,
807
- env: NodeJS.ProcessEnv = process.env,
808
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
809
- ): HookInstallableHarness | null {
810
- const local = localHarnessEvidence(cwd)
811
- if (local.length > 0) return local.length === 1 ? local[0]! : null
812
- const global = globalHarnessEvidence(env, platform)
813
- return global.length === 1 ? global[0]! : null
814
- }
815
-
816
- export interface SettingsDocument {
817
- hooks?: HookConfig
818
- [key: string]: unknown
819
- }
820
-
821
- function readCursorSettings(file: string): CursorSettingsDocument {
822
- if (!existsSync(file)) return { version: 1 }
823
- const source = readOwnedRegularFile(file)
824
- try {
825
- const parsed: unknown = JSON.parse(source)
826
- return typeof parsed === 'object' && parsed !== null
827
- ? (parsed as CursorSettingsDocument)
828
- : { version: 1 }
829
- } catch {
830
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
831
- }
832
- }
833
-
834
- function isOurCommand(command: string, scriptPath: string): boolean {
835
- if (command.includes(OWNER_MARKER)) return true
836
- return (
837
- command.includes(`${scriptPath}' hook `) ||
838
- command.includes(`${scriptPath}" hook `) ||
839
- command.includes(`${scriptPath} hook `) ||
840
- isLegacyNotifaiCommand(command)
841
- )
842
- }
843
-
844
- /** Cleanup-only recognition for unmistakable pre-marker Notifai commands. */
845
- function isLegacyNotifaiCommand(command: string): boolean {
846
- const hook = `['"]?\\s+hook (?:session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\\b`
847
- return (
848
- new RegExp(`(?:^|[\\s'"])notifai(?:\\.cmd)?${hook}`).test(command) ||
849
- new RegExp(
850
- `(?:^|[/\\\\])(?:notifai-public|notifai)[/\\\\]apps[/\\\\]cli[/\\\\]dist[/\\\\]main\\.js${hook}`,
851
- 'i',
852
- ).test(command) ||
853
- new RegExp(
854
- `[/\\\\]node_modules[/\\\\]@raidiant[/\\\\]notifai[/\\\\]dist[/\\\\]main\\.js${hook}`,
855
- 'i',
856
- ).test(command)
857
- )
858
- }
859
-
860
- export function mergeCursorHooks(
861
- existing: CursorSettingsDocument,
862
- incoming: CursorHookConfig,
863
- scriptPath: string,
864
- ): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
865
- const hooks: CursorHookConfig = {}
866
- const added: string[] = []
867
- const replaced: string[] = []
868
- const removed: string[] = []
869
-
870
- for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
871
- const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
872
- if (foreign.length !== handlers.length) {
873
- if (event in incoming) replaced.push(event)
874
- else removed.push(event)
875
- }
876
- if (foreign.length > 0) hooks[event] = foreign
877
- }
878
- for (const [event, handlers] of Object.entries(incoming)) {
879
- if (!replaced.includes(event)) added.push(event)
880
- hooks[event] = [...(hooks[event] ?? []), ...handlers]
881
- }
882
- return { document: { ...existing, version: 1, hooks }, added, replaced, removed }
883
- }
884
-
885
- export function removeCursorHooks(
886
- existing: CursorSettingsDocument,
887
- scriptPath: string,
888
- ): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
889
- const hooks: CursorHookConfig = {}
890
- const replaced: string[] = []
891
- for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
892
- const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
893
- if (foreign.length !== handlers.length) replaced.push(event)
894
- if (foreign.length > 0) hooks[event] = foreign
895
- }
896
- return { document: { ...existing, version: 1, hooks }, added: [], replaced, removed: [] }
897
- }
898
-
899
- function readSettings(file: string): SettingsDocument {
900
- if (!existsSync(file)) return {}
901
- const source = readOwnedRegularFile(file)
902
- try {
903
- const parsed: unknown = JSON.parse(source)
904
- return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
905
- } catch {
906
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
907
- }
908
- }
909
-
910
- /**
911
- * True when a single handler is ours. Deliberately per-handler, not per-group:
912
- * removing a whole group destroyed a user's own handler if they had added one
913
- * beside ours inside the same matcher group.
914
- */
915
- function isOurHandler(handler: HookHandler, scriptPath: string): boolean {
916
- return isOurCommand(handler.command, scriptPath)
917
- }
918
-
919
- /** Drops only our handlers, keeping the group and anyone else's handlers. */
920
- function withoutOurs(groups: HookGroup[], scriptPath: string): { groups: HookGroup[]; removed: boolean } {
921
- let removed = false
922
- const kept: HookGroup[] = []
923
- for (const group of groups) {
924
- const hooks = group.hooks.filter((handler) => {
925
- const ours = isOurHandler(handler, scriptPath)
926
- if (ours) removed = true
927
- return !ours
928
- })
929
- if (hooks.length > 0) kept.push({ ...group, hooks })
930
- }
931
- return { groups: kept, removed }
932
- }
933
-
934
- export interface MergeResult {
935
- document: SettingsDocument
936
- added: string[]
937
- replaced: string[]
938
- /** Events an older build installed that this one no longer serves. */
939
- removed: string[]
940
- }
941
-
942
- /**
943
- * Adds our handlers without disturbing anyone else's. Re-running replaces only
944
- * the groups we previously wrote, so an upgrade that changes a timeout does not
945
- * accumulate duplicate hooks.
946
- *
947
- * Handlers are stripped from EVERY event, not just the ones being installed.
948
- * Only rewriting incoming events left a dropped event's handler in place for
949
- * ever, and since the binary no longer implements it, it exited 2 with
950
- * "Unknown hook event" every time the harness fired it — a permanent hook
951
- * failure that reinstalling could not clear.
952
- */
953
- export function mergeHooks(
954
- existing: SettingsDocument,
955
- incoming: HookConfig,
956
- scriptPath: string,
957
- ): MergeResult {
958
- const hooks: HookConfig = {}
959
- const added: string[] = []
960
- const replaced: string[] = []
961
- const removed: string[] = []
962
-
963
- for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
964
- if (!Array.isArray(groups)) {
965
- // Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
966
- ;(hooks as Record<string, unknown>)[event] = groups
967
- continue
968
- }
969
- const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
970
- if (hadOurs) {
971
- if (event in incoming) replaced.push(event)
972
- else removed.push(event)
973
- }
974
- if (foreign.length > 0) hooks[event] = foreign
975
- }
976
-
977
- for (const [event, groups] of Object.entries(incoming)) {
978
- if (!replaced.includes(event)) added.push(event)
979
- hooks[event] = [...(hooks[event] ?? []), ...groups]
980
- }
981
-
982
- return { document: { ...existing, hooks }, added, replaced, removed }
983
- }
984
-
985
- export function removeHooks(existing: SettingsDocument, scriptPath: string): MergeResult {
986
- const hooks: HookConfig = {}
987
- const replaced: string[] = []
988
- for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
989
- if (!Array.isArray(groups)) {
990
- ;(hooks as Record<string, unknown>)[event] = groups
991
- continue
992
- }
993
- const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
994
- if (removed) replaced.push(event)
995
- if (foreign.length > 0) hooks[event] = foreign
996
- }
997
- const remaining = Object.keys(hooks).length > 0 ? hooks : undefined
998
- const document = { ...existing }
999
- if (remaining === undefined) delete document.hooks
1000
- else document.hooks = remaining
1001
- return { document, added: [], replaced, removed: [] }
1002
- }
1003
-
1004
- /**
1005
- * Writes the settings file the way a tool that does not own it should.
1006
- *
1007
- * Truncating in place lost the user's whole harness configuration if the write
1008
- * failed halfway, and clobbered a concurrent editor's changes; following a
1009
- * symlink wrote through to a target they never named. So: refuse
1010
- * anything that is not a regular file, write a sibling temp file, fsync it, and
1011
- * rename over the original — atomic within a directory on every platform we
1012
- * support.
1013
- */
1014
- export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
1015
- const body = isTomlSettingsPath(file)
1016
- ? tomlBody(file, document as SettingsDocument)
1017
- : `${JSON.stringify(document, null, 2)}\n`
1018
- if (
1019
- (path.basename(file) === 'hooks.json' && isEmptyJsonDocument(body)) ||
1020
- (isTomlSettingsPath(file) && body.trim() === '')
1021
- ) {
1022
- if (existsSync(file)) rmSync(file, { force: true })
1023
- return
1024
- }
1025
- if (existsSync(file)) {
1026
- try {
1027
- if (readOwnedRegularFile(file) === body) return
1028
- } catch {
1029
- // A file we cannot re-read still needs the replacement below.
1030
- }
1031
- }
1032
- atomicWriteFileSync(file, body, {
1033
- requireCurrentUserOwner: true,
1034
- })
1035
- }
1036
-
1037
- function isEmptyJsonDocument(body: string): boolean {
1038
- try {
1039
- const parsed: unknown = JSON.parse(body)
1040
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
1041
- } catch {
1042
- return false
1043
- }
1044
- }
1045
-
1046
- /** Delete an emptied Codex hooks.json and a now-empty layer directory. */
1047
- export function cleanupEmptiedCodexLayer(paths: CodexLayerPaths): void {
1048
- if (existsSync(paths.hooksJson)) {
1049
- try {
1050
- const document = loadSettings(paths.hooksJson)
1051
- const keys = Object.keys(document)
1052
- const hooksEmpty = document.hooks === undefined || Object.keys(document.hooks).length === 0
1053
- if (keys.every((key) => key === 'hooks') && hooksEmpty) {
1054
- rmSync(paths.hooksJson, { force: true })
1055
- }
1056
- } catch {
1057
- // Leave a file uninstall could not parse; the caller already reported it.
1058
- }
1059
- }
1060
- if (!existsSync(paths.dir)) return
1061
- try {
1062
- if (readdirSync(paths.dir).length === 0) rmdirSync(paths.dir)
1063
- } catch {
1064
- // A concurrently replaced directory is not ours to fight.
1065
- }
1066
- }
1067
-
1068
- /**
1069
- * A TOML file rewritten around its `[hooks]` tables, not over them.
1070
- *
1071
- * `config.toml` is the user's, and for most of them it is hand-written: model
1072
- * choices, sandbox settings, MCP servers, and the comments explaining why. A
1073
- * parse-and-restringify write returns all of that as data and none of it as
1074
- * the document they wrote — comments gone, order normalized, spacing theirs no
1075
- * longer. Notifai adds three hook handlers; it has no business reflowing the
1076
- * rest.
1077
- *
1078
- * So everything outside `[hooks...]` is carried across as the exact bytes it
1079
- * came in as, and only the hooks tables are regenerated — re-emitted at the end
1080
- * of the file, because TOML does not care where a table sits and splicing into
1081
- * the middle would mean reasoning about a region line by line. If the result
1082
- * cannot be proven to parse back to the document asked for, this falls back to
1083
- * the whole-file rewrite rather than risk a file it half-understood.
1084
- */
1085
- function tomlBody(file: string, document: SettingsDocument): string {
1086
- const whole = `${stringifyToml(document)}\n`
1087
- if (!existsSync(file)) return whole
1088
- let previous: string
1089
- try {
1090
- previous = readOwnedRegularFile(file)
1091
- } catch {
1092
- return whole
1093
- }
1094
- return spliceTomlHooks(previous, document) ?? whole
1095
- }
1096
-
1097
- /** A TOML table header line, e.g. `[hooks.state."…"]` or `[[hooks.Stop]]`. */
1098
- const TOML_TABLE_HEADER = /^\s*\[\[?([^[\]]+)\]\]?\s*(?:#.*)?$/
1099
-
1100
- /**
1101
- * The first key in a dotted TOML key path, unquoting it if it is quoted.
1102
- * `hooks.state."/a/b:stop:0:0"` is rooted at `hooks`, and so is `hooks.Stop`.
1103
- */
1104
- function firstTomlKey(keyPath: string): string {
1105
- const trimmed = keyPath.trim()
1106
- const quote = trimmed[0]
1107
- if (quote === '"' || quote === "'") {
1108
- const end = trimmed.indexOf(quote, 1)
1109
- return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
1110
- }
1111
- const dot = trimmed.indexOf('.')
1112
- return (dot === -1 ? trimmed : trimmed.slice(0, dot)).trim()
1113
- }
1114
-
1115
- /**
1116
- * `source` with its hooks tables replaced by `document`'s, or null when that
1117
- * cannot be done safely — an inline top-level `hooks` key, or a result that
1118
- * does not parse back to exactly the document asked for.
1119
- */
1120
- function spliceTomlHooks(source: string, document: SettingsDocument): string | null {
1121
- const kept: string[] = []
1122
- let inHooks = false
1123
- for (const line of source.split('\n')) {
1124
- const header = TOML_TABLE_HEADER.exec(line)
1125
- if (header !== null) inHooks = firstTomlKey(header[1] ?? '') === 'hooks'
1126
- // A root-level `hooks = …` or `hooks.x = …` would survive the splice and
1127
- // then collide with the tables appended below.
1128
- if (!inHooks && /^\s*hooks\s*[.=]/.test(line)) return null
1129
- if (!inHooks) kept.push(line)
1130
- }
1131
-
1132
- const hooks = document.hooks
1133
- const body = hooks === undefined ? '' : stringifyToml({ hooks })
1134
- const head = kept.join('\n').trimEnd()
1135
- const spliced =
1136
- body === '' ? `${head}\n` : head === '' ? `${body}\n` : `${head}\n\n${body}\n`
1137
-
1138
- try {
1139
- if (!sameTomlValue(parseToml(spliced), document)) return null
1140
- } catch {
1141
- return null
1142
- }
1143
- return spliced
1144
- }
1145
-
1146
- /** Structural equality for parsed TOML, which carries dates as well as data. */
1147
- function sameTomlValue(a: unknown, b: unknown): boolean {
1148
- if (a === b) return true
1149
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
1150
- if (Array.isArray(a) || Array.isArray(b)) {
1151
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
1152
- return a.every((item, index) => sameTomlValue(item, b[index]))
1153
- }
1154
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
1155
- const left = a as Record<string, unknown>
1156
- const right = b as Record<string, unknown>
1157
- const keys = Object.keys(left)
1158
- if (keys.length !== Object.keys(right).length) return false
1159
- return keys.every((key) => key in right && sameTomlValue(left[key], right[key]))
1160
- }
1161
-
1162
- export function loadSettings(file: string): SettingsDocument {
1163
- return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
1164
- }
1165
-
1166
- function isTomlSettingsPath(file: string): boolean {
1167
- return file.endsWith('.toml')
1168
- }
1169
-
1170
- function readTomlSettings(file: string): SettingsDocument {
1171
- if (!existsSync(file)) return {}
1172
- const source = readOwnedRegularFile(file)
1173
- try {
1174
- const parsed: unknown = parseToml(source)
1175
- return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
1176
- } catch {
1177
- throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
1178
- }
1179
- }
1180
-
1181
- export function loadCursorSettings(file: string): CursorSettingsDocument {
1182
- return readCursorSettings(file)
1183
- }
1184
-
1185
- // ---------------------------------------------------------------------------
1186
- // Discovery — answering "will my hooks actually run?" without a live test
1187
- // ---------------------------------------------------------------------------
1188
-
1189
- /** One of our handlers as it sits in a settings file, with its position. */
1190
- export interface InstalledHandler {
1191
- event: string
1192
- /** Index of the matcher group within the event, as the harness sees it. */
1193
- groupIndex: number
1194
- /** Index of the handler within its group. */
1195
- handlerIndex: number
1196
- command: string
1197
- /** Declared process lifetime, used to detect config/install drift. */
1198
- timeout?: number
1199
- async?: boolean
1200
- statusMessage?: string
1201
- }
1202
-
1203
- export interface Installation {
1204
- harness: HookInstallableHarness
1205
- file: string
1206
- global: boolean
1207
- handlers: InstalledHandler[]
1208
- /** Structural defects that require reinstalling this generated adapter. */
1209
- problems?: string[]
1210
- }
1211
-
1212
- /** Codex's canonical event spelling inside its persisted trust keys. */
1213
- function codexEventName(event: string): string {
1214
- return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
1215
- }
1216
-
1217
- /** Recursively sort object keys before hashing, matching Codex's canonical JSON. */
1218
- function canonicalValue(value: unknown): unknown {
1219
- if (Array.isArray(value)) return value.map(canonicalValue)
1220
- if (typeof value !== 'object' || value === null) return value
1221
- return Object.fromEntries(
1222
- Object.entries(value as Record<string, unknown>)
1223
- .sort(([left], [right]) => left.localeCompare(right))
1224
- .map(([key, child]) => [key, canonicalValue(child)]),
1225
- )
1226
- }
1227
-
1228
- /**
1229
- * Best-effort mirror of the identity current Codex builds compare with
1230
- * `hooks.state.*.trusted_hash`. This persisted format is diagnostic evidence,
1231
- * not an API: Codex's `/hooks` UI remains authoritative and Notifai never
1232
- * writes the trust store.
1233
- */
1234
- export function codexHookIdentityHash(handler: InstalledHandler): string {
1235
- const command = {
1236
- type: 'command',
1237
- command: handler.command,
1238
- timeout: Math.max(1, handler.timeout ?? 600),
1239
- async: handler.async ?? false,
1240
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1241
- }
1242
- const normalized = canonicalValue({
1243
- event_name: codexEventName(handler.event),
1244
- hooks: [command],
1245
- })
1246
- return `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
1247
- }
1248
-
1249
- function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
1250
- const file = path.join(codexGlobalDir(env), 'config.toml')
1251
- if (!existsSync(file)) return {}
1252
- try {
1253
- const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
1254
- const hooks = parsed['hooks']
1255
- if (typeof hooks !== 'object' || hooks === null) return {}
1256
- const state = (hooks as Record<string, unknown>)['state']
1257
- return typeof state === 'object' && state !== null ? state as Record<string, unknown> : {}
1258
- } catch {
1259
- return {}
1260
- }
1261
- }
1262
-
1263
- /** Best-effort mirror of current Codex's persisted key for one handler. */
1264
- export function codexTrustKey(
1265
- installation: Installation,
1266
- handler: InstalledHandler,
1267
- ): string {
1268
- let source = installation.file
1269
- try {
1270
- source = path.join(realpathSync(path.dirname(source)), path.basename(source))
1271
- } catch {
1272
- // Codex falls back to the logical source path when canonicalization fails,
1273
- // so the diagnostic must do the same.
1274
- }
1275
- return `${source}:${codexEventName(handler.event)}:${handler.groupIndex}:${handler.handlerIndex}`
1276
- }
1277
-
1278
- /**
1279
- * Trust defects that make installed Codex handlers look present while Codex
1280
- * skips them. Trust is user-owned; the supported repair is Codex's `/hooks`
1281
- * review UI, never writing the trust store on the user's behalf. Because the
1282
- * persisted shape is not a public Codex contract, this diagnosis can drift;
1283
- * `/hooks` remains the source of truth.
1284
- */
1285
- export function codexTrustProblems(
1286
- installations: Installation[],
1287
- env: NodeJS.ProcessEnv = process.env,
1288
- ): string[] {
1289
- const state = codexTrustState(env)
1290
- return installations
1291
- .filter((installation) => installation.harness === 'codex')
1292
- .flatMap((installation) =>
1293
- installation.handlers.flatMap((handler) => {
1294
- const key = codexTrustKey(installation, handler)
1295
- const entry = state[key]
1296
- const trustedHash =
1297
- typeof entry === 'object' && entry !== null
1298
- ? (entry as Record<string, unknown>)['trusted_hash']
1299
- : undefined
1300
- const currentHash = codexHookIdentityHash(handler)
1301
- if (
1302
- typeof entry === 'object' &&
1303
- entry !== null &&
1304
- (entry as Record<string, unknown>)['enabled'] === false
1305
- ) {
1306
- return [
1307
- `${handler.event} in ${installation.file} is disabled in Codex; open \`/hooks\` and enable the Notifai handler`,
1308
- ]
1309
- }
1310
- if (trustedHash === currentHash) return []
1311
- return [
1312
- `${handler.event} in ${installation.file} is ${typeof trustedHash === 'string' ? 'changed since it was trusted' : 'not trusted'}; open \`/hooks\` in Codex and approve the Notifai handler`,
1313
- ]
1314
- }),
1315
- )
1316
- }
1317
-
1318
- /**
1319
- * Any Notifai handler, whatever checkout wrote it.
1320
- *
1321
- * Deliberately looser than `isOurHandler`: this answers "has Notifai been set
1322
- * up here at all", and a handler installed from a second checkout is still
1323
- * evidence that it has (and is itself worth reporting).
1324
- */
1325
- function isNotifaiCommand(command: string): boolean {
1326
- return / hook (session-start|subagent-start|activation-stop|user-prompt-submit|stop|session-end)\b/.test(command)
1327
- }
1328
-
1329
- /** Every place either harness would read a Notifai handler from. */
1330
- export function findInstallations(
1331
- cwd: string,
1332
- env: NodeJS.ProcessEnv = process.env,
1333
- adapterHome?: string,
1334
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
1335
- ): Installation[] {
1336
- const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath
1337
- const commandOptions: HookCommandOptions = {
1338
- platform,
1339
- ...(nodePath === undefined ? {} : { nodePath }),
1340
- }
1341
- const found: Installation[] = []
1342
- for (const harness of HOOK_INSTALLABLE_HARNESSES) {
1343
- for (const global of [false, true]) {
1344
- const files = hookDefinitionFiles(harness, global, cwd, env, platform)
1345
- for (const file of files) {
1346
- if (!existsSync(file)) continue
1347
- // OpenCode's adapter is a plugin module, not a settings document, so it
1348
- // is reported as one installation covering all three events rather than
1349
- // parsed for handlers.
1350
- if (harness === 'opencode' || harness === 'openclaw') {
1351
- let source: string
1352
- try {
1353
- source = readOwnedRegularFile(file)
1354
- } catch {
1355
- continue
1356
- }
1357
- const target =
1358
- harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source)
1359
- if (target === null) continue
1360
- const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode'
1361
- const problems = [
1362
- ...(!target.current
1363
- ? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
1364
- : []),
1365
- ...(target.adapter !== hookAdapterPath(adapterHome)
1366
- ? [
1367
- `${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
1368
- ]
1369
- : []),
1370
- ]
1371
- const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS
1372
- found.push({
1373
- harness,
1374
- file,
1375
- global,
1376
- ...(problems.length > 0 ? { problems } : {}),
1377
- handlers: events.map(([event, hookEvent]) => ({
1378
- event,
1379
- groupIndex: 0,
1380
- handlerIndex: 0,
1381
- command: hookCommand(target.adapter, hookEvent, harness, {
1382
- ...commandOptions,
1383
- ...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
1384
- }),
1385
- ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
1386
- })),
1387
- })
1388
- continue
1389
- }
1390
- if (harness === 'cursor') {
1391
- let document: CursorSettingsDocument
1392
- try {
1393
- document = readCursorSettings(file)
1394
- } catch {
1395
- continue
1396
- }
1397
- const handlers = locateCursorHandlers(document)
1398
- if (handlers.length > 0) {
1399
- const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
1400
- found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
1401
- }
1402
- continue
1403
- }
1404
- let document: SettingsDocument
1405
- try {
1406
- document = loadSettings(file)
1407
- } catch {
1408
- continue
1409
- }
1410
- const handlers = locateHandlers(document)
1411
- if (handlers.length > 0) {
1412
- const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
1413
- found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
1414
- }
1415
- }
1416
- }
1417
- }
1418
- return found
1419
- }
1420
-
1421
- function harnessMarkerProblems(
1422
- harness: HookInstallableHarness,
1423
- handlers: InstalledHandler[],
1424
- adapterHome?: string,
1425
- options: HookCommandOptions = {},
1426
- ): string[] {
1427
- const problems: string[] = []
1428
- if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
1429
- problems.push(
1430
- `installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
1431
- )
1432
- }
1433
- const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `
1434
- if (!handlers.every((handler) => handler.command.startsWith(expected))) {
1435
- problems.push(
1436
- 'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
1437
- )
1438
- }
1439
- return problems
1440
- }
1441
-
1442
- function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
1443
- const handlers: InstalledHandler[] = []
1444
- for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
1445
- eventHandlers.forEach((handler, handlerIndex) => {
1446
- if (isNotifaiCommand(handler.command)) {
1447
- handlers.push({
1448
- event,
1449
- groupIndex: 0,
1450
- handlerIndex,
1451
- command: handler.command,
1452
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1453
- })
1454
- }
1455
- })
1456
- }
1457
- return handlers
1458
- }
1459
-
1460
- function locateAllHandlers(document: SettingsDocument): InstalledHandler[] {
1461
- const handlers: InstalledHandler[] = []
1462
- for (const [event, groups] of Object.entries(document.hooks ?? {})) {
1463
- if (!Array.isArray(groups)) continue
1464
- groups.forEach((group, groupIndex) => {
1465
- group.hooks?.forEach((handler, handlerIndex) => {
1466
- if (typeof handler?.command !== 'string') return
1467
- handlers.push({
1468
- event,
1469
- groupIndex,
1470
- handlerIndex,
1471
- command: handler.command,
1472
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1473
- ...(handler.async === undefined ? {} : { async: handler.async }),
1474
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1475
- })
1476
- })
1477
- })
1478
- }
1479
- return handlers
1480
- }
1481
-
1482
- function locateHandlers(document: SettingsDocument): InstalledHandler[] {
1483
- return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command))
1484
- }
1485
-
1486
- /** The hook event a handler's command actually invokes, e.g. `stop`. */
1487
- export function handlerEvent(command: string): string | null {
1488
- return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
1489
- }
1490
-
1491
- function readOwnedRegularFile(file: string): string {
1492
- const stat = lstatSync(file)
1493
- if (stat.isSymbolicLink() || !stat.isFile()) {
1494
- throw new Error(`${file} is not a regular file; refusing to read it.`)
1495
- }
1496
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
1497
- if (uid !== undefined && stat.uid !== uid) {
1498
- throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
1499
- }
1500
- return readFileSync(file, 'utf8')
1501
- }