@raidiant/notifai 1.0.1 → 2.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 (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
@@ -10,6 +10,7 @@ import {
10
10
  } from 'node:fs'
11
11
  import os from 'node:os'
12
12
  import path from 'node:path'
13
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
13
14
  import { describe, expect, it } from 'vitest'
14
15
  import { parseChoices } from './send.js'
15
16
  import {
@@ -19,7 +20,9 @@ import {
19
20
  opencodePluginSource,
20
21
  opencodePluginTarget,
21
22
  } from './opencode-plugin.js'
23
+ import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
22
24
  import {
25
+ BLOCKING_STOP_TIMEOUT_SECONDS,
23
26
  HARNESSES,
24
27
  applyPlan,
25
28
  detectHarness,
@@ -33,7 +36,9 @@ import {
33
36
  codexTrustKey,
34
37
  codexTrustProblems,
35
38
  handlerEvent,
39
+ harnessAccountHome,
36
40
  hookCommand,
41
+ quoteWindowsArg,
37
42
  loadSettings,
38
43
  mergeHooks,
39
44
  removeHooks,
@@ -126,7 +131,7 @@ describe('hook config', () => {
126
131
  })
127
132
  })
128
133
 
129
- it('gives Claude Code an asynchronous Stop with an explicit waiter budget', () => {
134
+ it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
130
135
  const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
131
136
 
132
137
  // `async: true` is what frees the turn: the handler returns at once and the
@@ -143,6 +148,22 @@ describe('hook config', () => {
143
148
  expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
144
149
  })
145
150
 
151
+ it('gives Claude Code a blocking Stop continuation on Windows', () => {
152
+ const options = {
153
+ adapterPath: 'C:\\Users\\Ada\\.notifai\\bin\\hook-adapter',
154
+ harness: 'claude-code' as const,
155
+ platform: 'win32' as const,
156
+ nodePath: 'C:\\Program Files\\nodejs\\node.exe',
157
+ }
158
+ const claude = buildHookConfig(options)
159
+
160
+ expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
161
+ type: 'command',
162
+ command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
163
+ timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
164
+ })
165
+ })
166
+
146
167
  it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
147
168
  const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
148
169
  const migrated = mergeHooks(
@@ -226,18 +247,37 @@ describe('settings locations', () => {
226
247
  )
227
248
  })
228
249
 
229
- it('uses config.toml for Codex, not the legacy hooks.json', () => {
230
- expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
250
+ it('keeps ambient global settings inside the test account', () => {
251
+ expect(os.homedir()).toBe(process.env['HOME'])
252
+ expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
253
+ expect(settingsFile('claude-code', true, '/repo', {})).toBe(
254
+ path.join(process.env['HOME']!, '.claude', 'settings.json'),
255
+ )
231
256
  expect(settingsFile('codex', true, '/repo', {})).toBe(
232
- path.join(os.homedir(), '.codex', 'config.toml'),
257
+ path.join(process.env['HOME']!, '.codex', 'hooks.json'),
233
258
  )
234
259
  })
235
260
 
236
- it('migrates a leftover Notifai-only hooks.json onto config.toml', () => {
237
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-migrate-json-'))
261
+ it('uses the dedicated Codex hooks file for a new layer', () => {
262
+ expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
263
+ expect(settingsFile('codex', true, '/repo', {})).toBe(
264
+ path.join(os.homedir(), '.codex', 'hooks.json'),
265
+ )
266
+ })
267
+
268
+ it('keeps a Notifai-only hooks.json instead of rewriting config.toml', () => {
269
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-'))
238
270
  mkdirSync(path.join(repo, '.codex'), { recursive: true })
239
271
  applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
240
272
 
273
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
274
+ })
275
+
276
+ it('keeps a Notifai-only inline representation so existing trust remains valid', () => {
277
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-ours-toml-'))
278
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
279
+ applyPlan(path.join(repo, '.codex', 'config.toml'), { hooks: ours() })
280
+
241
281
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
242
282
  })
243
283
 
@@ -282,17 +322,18 @@ describe('settings locations', () => {
282
322
 
283
323
  it('does not treat the Codex trust store as an inline hook representation', () => {
284
324
  const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
325
+ mkdirSync(path.join(home, '.codex'), { recursive: true })
285
326
  writeFileSync(
286
- path.join(home, 'config.toml'),
287
- `[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
327
+ path.join(home, '.codex', 'config.toml'),
328
+ `[hooks.state."${home}/.codex/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
288
329
  )
289
330
 
290
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
291
- path.join(home, 'config.toml'),
331
+ expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
332
+ path.join(home, '.codex', 'hooks.json'),
292
333
  )
293
334
  })
294
335
 
295
- it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
336
+ it('preserves foreign inline hooks when both representations exist', () => {
296
337
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
297
338
  const layer = path.join(repo, '.codex')
298
339
  mkdirSync(layer, { recursive: true })
@@ -330,7 +371,7 @@ describe('a Codex install run inside a git worktree', () => {
330
371
  it('targets the main repository, which is the file Codex actually reads', () => {
331
372
  const { main, worktree } = repoWithWorktree()
332
373
 
333
- expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
374
+ expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
334
375
  expect(codexProjectRoot(worktree)).toBe(main)
335
376
  })
336
377
 
@@ -395,19 +436,13 @@ describe('a Codex install run inside a git worktree', () => {
395
436
  )
396
437
  })
397
438
 
398
- it('follows a relocated config home rather than installing where nothing reads', () => {
399
- // Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
400
- // produced hooks Codex never loaded, and nothing reported a problem.
401
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
402
- '/managed/codex/config.toml',
439
+ it('follows the active harness home for global hook install', () => {
440
+ expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
441
+ '/managed/codex/hooks.json',
403
442
  )
404
443
  expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
405
444
  '/managed/claude/settings.json',
406
445
  )
407
- // An empty value is not a relocation.
408
- expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
409
- path.join(os.homedir(), '.codex', 'config.toml'),
410
- )
411
446
  })
412
447
  })
413
448
 
@@ -560,7 +595,7 @@ describe('finding what is installed', () => {
560
595
  ].join('\n'),
561
596
  )
562
597
 
563
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
598
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
564
599
  const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
565
600
  expect(codex?.file).toBe(file)
566
601
  expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
@@ -580,7 +615,7 @@ describe('finding what is installed', () => {
580
615
  ),
581
616
  )
582
617
 
583
- const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
618
+ const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
584
619
  expect(problems).toHaveLength(1)
585
620
  expect(problems[0]).toContain('loading hooks from both')
586
621
  expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
@@ -594,7 +629,7 @@ describe('finding what is installed', () => {
594
629
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
595
630
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
596
631
 
597
- const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
632
+ const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
598
633
  const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
599
634
 
600
635
  expect(claude).toBeDefined()
@@ -615,39 +650,41 @@ describe('finding what is installed', () => {
615
650
 
616
651
  it('detects when Codex still trusts an older definition of an installed hook', () => {
617
652
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
618
- const codexHome = path.join(cwd, 'codex-home')
653
+ const home = path.join(cwd, 'home')
654
+ const env = { HOME: home }
619
655
  const hookFile = path.join(cwd, '.codex', 'hooks.json')
620
656
  mkdirSync(path.dirname(hookFile), { recursive: true })
621
657
  applyPlan(hookFile, { hooks: ours() })
622
- const installations = findInstallations(cwd, { CODEX_HOME: codexHome })
658
+ const installations = findInstallations(cwd, env)
623
659
  const codex = installations.find((installation) => installation.harness === 'codex')
624
660
  const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
625
661
  expect(stop).toBeDefined()
626
662
 
627
- mkdirSync(codexHome, { recursive: true })
663
+ const trustFile = path.join(home, '.codex', 'config.toml')
664
+ mkdirSync(path.dirname(trustFile), { recursive: true })
628
665
  writeFileSync(
629
- path.join(codexHome, 'config.toml'),
666
+ trustFile,
630
667
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
631
668
  )
632
669
 
633
- const stale = codexTrustProblems(installations, { CODEX_HOME: codexHome })
670
+ const stale = codexTrustProblems(installations, env)
634
671
  expect(stale).toHaveLength(3)
635
672
  expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
636
673
 
637
674
  writeFileSync(
638
- path.join(codexHome, 'config.toml'),
675
+ trustFile,
639
676
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
640
677
  )
641
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual([
678
+ expect(codexTrustProblems(installations, env)).toEqual([
642
679
  expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
643
680
  expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
644
681
  ])
645
682
 
646
683
  writeFileSync(
647
- path.join(codexHome, 'config.toml'),
684
+ trustFile,
648
685
  `[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
649
686
  )
650
- expect(codexTrustProblems(installations, { CODEX_HOME: codexHome })).toEqual(
687
+ expect(codexTrustProblems(installations, env)).toEqual(
651
688
  expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
652
689
  )
653
690
  })
@@ -843,6 +880,14 @@ describe('detectHarness', () => {
843
880
  ])
844
881
  })
845
882
 
883
+ it('detects Codex from the active custom CODEX_HOME', () => {
884
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-'))
885
+ const codexHome = path.join(home, 'managed', 'codex')
886
+ mkdirSync(codexHome, { recursive: true })
887
+
888
+ expect(detectedHarnesses(project(), { HOME: home, CODEX_HOME: codexHome })).toEqual(['codex'])
889
+ })
890
+
846
891
  it('prefers project evidence over anything installed on the machine', () => {
847
892
  // The regression. Machine-global markers used to be OR-ed in per harness,
848
893
  // so a developer with several agent tools installed got a candidate for
@@ -865,3 +910,99 @@ describe('detectHarness', () => {
865
910
  expect(answer === null || HARNESSES.includes(answer)).toBe(true)
866
911
  })
867
912
  })
913
+
914
+ describe('Windows hook commands and discovery', () => {
915
+ const winNode = 'C:\\Program Files\\nodejs\\node.exe'
916
+ const winAdapter = 'C:\\Users\\Ada Lovelace\\.notifai\\bin\\hook-adapter'
917
+ const winOpts = { platform: 'win32' as const, nodePath: winNode }
918
+
919
+ it('keeps POSIX command bytes unchanged', () => {
920
+ expect(hookCommand(ADAPTER, 'stop', undefined, { platform: 'posix' })).toBe(
921
+ `'${ADAPTER}' hook stop --owner notifai`,
922
+ )
923
+ expect(hookCommand(ADAPTER, 'stop', 'codex', { platform: 'posix' })).toBe(
924
+ `'${ADAPTER}' hook stop --owner notifai --harness codex`,
925
+ )
926
+ })
927
+
928
+ it('emits a Windows-safe command with no POSIX single quotes', () => {
929
+ const command = hookCommand(winAdapter, 'stop', 'codex', winOpts)
930
+ expect(command).toBe(
931
+ `"${winNode}" "${winAdapter}" hook stop --owner notifai --harness codex`,
932
+ )
933
+ expect(command).not.toContain("'")
934
+ expect(quoteWindowsArg('C:\\ends\\with\\')).toBe('"C:\\ends\\with\\\\"')
935
+ expect(quoteWindowsArg('say "hi"')).toBe('"say \\"hi\\""')
936
+ })
937
+
938
+ it('round-trips the Windows command through JSON and TOML documents', () => {
939
+ const command = hookCommand(winAdapter, 'stop', 'cursor', winOpts)
940
+ expect(JSON.parse(JSON.stringify({ command }))).toEqual({ command })
941
+ expect(parseToml(stringifyToml({ command }))).toEqual({ command })
942
+ })
943
+
944
+ it('discovers a Windows-quoted installation as the stable adapter', () => {
945
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-win-discover-'))
946
+ const homeDir = path.join(cwd, 'home')
947
+ const script = path.join(cwd, 'cli.js')
948
+ writeFileSync(script, '')
949
+ installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32')
950
+ const adapter = hookAdapterPath(homeDir)
951
+ mkdirSync(path.join(cwd, '.claude'), { recursive: true })
952
+ applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
953
+ hooks: buildHookConfig({
954
+ adapterPath: adapter,
955
+ harness: 'claude-code',
956
+ platform: 'win32',
957
+ nodePath: process.execPath,
958
+ }),
959
+ })
960
+
961
+ const found = findInstallations(
962
+ cwd,
963
+ { HOME: homeDir, USERPROFILE: 'C:\\Users\\Ada' },
964
+ homeDir,
965
+ 'win32',
966
+ )
967
+ expect(found).toHaveLength(1)
968
+ expect(found[0]?.harness).toBe('claude-code')
969
+ expect(found[0]?.problems ?? []).toEqual([])
970
+ expect(found[0]?.handlers[0]?.command.startsWith(`${quoteWindowsArg(process.execPath)} `)).toBe(
971
+ true,
972
+ )
973
+ })
974
+
975
+ it('prefers a Windows-absolute USERPROFILE over an MSYS HOME', () => {
976
+ const env = { HOME: '/c/Users/msys', USERPROFILE: 'C:\\Users\\Ada' }
977
+ expect(harnessAccountHome(env, 'win32')).toBe('C:\\Users\\Ada')
978
+ expect(settingsFile('cursor', true, '/repo', env, 'win32')).toBe(
979
+ path.join('C:\\Users\\Ada', '.cursor', 'hooks.json'),
980
+ )
981
+ expect(settingsFile('claude-code', true, '/repo', env, 'win32')).toBe(
982
+ path.join('C:\\Users\\Ada', '.claude', 'settings.json'),
983
+ )
984
+ expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
985
+ path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
986
+ )
987
+ })
988
+
989
+ it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
990
+ const source = opencodePluginSource({
991
+ adapterPath: winAdapter,
992
+ timeoutSeconds: 240,
993
+ platform: 'win32',
994
+ nodePath: winNode,
995
+ })
996
+ expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
997
+ expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]')
998
+ expect(source).toContain('shell: false')
999
+ expect(source).toContain('windowsHide: true')
1000
+ expect(source).not.toContain('spawn(ADAPTER,')
1001
+ expect(opencodePluginTarget(source)).toEqual({
1002
+ adapter: winAdapter,
1003
+ current: true,
1004
+ timeoutSeconds: 240,
1005
+ nodePath: winNode,
1006
+ })
1007
+ })
1008
+ })