@raidiant/notifai 0.5.1 → 1.0.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 (57) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +371 -157
  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 +54 -17
  19. package/dist/install-hooks.js +258 -97
  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 +364 -31
  37. package/src/commands.ts +450 -177
  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 +154 -2
  46. package/src/install-hooks.ts +231 -44
  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
@@ -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,12 @@ 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 accepts that shape as `hooks.json`
38
+ * or as inline `[hooks]` tables in the same layer's `config.toml`; one
39
+ * representation per layer, never both.
37
40
  *
38
41
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
39
42
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -260,9 +263,7 @@ export function settingsFile(
260
263
  ? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
261
264
  : path.join(cwd, '.claude', 'settings.local.json')
262
265
  case 'codex':
263
- return global
264
- ? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
265
- : path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
266
+ return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget
266
267
  default:
267
268
  return assertNeverHarness(harness)
268
269
  }
@@ -363,6 +364,159 @@ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: s
363
364
  return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
364
365
  }
365
366
 
367
+ export interface CodexLayerPaths {
368
+ dir: string
369
+ hooksJson: string
370
+ configToml: string
371
+ }
372
+
373
+ /** The two files Codex will look at for one config layer. */
374
+ export function codexLayerPaths(
375
+ global: boolean,
376
+ cwd: string,
377
+ env: NodeJS.ProcessEnv = process.env,
378
+ ): CodexLayerPaths {
379
+ const dir = global
380
+ ? configHome(env, 'CODEX_HOME', '.codex')
381
+ : path.join(codexProjectRoot(cwd), '.codex')
382
+ return {
383
+ dir,
384
+ hooksJson: path.join(dir, 'hooks.json'),
385
+ configToml: path.join(dir, 'config.toml'),
386
+ }
387
+ }
388
+
389
+ export interface CodexLayerInspection {
390
+ paths: CodexLayerPaths
391
+ jsonEvents: string[]
392
+ tomlEvents: string[]
393
+ writeTarget: string
394
+ dual: boolean
395
+ overlappingEvents: string[]
396
+ canCollapse: boolean
397
+ }
398
+
399
+ /**
400
+ * Which representation this Codex layer already uses.
401
+ *
402
+ * Codex loads `hooks.json` and inline `[hooks]` side by side, runs every
403
+ * matching handler, and warns when both exist. `[hooks.state]` is the trust
404
+ * store, not a hook definition, so it does not count as a representation.
405
+ */
406
+ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
407
+ const jsonDocument = tryLoadSettings(paths.hooksJson)
408
+ const tomlDocument = tryLoadSettings(paths.configToml)
409
+ const jsonEvents = hookEventNames(jsonDocument?.hooks)
410
+ const tomlEvents = hookEventNames(tomlDocument?.hooks)
411
+ const jsonForeign = documentHasForeignHandlers(jsonDocument)
412
+ const tomlForeign = documentHasForeignHandlers(tomlDocument)
413
+ const jsonOurs = documentHasOurHandlers(jsonDocument)
414
+ const tomlOurs = documentHasOurHandlers(tomlDocument)
415
+ const dual = jsonEvents.length > 0 && tomlEvents.length > 0
416
+ let writeTarget = paths.hooksJson
417
+ if (tomlEvents.length > 0 && jsonEvents.length === 0) writeTarget = paths.configToml
418
+ else if (jsonEvents.length > 0 && tomlEvents.length === 0) writeTarget = paths.hooksJson
419
+ else if (dual) {
420
+ if (tomlForeign && !jsonForeign) writeTarget = paths.configToml
421
+ else if (jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
422
+ else if (!jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
423
+ else if (tomlOurs && !jsonOurs) writeTarget = paths.configToml
424
+ else if (jsonOurs && !tomlOurs) writeTarget = paths.hooksJson
425
+ else writeTarget = paths.hooksJson
426
+ }
427
+ return {
428
+ paths,
429
+ jsonEvents,
430
+ tomlEvents,
431
+ writeTarget,
432
+ dual,
433
+ overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
434
+ canCollapse: dual && !(jsonForeign && tomlForeign),
435
+ }
436
+ }
437
+
438
+ /** Every file in this layer Codex might already be reading hooks from. */
439
+ export function hookDefinitionFiles(
440
+ harness: Harness,
441
+ global: boolean,
442
+ cwd: string,
443
+ env: NodeJS.ProcessEnv = process.env,
444
+ ): string[] {
445
+ if (harness !== 'codex') return [settingsFile(harness, global, cwd, env)]
446
+ const paths = codexLayerPaths(global, cwd, env)
447
+ return [paths.hooksJson, paths.configToml]
448
+ }
449
+
450
+ /**
451
+ * Drop the unused representation after writing the one this layer already had.
452
+ *
453
+ * Only safe when the discarded file has no one else's handlers. `hooks.json`
454
+ * is deleted once it has no events left so Codex stops warning; `config.toml`
455
+ * is never deleted.
456
+ */
457
+ export function collapseUnusedCodexRepresentation(
458
+ keepFile: string,
459
+ scriptPath: string,
460
+ paths: CodexLayerPaths,
461
+ ): string | null {
462
+ const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson
463
+ if (!existsSync(other)) return null
464
+ const document = tryLoadSettings(other)
465
+ if (document === null || documentHasForeignHandlers(document)) return null
466
+ const stripped = removeHooks(document, scriptPath)
467
+ if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
468
+ rmSync(other)
469
+ return other
470
+ }
471
+ applyPlan(other, stripped.document)
472
+ return other
473
+ }
474
+
475
+ export function codexRepresentationProblems(
476
+ cwd: string,
477
+ env: NodeJS.ProcessEnv = process.env,
478
+ ): string[] {
479
+ return [false, true].flatMap((global) => {
480
+ const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env))
481
+ if (!layer.dual) return []
482
+ const overlap =
483
+ layer.overlappingEvents.length > 0
484
+ ? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
485
+ : ' Matching handlers from both files all run.'
486
+ const collapse = layer.canCollapse
487
+ ? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
488
+ : ' Both files have other hooks, so Notifai cannot collapse them.'
489
+ return [
490
+ `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
491
+ ]
492
+ })
493
+ }
494
+
495
+ function tryLoadSettings(file: string): SettingsDocument | null {
496
+ if (!existsSync(file)) return null
497
+ try {
498
+ return loadSettings(file)
499
+ } catch {
500
+ return null
501
+ }
502
+ }
503
+
504
+ function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
505
+ return Object.entries(hooks ?? {})
506
+ .filter(([, value]) => Array.isArray(value))
507
+ .map(([event]) => event)
508
+ }
509
+
510
+ function documentHasForeignHandlers(document: SettingsDocument | null): boolean {
511
+ if (document === null) return false
512
+ return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
513
+ }
514
+
515
+ function documentHasOurHandlers(document: SettingsDocument | null): boolean {
516
+ if (document === null) return false
517
+ return locateHandlers(document).length > 0
518
+ }
519
+
366
520
  /** Best-effort detection so `hooks install` usually needs no flags. */
367
521
  /**
368
522
  * Harnesses this *project* shows evidence of, in the working directory only.
@@ -389,8 +543,8 @@ function localHarnessEvidence(cwd: string): Harness[] {
389
543
  }
390
544
 
391
545
  /** Harnesses installed anywhere on this machine — a much weaker signal. */
392
- function globalHarnessEvidence(): Harness[] {
393
- const home = os.homedir()
546
+ function globalHarnessEvidence(env: NodeJS.ProcessEnv = process.env): Harness[] {
547
+ const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
394
548
  const found: Harness[] = []
395
549
  if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
396
550
  if (existsSync(path.join(home, '.codex'))) found.push('codex')
@@ -400,30 +554,34 @@ function globalHarnessEvidence(): Harness[] {
400
554
  }
401
555
 
402
556
  /**
403
- * Which harness to wire in this directory, or null when it is genuinely
404
- * unclear.
557
+ * Every supported harness this project or machine shows evidence of, in
558
+ * declared order. Project markers come first, then machine installs that the
559
+ * project did not already name.
405
560
  *
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.
561
+ * `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
562
+ */
563
+ export function detectedHarnesses(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness[] {
564
+ const seen = new Set<Harness>([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)])
565
+ return HARNESSES.filter((harness) => seen.has(harness))
566
+ }
567
+
568
+ /**
569
+ * Which *single* harness to wire when a caller still needs exactly one, or
570
+ * null when it is genuinely unclear.
414
571
  *
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.
572
+ * Project evidence decides, and machine evidence is consulted only when the
573
+ * project offers none. Several markers are not a failure of detection — they
574
+ * are several harnesses. `detectedHarnesses` is the default for install;
575
+ * this helper stays for uninstall and any caller that cannot take a list.
418
576
  */
419
- export function detectHarness(cwd: string): Harness | null {
577
+ export function detectHarness(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness | null {
420
578
  const local = localHarnessEvidence(cwd)
421
579
  if (local.length > 0) return local.length === 1 ? local[0]! : null
422
- const global = globalHarnessEvidence()
580
+ const global = globalHarnessEvidence(env)
423
581
  return global.length === 1 ? global[0]! : null
424
582
  }
425
583
 
426
- interface SettingsDocument {
584
+ export interface SettingsDocument {
427
585
  hooks?: HookConfig
428
586
  [key: string]: unknown
429
587
  }
@@ -571,6 +729,11 @@ export function mergeHooks(
571
729
  const removed: string[] = []
572
730
 
573
731
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
732
+ if (!Array.isArray(groups)) {
733
+ // Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
734
+ ;(hooks as Record<string, unknown>)[event] = groups
735
+ continue
736
+ }
574
737
  const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
575
738
  if (hadOurs) {
576
739
  if (event in incoming) replaced.push(event)
@@ -591,6 +754,10 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
591
754
  const hooks: HookConfig = {}
592
755
  const replaced: string[] = []
593
756
  for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
757
+ if (!Array.isArray(groups)) {
758
+ ;(hooks as Record<string, unknown>)[event] = groups
759
+ continue
760
+ }
594
761
  const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
595
762
  if (removed) replaced.push(event)
596
763
  if (foreign.length > 0) hooks[event] = foreign
@@ -609,13 +776,31 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
609
776
  * support.
610
777
  */
611
778
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
612
- atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`, {
779
+ const body = isTomlSettingsPath(file)
780
+ ? `${stringifyToml(document)}\n`
781
+ : `${JSON.stringify(document, null, 2)}\n`
782
+ atomicWriteFileSync(file, body, {
613
783
  requireCurrentUserOwner: true,
614
784
  })
615
785
  }
616
786
 
617
787
  export function loadSettings(file: string): SettingsDocument {
618
- return readSettings(file)
788
+ return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
789
+ }
790
+
791
+ function isTomlSettingsPath(file: string): boolean {
792
+ return file.endsWith('.toml')
793
+ }
794
+
795
+ function readTomlSettings(file: string): SettingsDocument {
796
+ if (!existsSync(file)) return {}
797
+ const source = readOwnedRegularFile(file)
798
+ try {
799
+ const parsed: unknown = parseToml(source)
800
+ return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
801
+ } catch {
802
+ throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
803
+ }
619
804
  }
620
805
 
621
806
  export function loadCursorSettings(file: string): CursorSettingsDocument {
@@ -766,10 +951,6 @@ function isNotifaiCommand(command: string): boolean {
766
951
  return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
767
952
  }
768
953
 
769
- function isNotifaiHandler(handler: HookHandler): boolean {
770
- return isNotifaiCommand(handler.command)
771
- }
772
-
773
954
  /** Every place either harness would read a Notifai handler from. */
774
955
  export function findInstallations(
775
956
  cwd: string,
@@ -779,7 +960,8 @@ export function findInstallations(
779
960
  const found: Installation[] = []
780
961
  for (const harness of HARNESSES) {
781
962
  for (const global of [false, true]) {
782
- const file = settingsFile(harness, global, cwd, env)
963
+ const files = hookDefinitionFiles(harness, global, cwd, env)
964
+ for (const file of files) {
783
965
  if (!existsSync(file)) continue
784
966
  // OpenCode's adapter is a plugin module, not a settings document, so it
785
967
  // is reported as one installation covering all three events rather than
@@ -837,7 +1019,7 @@ export function findInstallations(
837
1019
  }
838
1020
  let document: SettingsDocument
839
1021
  try {
840
- document = readSettings(file)
1022
+ document = loadSettings(file)
841
1023
  } catch {
842
1024
  continue
843
1025
  }
@@ -846,6 +1028,7 @@ export function findInstallations(
846
1028
  const problems = harnessMarkerProblems(harness, handlers, adapterHome)
847
1029
  found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
848
1030
  }
1031
+ }
849
1032
  }
850
1033
  }
851
1034
  return found
@@ -889,28 +1072,32 @@ function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandle
889
1072
  return handlers
890
1073
  }
891
1074
 
892
- function locateHandlers(document: SettingsDocument): InstalledHandler[] {
1075
+ function locateAllHandlers(document: SettingsDocument): InstalledHandler[] {
893
1076
  const handlers: InstalledHandler[] = []
894
1077
  for (const [event, groups] of Object.entries(document.hooks ?? {})) {
1078
+ if (!Array.isArray(groups)) continue
895
1079
  groups.forEach((group, groupIndex) => {
896
1080
  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
- }
1081
+ if (typeof handler?.command !== 'string') return
1082
+ handlers.push({
1083
+ event,
1084
+ groupIndex,
1085
+ handlerIndex,
1086
+ command: handler.command,
1087
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
1088
+ ...(handler.async === undefined ? {} : { async: handler.async }),
1089
+ ...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
1090
+ })
908
1091
  })
909
1092
  })
910
1093
  }
911
1094
  return handlers
912
1095
  }
913
1096
 
1097
+ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
1098
+ return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command))
1099
+ }
1100
+
914
1101
  /** The hook event a handler's command actually invokes, e.g. `stop`. */
915
1102
  export function handlerEvent(command: string): string | null {
916
1103
  return / hook ([a-z-]+)/.exec(command)?.[1] ?? null