@raidiant/notifai 0.5.1 → 1.0.1

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 (57) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +385 -158
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.d.ts +1 -1
  7. package/dist/config-schema.js +2 -14
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config.d.ts +19 -8
  10. package/dist/config.js +50 -7
  11. package/dist/config.js.map +1 -1
  12. package/dist/harnesses.d.ts +1 -0
  13. package/dist/harnesses.js +6 -0
  14. package/dist/harnesses.js.map +1 -1
  15. package/dist/hook-adapter.d.ts +10 -1
  16. package/dist/hook-adapter.js +79 -10
  17. package/dist/hook-adapter.js.map +1 -1
  18. package/dist/install-hooks.d.ts +60 -20
  19. package/dist/install-hooks.js +246 -100
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.d.ts +32 -1
  22. package/dist/interactive.js +163 -47
  23. package/dist/interactive.js.map +1 -1
  24. package/dist/main.js +8 -8
  25. package/dist/main.js.map +1 -1
  26. package/dist/native-skills.d.ts +1 -1
  27. package/dist/native-skills.js +23 -58
  28. package/dist/native-skills.js.map +1 -1
  29. package/dist/pack-feedback-slice.d.ts +35 -0
  30. package/dist/pack-feedback-slice.js +126 -0
  31. package/dist/pack-feedback-slice.js.map +1 -0
  32. package/dist/readiness.d.ts +20 -0
  33. package/dist/readiness.js +24 -0
  34. package/dist/readiness.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/commands.test.ts +411 -42
  37. package/src/commands.ts +468 -178
  38. package/src/config-schema.ts +2 -17
  39. package/src/config.test.ts +29 -4
  40. package/src/config.ts +67 -11
  41. package/src/harnesses.ts +7 -0
  42. package/src/hook-adapter.test.ts +43 -1
  43. package/src/hook-adapter.ts +83 -11
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +180 -8
  46. package/src/install-hooks.ts +227 -47
  47. package/src/interactive.test.ts +338 -0
  48. package/src/interactive.ts +221 -49
  49. package/src/main.ts +8 -8
  50. package/src/native-skills.test.ts +75 -0
  51. package/src/native-skills.ts +31 -75
  52. package/src/pack-feedback-slice.test.ts +212 -0
  53. package/src/pack-feedback-slice.ts +187 -0
  54. package/src/readiness.test.ts +25 -0
  55. package/src/readiness.ts +38 -0
  56. package/src/skill.test.ts +43 -0
  57. package/src/ui/config-view.test.ts +5 -4
@@ -23,15 +23,18 @@ import {
23
23
  HARNESSES,
24
24
  applyPlan,
25
25
  detectHarness,
26
+ detectedHarnesses,
26
27
  buildHookConfig,
27
28
  codexLayerDir,
28
29
  codexProjectRoot,
29
30
  findInstallations,
30
31
  codexHookIdentityHash,
32
+ codexRepresentationProblems,
31
33
  codexTrustKey,
32
34
  codexTrustProblems,
33
35
  handlerEvent,
34
36
  hookCommand,
37
+ loadSettings,
35
38
  mergeHooks,
36
39
  removeHooks,
37
40
  settingsFile,
@@ -223,12 +226,86 @@ describe('settings locations', () => {
223
226
  )
224
227
  })
225
228
 
226
- it('uses hooks.json for Codex', () => {
227
- expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
229
+ it('uses config.toml for Codex, not the legacy hooks.json', () => {
230
+ expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
228
231
  expect(settingsFile('codex', true, '/repo', {})).toBe(
229
- path.join(os.homedir(), '.codex', 'hooks.json'),
232
+ path.join(os.homedir(), '.codex', 'config.toml'),
230
233
  )
231
234
  })
235
+
236
+ it('migrates a leftover Notifai-only hooks.json onto config.toml', () => {
237
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-migrate-json-'))
238
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
239
+ applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
240
+
241
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
242
+ })
243
+
244
+ it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
245
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
246
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
247
+ applyPlan(path.join(repo, '.codex', 'hooks.json'), {
248
+ hooks: {
249
+ Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
250
+ },
251
+ })
252
+
253
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
254
+ })
255
+
256
+ it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
257
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
258
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
259
+ writeFileSync(
260
+ path.join(repo, '.codex', 'config.toml'),
261
+ [
262
+ 'model = "gpt-5.6"',
263
+ '',
264
+ '[[hooks.PostToolUse]]',
265
+ 'matcher = "Bash"',
266
+ '',
267
+ '[[hooks.PostToolUse.hooks]]',
268
+ 'type = "command"',
269
+ 'command = "gdh-guard"',
270
+ '',
271
+ '[[hooks.Stop]]',
272
+ '',
273
+ '[[hooks.Stop.hooks]]',
274
+ 'type = "command"',
275
+ 'command = "gdh-stop"',
276
+ '',
277
+ ].join('\n'),
278
+ )
279
+
280
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
281
+ })
282
+
283
+ it('does not treat the Codex trust store as an inline hook representation', () => {
284
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
285
+ writeFileSync(
286
+ path.join(home, 'config.toml'),
287
+ `[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
288
+ )
289
+
290
+ expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
291
+ path.join(home, 'config.toml'),
292
+ )
293
+ })
294
+
295
+ it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
296
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
297
+ const layer = path.join(repo, '.codex')
298
+ mkdirSync(layer, { recursive: true })
299
+ writeFileSync(
300
+ path.join(layer, 'config.toml'),
301
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
302
+ '\n',
303
+ ),
304
+ )
305
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
306
+
307
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
308
+ })
232
309
  })
233
310
 
234
311
  /**
@@ -253,7 +330,7 @@ describe('a Codex install run inside a git worktree', () => {
253
330
  it('targets the main repository, which is the file Codex actually reads', () => {
254
331
  const { main, worktree } = repoWithWorktree()
255
332
 
256
- expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
333
+ expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
257
334
  expect(codexProjectRoot(worktree)).toBe(main)
258
335
  })
259
336
 
@@ -322,14 +399,14 @@ describe('a Codex install run inside a git worktree', () => {
322
399
  // Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
323
400
  // produced hooks Codex never loaded, and nothing reported a problem.
324
401
  expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
325
- '/managed/codex/hooks.json',
402
+ '/managed/codex/config.toml',
326
403
  )
327
404
  expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
328
405
  '/managed/claude/settings.json',
329
406
  )
330
407
  // An empty value is not a relocation.
331
408
  expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
332
- path.join(os.homedir(), '.codex', 'hooks.json'),
409
+ path.join(os.homedir(), '.codex', 'config.toml'),
333
410
  )
334
411
  })
335
412
  })
@@ -422,9 +499,96 @@ describe('writing the settings file', () => {
422
499
  applyPlan(file, { hooks: {} })
423
500
  expect(statSync(file).mode & 0o777).toBe(0o600)
424
501
  })
502
+
503
+ it('merges into Codex config.toml without dropping trust state or foreign hooks', () => {
504
+ const dir = scratch()
505
+ const file = path.join(dir, 'config.toml')
506
+ writeFileSync(
507
+ file,
508
+ [
509
+ 'model = "gpt-5.6"',
510
+ '',
511
+ '[features]',
512
+ 'hooks = true',
513
+ '',
514
+ `[hooks.state."${file}:stop:0:0"]`,
515
+ 'trusted_hash = "sha256:abc"',
516
+ '',
517
+ '[[hooks.Stop]]',
518
+ '',
519
+ '[[hooks.Stop.hooks]]',
520
+ 'type = "command"',
521
+ 'command = "gdh-stop"',
522
+ '',
523
+ ].join('\n'),
524
+ )
525
+
526
+ const merged = mergeHooks(loadSettings(file), ours(), SCRIPT)
527
+ applyPlan(file, merged.document)
528
+
529
+ const text = readFileSync(file, 'utf8')
530
+ expect(text).toContain('model = "gpt-5.6"')
531
+ expect(text).toContain('trusted_hash = "sha256:abc"')
532
+ expect(text).toContain('gdh-stop')
533
+ expect(text).toContain('hook stop')
534
+ expect(text).toContain('[[hooks.UserPromptSubmit]]')
535
+ expect(text).toContain('[[hooks.SessionEnd]]')
536
+ expect(merged.document.hooks?.['Stop']?.flatMap((group) => group.hooks)).toHaveLength(2)
537
+ })
425
538
  })
426
539
 
427
540
  describe('finding what is installed', () => {
541
+ it('reports Notifai handlers written as inline Codex [hooks]', () => {
542
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-toml-'))
543
+ const file = path.join(cwd, '.codex', 'config.toml')
544
+ mkdirSync(path.dirname(file), { recursive: true })
545
+ writeFileSync(
546
+ file,
547
+ [
548
+ '[[hooks.Stop]]',
549
+ '',
550
+ '[[hooks.Stop.hooks]]',
551
+ 'type = "command"',
552
+ 'command = "gdh-stop"',
553
+ '',
554
+ '[[hooks.Stop]]',
555
+ '',
556
+ '[[hooks.Stop.hooks]]',
557
+ 'type = "command"',
558
+ `command = "${hookCommand(ADAPTER, 'stop', 'codex')}"`,
559
+ '',
560
+ ].join('\n'),
561
+ )
562
+
563
+ const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
564
+ const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
565
+ expect(codex?.file).toBe(file)
566
+ expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
567
+ expect(codex?.handlers[0]?.handlerIndex).toBe(0)
568
+ expect(codex?.handlers[0]?.groupIndex).toBe(1)
569
+ })
570
+
571
+ it('names a Codex layer that loads both representations', () => {
572
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-dual-rep-'))
573
+ const layer = path.join(cwd, '.codex')
574
+ mkdirSync(layer, { recursive: true })
575
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
576
+ writeFileSync(
577
+ path.join(layer, 'config.toml'),
578
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
579
+ '\n',
580
+ ),
581
+ )
582
+
583
+ const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
584
+ expect(problems).toHaveLength(1)
585
+ expect(problems[0]).toContain('loading hooks from both')
586
+ expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
587
+ expect(problems[0]).toContain(path.join(layer, 'config.toml'))
588
+ expect(problems[0]).toMatch(/prefer a single representation for this layer/)
589
+ expect(problems[0]).toMatch(/Stop/)
590
+ })
591
+
428
592
  it('reports handlers from either harness with their positions', () => {
429
593
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-'))
430
594
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
@@ -666,11 +830,19 @@ describe('detectHarness', () => {
666
830
  })
667
831
 
668
832
  it('returns null when the project itself names two harnesses', () => {
669
- // Genuine ambiguity, and the caller must ask rather than pick. Not the
670
- // same as having no evidence.
833
+ // Genuine ambiguity for a single-harness caller. detectedHarnesses lists
834
+ // both so install can wire them together.
671
835
  expect(detectHarness(project('.claude', '.codex'))).toBeNull()
672
836
  })
673
837
 
838
+ it('lists every project-named harness in declared order', () => {
839
+ const isolated = { HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-')) }
840
+ expect(detectedHarnesses(project('.codex', '.claude'), isolated)).toEqual([
841
+ 'claude-code',
842
+ 'codex',
843
+ ])
844
+ })
845
+
674
846
  it('prefers project evidence over anything installed on the machine', () => {
675
847
  // The regression. Machine-global markers used to be OR-ed in per harness,
676
848
  // so a developer with several agent tools installed got a candidate for
@@ -3,12 +3,13 @@ import {
3
3
  lstatSync,
4
4
  readFileSync,
5
5
  realpathSync,
6
+ rmSync,
6
7
  statSync,
7
8
  } from 'node:fs'
8
9
  import { createHash } from 'node:crypto'
9
10
  import os from 'node:os'
10
11
  import path from 'node:path'
11
- import { parse as parseToml } from 'smol-toml'
12
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
12
13
  import { atomicWriteFileSync } from './atomic-file.js'
13
14
  import { hookAdapterPath } from './hook-adapter.js'
14
15
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
@@ -30,10 +31,13 @@ const OPENCODE_EVENTS = [
30
31
  /**
31
32
  * Harness hook installation.
32
33
  *
33
- * Claude Code's `settings.json` and Codex's `hooks.json` use the same shape —
34
+ * Claude Code's `settings.json` and Codex's hook files use the same shape —
34
35
  * `hooks` maps an event name to matcher groups, each holding command handlers
35
36
  * with an optional timeout — so one generator serves both. Only the file
36
- * location and the event set differ.
37
+ * location and the event set differ. Codex still loads a leftover
38
+ * `hooks.json`, but that file is the legacy representation. We write inline
39
+ * `[hooks]` tables in the same layer's `config.toml`. One representation per
40
+ * layer, never both.
37
41
  *
38
42
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
39
43
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -260,9 +264,7 @@ export function settingsFile(
260
264
  ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
261
265
  : path.join(cwd, '.claude', 'settings.local.json')
262
266
  case 'codex':
263
- return global
264
- ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
265
- : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
267
+ return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget
266
268
  default:
267
269
  return assertNeverHarness(harness)
268
270
  }
@@ -276,8 +278,8 @@ function assertNeverHarness(harness: never): never {
276
278
  * Where Codex looks for a project's hooks — the **main** repository, not the
277
279
  * working directory.
278
280
  *
279
- * Run from a linked git worktree, Codex reads `<main repo>/.codex/hooks.json`
280
- * and never looks at the worktree's own. Proven 2026-08-03 in an isolated
281
+ * Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
282
+ * (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
281
283
  * `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
282
284
  * fired and an identical one at the worktree root did not; removing the main
283
285
  * one left nothing firing at all. Writing to cwd therefore produces a silent
@@ -307,7 +309,7 @@ export function codexProjectRoot(cwd: string): string {
307
309
  *
308
310
  * Codex splits the two halves of "which project am I in": it discovers the
309
311
  * project layer by walking up from cwd for a `.codex` directory, but resolves
310
- * `hooks.json` inside that layer against the main repository. In an ordinary
312
+ * the hook file inside that layer against the main repository. In an ordinary
311
313
  * checkout both land on the same directory and the split is invisible. In a
312
314
  * worktree they diverge, and writing only the main repository's file leaves it
313
315
  * unread — Codex never looks, because nothing at or above cwd told it there was
@@ -363,6 +365,151 @@ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: s
363
365
  return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
364
366
  }
365
367
 
368
+ export interface CodexLayerPaths {
369
+ dir: string
370
+ hooksJson: string
371
+ configToml: string
372
+ }
373
+
374
+ /** The two files Codex will look at for one config layer. */
375
+ export function codexLayerPaths(
376
+ global: boolean,
377
+ cwd: string,
378
+ env: NodeJS.ProcessEnv = process.env,
379
+ ): CodexLayerPaths {
380
+ const dir = global
381
+ ? configHome(env, 'CODEX_HOME', '.codex')
382
+ : path.join(codexProjectRoot(cwd), '.codex')
383
+ return {
384
+ dir,
385
+ hooksJson: path.join(dir, 'hooks.json'),
386
+ configToml: path.join(dir, 'config.toml'),
387
+ }
388
+ }
389
+
390
+ export interface CodexLayerInspection {
391
+ paths: CodexLayerPaths
392
+ jsonEvents: string[]
393
+ tomlEvents: string[]
394
+ writeTarget: string
395
+ dual: boolean
396
+ overlappingEvents: string[]
397
+ canCollapse: boolean
398
+ }
399
+
400
+ /**
401
+ * Which representation this Codex layer already uses.
402
+ *
403
+ * `hooks.json` is the legacy file. We write inline `[hooks]` in
404
+ * `config.toml` unless this layer already has someone else's hooks only in
405
+ * `hooks.json` — writing a second file would make Codex load both and run
406
+ * every matching handler. `[hooks.state]` is the trust store, not a hook
407
+ * definition, so it does not count as a representation.
408
+ */
409
+ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
410
+ const jsonDocument = tryLoadSettings(paths.hooksJson)
411
+ const tomlDocument = tryLoadSettings(paths.configToml)
412
+ const jsonEvents = hookEventNames(jsonDocument?.hooks)
413
+ const tomlEvents = hookEventNames(tomlDocument?.hooks)
414
+ const jsonForeign = documentHasForeignHandlers(jsonDocument)
415
+ const tomlForeign = documentHasForeignHandlers(tomlDocument)
416
+ const dual = jsonEvents.length > 0 && tomlEvents.length > 0
417
+ // Current representation. Stay on the legacy file only when that is
418
+ // already where someone else's hooks live and toml has none.
419
+ let writeTarget = paths.configToml
420
+ if (jsonEvents.length > 0 && tomlEvents.length === 0 && jsonForeign) {
421
+ writeTarget = paths.hooksJson
422
+ } else if (dual && jsonForeign && !tomlForeign) {
423
+ writeTarget = paths.hooksJson
424
+ }
425
+ return {
426
+ paths,
427
+ jsonEvents,
428
+ tomlEvents,
429
+ writeTarget,
430
+ dual,
431
+ overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
432
+ canCollapse: dual && !(jsonForeign && tomlForeign),
433
+ }
434
+ }
435
+
436
+ /** Every file in this layer Codex might already be reading hooks from. */
437
+ export function hookDefinitionFiles(
438
+ harness: Harness,
439
+ global: boolean,
440
+ cwd: string,
441
+ env: NodeJS.ProcessEnv = process.env,
442
+ ): string[] {
443
+ if (harness !== 'codex') return [settingsFile(harness, global, cwd, env)]
444
+ const paths = codexLayerPaths(global, cwd, env)
445
+ return [paths.hooksJson, paths.configToml]
446
+ }
447
+
448
+ /**
449
+ * Drop the unused representation after writing the one this layer already had.
450
+ *
451
+ * Only safe when the discarded file has no one else's handlers. `hooks.json`
452
+ * is deleted once it has no events left so Codex stops warning; `config.toml`
453
+ * is never deleted.
454
+ */
455
+ export function collapseUnusedCodexRepresentation(
456
+ keepFile: string,
457
+ scriptPath: string,
458
+ paths: CodexLayerPaths,
459
+ ): string | null {
460
+ const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson
461
+ if (!existsSync(other)) return null
462
+ const document = tryLoadSettings(other)
463
+ if (document === null || documentHasForeignHandlers(document)) return null
464
+ const stripped = removeHooks(document, scriptPath)
465
+ if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
466
+ rmSync(other)
467
+ return other
468
+ }
469
+ applyPlan(other, stripped.document)
470
+ return other
471
+ }
472
+
473
+ export function codexRepresentationProblems(
474
+ cwd: string,
475
+ env: NodeJS.ProcessEnv = process.env,
476
+ ): string[] {
477
+ return [false, true].flatMap((global) => {
478
+ const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env))
479
+ if (!layer.dual) return []
480
+ const overlap =
481
+ layer.overlappingEvents.length > 0
482
+ ? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
483
+ : ' Matching handlers from both files all run.'
484
+ const collapse = layer.canCollapse
485
+ ? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
486
+ : ' Both files have other hooks, so Notifai cannot collapse them.'
487
+ return [
488
+ `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
489
+ ]
490
+ })
491
+ }
492
+
493
+ function tryLoadSettings(file: string): SettingsDocument | null {
494
+ if (!existsSync(file)) return null
495
+ try {
496
+ return loadSettings(file)
497
+ } catch {
498
+ return null
499
+ }
500
+ }
501
+
502
+ function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
503
+ return Object.entries(hooks ?? {})
504
+ .filter(([, value]) => Array.isArray(value))
505
+ .map(([event]) => event)
506
+ }
507
+
508
+ function documentHasForeignHandlers(document: SettingsDocument | null): boolean {
509
+ if (document === null) return false
510
+ return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
511
+ }
512
+
366
513
  /** Best-effort detection so `hooks install` usually needs no flags. */
367
514
  /**
368
515
  * Harnesses this *project* shows evidence of, in the working directory only.
@@ -389,8 +536,8 @@ function localHarnessEvidence(cwd: string): Harness[] {
389
536
  }
390
537
 
391
538
  /** Harnesses installed anywhere on this machine — a much weaker signal. */
392
- function globalHarnessEvidence(): Harness[] {
393
- const home = os.homedir()
539
+ function globalHarnessEvidence(env: NodeJS.ProcessEnv = process.env): Harness[] {
540
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
394
541
  const found: Harness[] = []
395
542
  if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
396
543
  if (existsSync(path.join(home, '.codex'))) found.push('codex')
@@ -400,30 +547,34 @@ function globalHarnessEvidence(): Harness[] {
400
547
  }
401
548
 
402
549
  /**
403
- * Which harness to wire in this directory, or null when it is genuinely
404
- * unclear.
550
+ * Every supported harness this project or machine shows evidence of, in
551
+ * declared order. Project markers come first, then machine installs that the
552
+ * project did not already name.
405
553
  *
406
- * Project evidence decides, and machine evidence is consulted only when the
407
- * project offers none. The two were previously OR-ed together per harness,
408
- * which meant a developer with several agent tools installed could never get
409
- * a detection at all: every `~/.<tool>` directory contributed a candidate, the
410
- * "exactly one" test never passed, and the answer was null in every repository
411
- * on the machine. Verified on a machine carrying all four — `notifai init
412
- * --hooks` failed in five separate projects, each of which plainly used Claude
413
- * Code.
554
+ * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
555
+ */
556
+ export function detectedHarnesses(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness[] {
557
+ const seen = new Set<Harness>([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)])
558
+ return HARNESSES.filter((harness) => seen.has(harness))
559
+ }
560
+
561
+ /**
562
+ * Which *single* harness to wire when a caller still needs exactly one, or
563
+ * null when it is genuinely unclear.
414
564
  *
415
- * Having a tool installed says nothing about which one *this* project is
416
- * worked in. Ambiguity within the project is still ambiguity, and returns
417
- * null so the caller can ask rather than guess.
565
+ * Project evidence decides, and machine evidence is consulted only when the
566
+ * project offers none. Several markers are not a failure of detection — they
567
+ * are several harnesses. `detectedHarnesses` is the default for install;
568
+ * this helper stays for uninstall and any caller that cannot take a list.
418
569
  */
419
- export function detectHarness(cwd: string): Harness | null {
570
+ export function detectHarness(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness | null {
420
571
  const local = localHarnessEvidence(cwd)
421
572
  if (local.length > 0) return local.length === 1 ? local[0]! : null
422
- const global = globalHarnessEvidence()
573
+ const global = globalHarnessEvidence(env)
423
574
  return global.length === 1 ? global[0]! : null
424
575
  }
425
576
 
426
- interface SettingsDocument {
577
+ export interface SettingsDocument {
427
578
  hooks?: HookConfig
428
579
  [key: string]: unknown
429
580
  }
@@ -571,6 +722,11 @@ export function mergeHooks(
571
722
  const removed: string[] = []
572
723
 
573
724
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
725
+ if (!Array.isArray(groups)) {
726
+ // Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
727
+ ;(hooks as Record<string, unknown>)[event] = groups
728
+ continue
729
+ }
574
730
  const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
575
731
  if (hadOurs) {
576
732
  if (event in incoming) replaced.push(event)
@@ -591,6 +747,10 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
591
747
  const hooks: HookConfig = {}
592
748
  const replaced: string[] = []
593
749
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
750
+ if (!Array.isArray(groups)) {
751
+ ;(hooks as Record<string, unknown>)[event] = groups
752
+ continue
753
+ }
594
754
  const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
595
755
  if (removed) replaced.push(event)
596
756
  if (foreign.length > 0) hooks[event] = foreign
@@ -609,13 +769,31 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
609
769
  * support.
610
770
  */
611
771
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
612
- atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
772
+ const body = isTomlSettingsPath(file)
773
+ ? `${stringifyToml(document)}\n`
774
+ : `${JSON.stringify(document, null, 2)}\n`
775
+ atomicWriteFileSync(file, body, {
613
776
  requireCurrentUserOwner: true,
614
777
  })
615
778
  }
616
779
 
617
780
  export function loadSettings(file: string): SettingsDocument {
618
- return readSettings(file)
781
+ return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
782
+ }
783
+
784
+ function isTomlSettingsPath(file: string): boolean {
785
+ return file.endsWith('.toml')
786
+ }
787
+
788
+ function readTomlSettings(file: string): SettingsDocument {
789
+ if (!existsSync(file)) return {}
790
+ const source = readOwnedRegularFile(file)
791
+ try {
792
+ const parsed: unknown = parseToml(source)
793
+ return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
794
+ } catch {
795
+ throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
796
+ }
619
797
  }
620
798
 
621
799
  export function loadCursorSettings(file: string): CursorSettingsDocument {
@@ -766,10 +944,6 @@ function isNotifaiCommand(command: string): boolean {
766
944
  return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
767
945
  }
768
946
 
769
- function isNotifaiHandler(handler: HookHandler): boolean {
770
- return isNotifaiCommand(handler.command)
771
- }
772
-
773
947
  /** Every place either harness would read a Notifai handler from. */
774
948
  export function findInstallations(
775
949
  cwd: string,
@@ -779,7 +953,8 @@ export function findInstallations(
779
953
  const found: Installation[] = []
780
954
  for (const harness of HARNESSES) {
781
955
  for (const global of [false, true]) {
782
- const file = settingsFile(harness, global, cwd, env)
956
+ const files = hookDefinitionFiles(harness, global, cwd, env)
957
+ for (const file of files) {
783
958
  if (!existsSync(file)) continue
784
959
  // OpenCode's adapter is a plugin module, not a settings document, so it
785
960
  // is reported as one installation covering all three events rather than
@@ -837,7 +1012,7 @@ export function findInstallations(
837
1012
  }
838
1013
  let document: SettingsDocument
839
1014
  try {
840
- document = readSettings(file)
1015
+ document = loadSettings(file)
841
1016
  } catch {
842
1017
  continue
843
1018
  }
@@ -846,6 +1021,7 @@ export function findInstallations(
846
1021
  const problems = harnessMarkerProblems(harness, handlers, adapterHome)
847
1022
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
848
1023
  }
1024
+ }
849
1025
  }
850
1026
  }
851
1027
  return found
@@ -889,28 +1065,32 @@ function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandle
889
1065
  return handlers
890
1066
  }
891
1067
 
892
- function locateHandlers(document: SettingsDocument): InstalledHandler[] {
1068
+ function locateAllHandlers(document: SettingsDocument): InstalledHandler[] {
893
1069
  const handlers: InstalledHandler[] = []
894
1070
  for (const [event, groups] of Object.entries(document.hooks ?? {})) {
1071
+ if (!Array.isArray(groups)) continue
895
1072
  groups.forEach((group, groupIndex) => {
896
1073
  group.hooks?.forEach((handler, handlerIndex) => {
897
- if (isNotifaiHandler(handler)) {
898
- handlers.push({
899
- event,
900
- groupIndex,
901
- handlerIndex,
902
- command: handler.command,
903
- ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
904
- ...(handler.async === undefined ? {} : { async: handler.async }),
905
- ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
906
- })
907
- }
1074
+ if (typeof handler?.command !== 'string') return
1075
+ handlers.push({
1076
+ event,
1077
+ groupIndex,
1078
+ handlerIndex,
1079
+ command: handler.command,
1080
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1081
+ ...(handler.async === undefined ? {} : { async: handler.async }),
1082
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1083
+ })
908
1084
  })
909
1085
  })
910
1086
  }
911
1087
  return handlers
912
1088
  }
913
1089
 
1090
+ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
1091
+ return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command))
1092
+ }
1093
+
914
1094
  /** The hook event a handler's command actually invokes, e.g. `stop`. */
915
1095
  export function handlerEvent(command: string): string | null {
916
1096
  return / hook ([a-z-]+)/.exec(command)?.[1] ?? null