@raidiant/notifai 0.1.0

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
@@ -0,0 +1,708 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ statSync,
10
+ renameSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
17
+
18
+ /**
19
+ * The three joints the OpenCode plugin hooks into, as (harness event, the
20
+ * `notifai hook` event it runs). The plugin is a module and has no settings
21
+ * entries to read, so `findInstallations` reconstructs its handlers from this.
22
+ */
23
+ const OPENCODE_EVENTS = [
24
+ ['UserPromptSubmit', 'user-prompt-submit'],
25
+ ['Stop', 'stop'],
26
+ ['SessionEnd', 'session-end'],
27
+ ] as const
28
+
29
+ /**
30
+ * Harness hook installation.
31
+ *
32
+ * Claude Code's `settings.json` and Codex's `hooks.json` use the same shape —
33
+ * `hooks` maps an event name to matcher groups, each holding command handlers
34
+ * with an optional timeout — so one generator serves both. Only the file
35
+ * location and the event set differ.
36
+ *
37
+ * Cursor's native format is flat and lower-camel-cased, while OpenCode's
38
+ * extension point is a JavaScript plugin module. Each therefore has a bounded
39
+ * adapter instead of being forced through this shared document shape.
40
+ */
41
+
42
+ export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
43
+ export type Harness = (typeof HARNESSES)[number]
44
+
45
+ export interface HookHandler {
46
+ type: 'command'
47
+ command: string
48
+ timeout?: number
49
+ }
50
+
51
+ export interface HookGroup {
52
+ matcher?: string
53
+ hooks: HookHandler[]
54
+ }
55
+
56
+ export type HookConfig = Record<string, HookGroup[]>
57
+
58
+ export interface InstallPlan {
59
+ harness: Harness
60
+ /** Absolute path of the settings file that will be written. */
61
+ file: string
62
+ /** True when the file is machine-wide rather than per-project. */
63
+ global: boolean
64
+ config: HookConfig
65
+ }
66
+
67
+ /**
68
+ * The command each hook runs. Uses the absolute interpreter and script path
69
+ * rather than the bare binary name: hooks inherit a login-ish environment, and
70
+ * a `notifai` that is only on an interactive shell's PATH fails silently.
71
+ */
72
+ export function hookCommand(
73
+ execPath: string,
74
+ scriptPath: string,
75
+ event: string,
76
+ harness?: 'cursor',
77
+ ): string {
78
+ return (
79
+ `${quote(execPath)} ${quote(scriptPath)} hook ${event} ${OWNER_MARKER}` +
80
+ (harness === 'cursor' ? ' --harness cursor' : '')
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Says "NotifAI installed this" without saying which checkout did.
86
+ *
87
+ * Ownership used to be matched on the absolute script path, so installing from
88
+ * a second checkout did not recognise the first one's handlers as ours. Both
89
+ * stayed, the harness ran both, and one question produced two notifications;
90
+ * uninstalling the second silently left the first running. A marker that every
91
+ * NotifAI build writes and no build's path appears in fixes that by making
92
+ * ownership a property of the handler rather than of the machine it was
93
+ * installed from.
94
+ *
95
+ * It is a real (ignored) CLI flag rather than a comment because not every
96
+ * harness runs the command through a shell.
97
+ */
98
+ export const OWNER_MARKER = '--owner notifai'
99
+
100
+ /**
101
+ * POSIX single-quoting. Double quotes were wrong: they still expand `$(...)`
102
+ * and backticks, so a checkout path containing shell syntax became command
103
+ * execution on every hook event. Single quotes suppress all expansion; the
104
+ * only escape needed is for a literal single quote.
105
+ */
106
+ function quote(value: string): string {
107
+ return `'${value.replaceAll("'", `'\\''`)}'`
108
+ }
109
+
110
+ export interface BuildOptions {
111
+ execPath: string
112
+ scriptPath: string
113
+ /** Seconds a blocking hook may wait for a companion-device answer. */
114
+ replyTimeoutSeconds: number
115
+ /** Seconds the question waits in the terminal before it is pushed at all. */
116
+ graceSeconds: number
117
+ }
118
+
119
+ /** Command-hook harnesses kill at 600s; generated adapters use the same safe ceiling. */
120
+ const HOOK_CEILING_SECONDS = 590
121
+
122
+ /** Grace, reply wait, and teardown headroom for any blocking turn-end adapter. */
123
+ export function blockingHookTimeoutSeconds(graceSeconds: number, replyTimeoutSeconds: number): number {
124
+ return Math.min(HOOK_CEILING_SECONDS, graceSeconds + replyTimeoutSeconds + 60)
125
+ }
126
+
127
+ export function buildHookConfig(options: BuildOptions): HookConfig {
128
+ const { execPath, scriptPath } = options
129
+ // Stop can spend the grace window AND then the reply wait, so the declared
130
+ // timeout must cover both. Headroom on top so the harness never kills the
131
+ // hook first; a killed hook loses the answer the user already gave.
132
+ const blockingTimeout = blockingHookTimeoutSeconds(
133
+ options.graceSeconds,
134
+ options.replyTimeoutSeconds,
135
+ )
136
+ return {
137
+ UserPromptSubmit: [
138
+ {
139
+ hooks: [
140
+ {
141
+ type: 'command',
142
+ command: hookCommand(execPath, scriptPath, 'user-prompt-submit'),
143
+ // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
144
+ // a slow network can never delay the user's own prompt.
145
+ timeout: 15,
146
+ },
147
+ ],
148
+ },
149
+ ],
150
+ Stop: [
151
+ {
152
+ hooks: [
153
+ {
154
+ type: 'command',
155
+ command: hookCommand(execPath, scriptPath, 'stop'),
156
+ timeout: blockingTimeout,
157
+ },
158
+ ],
159
+ },
160
+ ],
161
+ SessionEnd: [
162
+ {
163
+ hooks: [
164
+ {
165
+ type: 'command',
166
+ command: hookCommand(execPath, scriptPath, 'session-end'),
167
+ // Both harnesses give SessionEnd a ~1-3s budget, so this handler
168
+ // only touches local state.
169
+ timeout: 3,
170
+ },
171
+ ],
172
+ },
173
+ ],
174
+ }
175
+ }
176
+
177
+ export interface CursorHookHandler {
178
+ command: string
179
+ timeout?: number
180
+ loop_limit?: number | null
181
+ }
182
+
183
+ export type CursorHookConfig = Record<string, CursorHookHandler[]>
184
+
185
+ interface CursorSettingsDocument {
186
+ version?: number
187
+ hooks?: CursorHookConfig
188
+ [key: string]: unknown
189
+ }
190
+
191
+ /** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
192
+ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
193
+ const blockingTimeout = blockingHookTimeoutSeconds(
194
+ options.graceSeconds,
195
+ options.replyTimeoutSeconds,
196
+ )
197
+ return {
198
+ beforeSubmitPrompt: [
199
+ {
200
+ command: hookCommand(options.execPath, options.scriptPath, 'user-prompt-submit', 'cursor'),
201
+ timeout: 15,
202
+ },
203
+ ],
204
+ stop: [
205
+ {
206
+ command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
207
+ timeout: blockingTimeout,
208
+ // One companion-device answer produces one automatic follow-up turn. There is no
209
+ // useful reason for Cursor to repeat it, even if state cleanup fails.
210
+ loop_limit: 1,
211
+ },
212
+ ],
213
+ sessionEnd: [
214
+ {
215
+ command: hookCommand(options.execPath, options.scriptPath, 'session-end', 'cursor'),
216
+ timeout: 3,
217
+ },
218
+ ],
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Where each harness reads hooks from. Project installs deliberately target the
224
+ * gitignored file where one exists: which devices a person wants their
225
+ * questions pushed to is not a property of the repository.
226
+ */
227
+ export function settingsFile(
228
+ harness: Harness,
229
+ global: boolean,
230
+ cwd: string,
231
+ env: NodeJS.ProcessEnv = process.env,
232
+ ): string {
233
+ // OpenCode has no settings document to merge into — its adapter is a
234
+ // generated plugin module, so it owns a whole file rather than a handler
235
+ // inside one. `opencodePluginPath` is its equivalent.
236
+ if (harness === 'opencode') return opencodePluginPath(global, cwd, env)
237
+ if (harness === 'cursor') {
238
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
239
+ return global
240
+ ? path.join(home, '.cursor', 'hooks.json')
241
+ : path.join(cwd, '.cursor', 'hooks.json')
242
+ }
243
+ if (harness === 'claude-code') {
244
+ return global
245
+ ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
246
+ : path.join(cwd, '.claude', 'settings.local.json')
247
+ }
248
+ return global
249
+ ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
250
+ : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
251
+ }
252
+
253
+ /**
254
+ * Where Codex looks for a project's hooks — the **main** repository, not the
255
+ * working directory.
256
+ *
257
+ * Run from a linked git worktree, Codex reads `<main repo>/.codex/hooks.json`
258
+ * and never looks at the worktree's own. Proven 2026-08-03 in an isolated
259
+ * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
260
+ * fired and an identical one at the worktree root did not; removing the main
261
+ * one left nothing firing at all. Writing to cwd therefore produces a silent
262
+ * no-op for anyone whose agent runs in a worktree, which is the normal case
263
+ * under tooling like Orca.
264
+ *
265
+ * Claude Code does not share this behaviour — it reads the worktree's own
266
+ * `.claude/settings.local.json` — so this deliberately applies to Codex only.
267
+ */
268
+ export function codexProjectRoot(cwd: string): string {
269
+ const entry = findGitEntry(cwd)
270
+ if (entry === null) return cwd
271
+ try {
272
+ if (statSync(entry).isDirectory()) return path.dirname(entry)
273
+ const linked = worktreeGitDir(entry)
274
+ return linked === null ? cwd : path.dirname(commonGitDir(linked))
275
+ } catch {
276
+ // An unreadable or exotic checkout is not worth failing an install over;
277
+ // cwd is what we used to do and is right for every non-worktree case.
278
+ return cwd
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The `.codex` directory that has to exist for Codex to load project hooks at
284
+ * all, or null when nothing extra is needed.
285
+ *
286
+ * Codex splits the two halves of "which project am I in": it discovers the
287
+ * project layer by walking up from cwd for a `.codex` directory, but resolves
288
+ * `hooks.json` inside that layer against the main repository. In an ordinary
289
+ * checkout both land on the same directory and the split is invisible. In a
290
+ * worktree they diverge, and writing only the main repository's file leaves it
291
+ * unread — Codex never looks, because nothing at or above cwd told it there was
292
+ * a project layer to load. Proven 2026-08-03: with cwd in a worktree, an *empty*
293
+ * `.codex` directory there was the difference between the main repository's
294
+ * handler running and nothing running.
295
+ */
296
+ export function codexLayerDir(cwd: string): string | null {
297
+ const entry = findGitEntry(cwd)
298
+ if (entry === null) return null
299
+ const worktreeRoot = path.dirname(entry)
300
+ if (path.resolve(worktreeRoot) === path.resolve(codexProjectRoot(cwd))) return null
301
+ return path.join(worktreeRoot, '.codex')
302
+ }
303
+
304
+ /** The nearest `.git` at or above `from`, or null outside a repository. */
305
+ function findGitEntry(from: string): string | null {
306
+ let dir = path.resolve(from)
307
+ for (;;) {
308
+ const entry = path.join(dir, '.git')
309
+ if (existsSync(entry)) return entry
310
+ const parent = path.dirname(dir)
311
+ if (parent === dir) return null
312
+ dir = parent
313
+ }
314
+ }
315
+
316
+ /** `.git` in a linked worktree is a file naming that worktree's git directory. */
317
+ function worktreeGitDir(file: string): string | null {
318
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(file, 'utf8'))
319
+ if (match === null) return null
320
+ return path.resolve(path.dirname(file), match[1]!.trim())
321
+ }
322
+
323
+ /**
324
+ * A worktree's git directory records the shared one in `commondir`; that shared
325
+ * directory is the main repository's `.git`, whose parent is its root.
326
+ */
327
+ function commonGitDir(gitDir: string): string {
328
+ const marker = path.join(gitDir, 'commondir')
329
+ if (!existsSync(marker)) return gitDir
330
+ return path.resolve(gitDir, readFileSync(marker, 'utf8').trim())
331
+ }
332
+
333
+ /**
334
+ * Both harnesses let an environment variable relocate their whole config
335
+ * directory, and tooling that manages sessions does exactly that — Orca points
336
+ * `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
337
+ * into a file Codex never reads and the failure is silent. Observed 2026-08-02.
338
+ */
339
+ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: string): string {
340
+ const override = env[variable]
341
+ return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
342
+ }
343
+
344
+ /** Best-effort detection so `hooks install` usually needs no flags. */
345
+ export function detectHarness(cwd: string): Harness | null {
346
+ const found: Harness[] = []
347
+ if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(os.homedir(), '.claude'))) {
348
+ found.push('claude-code')
349
+ }
350
+ if (existsSync(path.join(cwd, '.codex')) || existsSync(path.join(os.homedir(), '.codex'))) {
351
+ found.push('codex')
352
+ }
353
+ if (existsSync(path.join(cwd, '.cursor')) || existsSync(path.join(os.homedir(), '.cursor'))) {
354
+ found.push('cursor')
355
+ }
356
+ if (
357
+ existsSync(path.join(cwd, '.opencode')) ||
358
+ existsSync(path.join(os.homedir(), '.config', 'opencode'))
359
+ ) {
360
+ found.push('opencode')
361
+ }
362
+ return found.length === 1 ? found[0]! : null
363
+ }
364
+
365
+ interface SettingsDocument {
366
+ hooks?: HookConfig
367
+ [key: string]: unknown
368
+ }
369
+
370
+ function readCursorSettings(file: string): CursorSettingsDocument {
371
+ if (!existsSync(file)) return { version: 1 }
372
+ try {
373
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
374
+ return typeof parsed === 'object' && parsed !== null
375
+ ? (parsed as CursorSettingsDocument)
376
+ : { version: 1 }
377
+ } catch {
378
+ throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
379
+ }
380
+ }
381
+
382
+ function isOurCommand(command: string, scriptPath: string): boolean {
383
+ if (command.includes(OWNER_MARKER)) return true
384
+ return (
385
+ command.includes(`${scriptPath}' hook `) ||
386
+ command.includes(`${scriptPath}" hook `) ||
387
+ command.includes(`${scriptPath} hook `)
388
+ )
389
+ }
390
+
391
+ export function mergeCursorHooks(
392
+ existing: CursorSettingsDocument,
393
+ incoming: CursorHookConfig,
394
+ scriptPath: string,
395
+ ): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
396
+ const hooks: CursorHookConfig = {}
397
+ const added: string[] = []
398
+ const replaced: string[] = []
399
+ const removed: string[] = []
400
+
401
+ for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
402
+ const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
403
+ if (foreign.length !== handlers.length) {
404
+ if (event in incoming) replaced.push(event)
405
+ else removed.push(event)
406
+ }
407
+ if (foreign.length > 0) hooks[event] = foreign
408
+ }
409
+ for (const [event, handlers] of Object.entries(incoming)) {
410
+ if (!replaced.includes(event)) added.push(event)
411
+ hooks[event] = [...(hooks[event] ?? []), ...handlers]
412
+ }
413
+ return { document: { ...existing, version: 1, hooks }, added, replaced, removed }
414
+ }
415
+
416
+ export function removeCursorHooks(
417
+ existing: CursorSettingsDocument,
418
+ scriptPath: string,
419
+ ): { document: CursorSettingsDocument; added: string[]; replaced: string[]; removed: string[] } {
420
+ const hooks: CursorHookConfig = {}
421
+ const replaced: string[] = []
422
+ for (const [event, handlers] of Object.entries(existing.hooks ?? {})) {
423
+ const foreign = handlers.filter((handler) => !isOurCommand(handler.command, scriptPath))
424
+ if (foreign.length !== handlers.length) replaced.push(event)
425
+ if (foreign.length > 0) hooks[event] = foreign
426
+ }
427
+ return { document: { ...existing, version: 1, hooks }, added: [], replaced, removed: [] }
428
+ }
429
+
430
+ function readSettings(file: string): SettingsDocument {
431
+ if (!existsSync(file)) return {}
432
+ try {
433
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
434
+ return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
435
+ } catch {
436
+ throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
437
+ }
438
+ }
439
+
440
+ /**
441
+ * True when a single handler is ours. Deliberately per-handler, not per-group:
442
+ * removing a whole group destroyed a user's own handler if they had added one
443
+ * beside ours inside the same matcher group.
444
+ */
445
+ function isOurHandler(handler: HookHandler, scriptPath: string): boolean {
446
+ return isOurCommand(handler.command, scriptPath)
447
+ }
448
+
449
+ /** Drops only our handlers, keeping the group and anyone else's handlers. */
450
+ function withoutOurs(groups: HookGroup[], scriptPath: string): { groups: HookGroup[]; removed: boolean } {
451
+ let removed = false
452
+ const kept: HookGroup[] = []
453
+ for (const group of groups) {
454
+ const hooks = group.hooks.filter((handler) => {
455
+ const ours = isOurHandler(handler, scriptPath)
456
+ if (ours) removed = true
457
+ return !ours
458
+ })
459
+ if (hooks.length > 0) kept.push({ ...group, hooks })
460
+ }
461
+ return { groups: kept, removed }
462
+ }
463
+
464
+ export interface MergeResult {
465
+ document: SettingsDocument
466
+ added: string[]
467
+ replaced: string[]
468
+ /** Events an older build installed that this one no longer serves. */
469
+ removed: string[]
470
+ }
471
+
472
+ /**
473
+ * Adds our handlers without disturbing anyone else's. Re-running replaces only
474
+ * the groups we previously wrote, so an upgrade that changes a timeout does not
475
+ * accumulate duplicate hooks.
476
+ *
477
+ * Handlers are stripped from EVERY event, not just the ones being installed.
478
+ * Only rewriting incoming events left a dropped event's handler in place for
479
+ * ever, and since the binary no longer implements it, it exited 2 with
480
+ * "Unknown hook event" every time the harness fired it — a permanent hook
481
+ * failure that reinstalling could not clear.
482
+ */
483
+ export function mergeHooks(
484
+ existing: SettingsDocument,
485
+ incoming: HookConfig,
486
+ scriptPath: string,
487
+ ): MergeResult {
488
+ const hooks: HookConfig = {}
489
+ const added: string[] = []
490
+ const replaced: string[] = []
491
+ const removed: string[] = []
492
+
493
+ for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
494
+ const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
495
+ if (hadOurs) {
496
+ if (event in incoming) replaced.push(event)
497
+ else removed.push(event)
498
+ }
499
+ if (foreign.length > 0) hooks[event] = foreign
500
+ }
501
+
502
+ for (const [event, groups] of Object.entries(incoming)) {
503
+ if (!replaced.includes(event)) added.push(event)
504
+ hooks[event] = [...(hooks[event] ?? []), ...groups]
505
+ }
506
+
507
+ return { document: { ...existing, hooks }, added, replaced, removed }
508
+ }
509
+
510
+ export function removeHooks(existing: SettingsDocument, scriptPath: string): MergeResult {
511
+ const hooks: HookConfig = {}
512
+ const replaced: string[] = []
513
+ for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
514
+ const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
515
+ if (removed) replaced.push(event)
516
+ if (foreign.length > 0) hooks[event] = foreign
517
+ }
518
+ return { document: { ...existing, hooks }, added: [], replaced, removed: [] }
519
+ }
520
+
521
+ /**
522
+ * Writes the settings file the way a tool that does not own it should.
523
+ *
524
+ * Truncating in place lost the user's whole harness configuration if the write
525
+ * failed halfway, and clobbered a concurrent editor's changes; following a
526
+ * symlink wrote through to a target they never named. So: refuse
527
+ * anything that is not a regular file, write a sibling temp file, fsync it, and
528
+ * rename over the original — atomic within a directory on every platform we
529
+ * support.
530
+ */
531
+ export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
532
+ mkdirSync(path.dirname(file), { recursive: true })
533
+ const mode = targetMode(file)
534
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.notifai-${process.pid}.tmp`)
535
+
536
+ const handle = openSync(temp, 'w', mode)
537
+ try {
538
+ writeFileSync(handle, `${JSON.stringify(document, null, 2)}\n`)
539
+ fsyncSync(handle)
540
+ } finally {
541
+ closeSync(handle)
542
+ }
543
+ try {
544
+ renameSync(temp, file)
545
+ } catch (err) {
546
+ // Leaving the temp behind would be a second, quieter mess on top of the
547
+ // failure the caller is about to hear about.
548
+ try {
549
+ unlinkSync(temp)
550
+ } catch {
551
+ // Nothing useful to do; the rename error is the one that matters.
552
+ }
553
+ throw err
554
+ }
555
+ }
556
+
557
+ /** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
558
+ function targetMode(file: string): number {
559
+ let stat
560
+ try {
561
+ stat = lstatSync(file)
562
+ } catch (err) {
563
+ // A settings file can carry tokens, so a file we create is ours alone.
564
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0o600
565
+ throw err
566
+ }
567
+ if (stat.isSymbolicLink()) {
568
+ throw new Error(
569
+ `${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
570
+ 'or install to a different location.',
571
+ )
572
+ }
573
+ if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
574
+ return stat.mode & 0o777
575
+ }
576
+
577
+ export function loadSettings(file: string): SettingsDocument {
578
+ return readSettings(file)
579
+ }
580
+
581
+ export function loadCursorSettings(file: string): CursorSettingsDocument {
582
+ return readCursorSettings(file)
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Discovery — answering "will my hooks actually run?" without a live test
587
+ // ---------------------------------------------------------------------------
588
+
589
+ /** One of our handlers as it sits in a settings file, with its position. */
590
+ export interface InstalledHandler {
591
+ event: string
592
+ /** Index of the matcher group within the event, as the harness sees it. */
593
+ groupIndex: number
594
+ /** Index of the handler within its group. */
595
+ handlerIndex: number
596
+ command: string
597
+ }
598
+
599
+ export interface Installation {
600
+ harness: Harness
601
+ file: string
602
+ global: boolean
603
+ handlers: InstalledHandler[]
604
+ /** Structural defects that require reinstalling this generated adapter. */
605
+ problems?: string[]
606
+ }
607
+
608
+ /**
609
+ * Any NotifAI handler, whatever checkout wrote it.
610
+ *
611
+ * Deliberately looser than `isOurHandler`: this answers "has NotifAI been set
612
+ * up here at all", and a handler installed from a second checkout is still
613
+ * evidence that it has (and is itself worth reporting).
614
+ */
615
+ function isNotifaiCommand(command: string): boolean {
616
+ return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
617
+ }
618
+
619
+ function isNotifaiHandler(handler: HookHandler): boolean {
620
+ return isNotifaiCommand(handler.command)
621
+ }
622
+
623
+ /** Every place either harness would read a NotifAI handler from. */
624
+ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
625
+ const found: Installation[] = []
626
+ for (const harness of HARNESSES) {
627
+ for (const global of [false, true]) {
628
+ const file = settingsFile(harness, global, cwd, env)
629
+ if (!existsSync(file)) continue
630
+ // OpenCode's adapter is a plugin module, not a settings document, so it
631
+ // is reported as one installation covering all three events rather than
632
+ // parsed for handlers.
633
+ if (harness === 'opencode') {
634
+ const target = opencodePluginTarget(readFileSync(file, 'utf8'))
635
+ if (target === null) continue
636
+ found.push({
637
+ harness,
638
+ file,
639
+ global,
640
+ ...(!target.current
641
+ ? { problems: ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`'] }
642
+ : {}),
643
+ // Reported as the command line the plugin will actually run, not as
644
+ // the plugin's own path: every check downstream asks which build a
645
+ // handler invokes, and for a module that answer lives inside it.
646
+ handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
647
+ event,
648
+ groupIndex: 0,
649
+ handlerIndex: 0,
650
+ command: hookCommand(target.exec, target.script, hookEvent),
651
+ })),
652
+ })
653
+ continue
654
+ }
655
+ if (harness === 'cursor') {
656
+ let document: CursorSettingsDocument
657
+ try {
658
+ document = readCursorSettings(file)
659
+ } catch {
660
+ continue
661
+ }
662
+ const handlers = locateCursorHandlers(document)
663
+ if (handlers.length > 0) found.push({ harness, file, global, handlers })
664
+ continue
665
+ }
666
+ let document: SettingsDocument
667
+ try {
668
+ document = readSettings(file)
669
+ } catch {
670
+ continue
671
+ }
672
+ const handlers = locateHandlers(document)
673
+ if (handlers.length > 0) found.push({ harness, file, global, handlers })
674
+ }
675
+ }
676
+ return found
677
+ }
678
+
679
+ function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
680
+ const handlers: InstalledHandler[] = []
681
+ for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
682
+ eventHandlers.forEach((handler, handlerIndex) => {
683
+ if (isNotifaiCommand(handler.command)) {
684
+ handlers.push({ event, groupIndex: 0, handlerIndex, command: handler.command })
685
+ }
686
+ })
687
+ }
688
+ return handlers
689
+ }
690
+
691
+ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
692
+ const handlers: InstalledHandler[] = []
693
+ for (const [event, groups] of Object.entries(document.hooks ?? {})) {
694
+ groups.forEach((group, groupIndex) => {
695
+ group.hooks?.forEach((handler, handlerIndex) => {
696
+ if (isNotifaiHandler(handler)) {
697
+ handlers.push({ event, groupIndex, handlerIndex, command: handler.command })
698
+ }
699
+ })
700
+ })
701
+ }
702
+ return handlers
703
+ }
704
+
705
+ /** The hook event a handler's command actually invokes, e.g. `stop`. */
706
+ export function handlerEvent(command: string): string | null {
707
+ return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
708
+ }