@raidiant/notifai 0.5.0 → 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 (74) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/claude-wake.d.ts +101 -0
  4. package/dist/claude-wake.js +397 -0
  5. package/dist/claude-wake.js.map +1 -0
  6. package/dist/codex-wake.d.ts +84 -0
  7. package/dist/codex-wake.js +238 -0
  8. package/dist/codex-wake.js.map +1 -0
  9. package/dist/commands.d.ts +31 -11
  10. package/dist/commands.js +679 -232
  11. package/dist/commands.js.map +1 -1
  12. package/dist/config-schema.d.ts +3 -3
  13. package/dist/config-schema.js +11 -49
  14. package/dist/config-schema.js.map +1 -1
  15. package/dist/config.d.ts +23 -45
  16. package/dist/config.js +61 -39
  17. package/dist/config.js.map +1 -1
  18. package/dist/harnesses.d.ts +4 -13
  19. package/dist/harnesses.js +12 -17
  20. package/dist/harnesses.js.map +1 -1
  21. package/dist/hook-adapter.d.ts +10 -1
  22. package/dist/hook-adapter.js +79 -10
  23. package/dist/hook-adapter.js.map +1 -1
  24. package/dist/hooks.d.ts +119 -65
  25. package/dist/hooks.js +330 -285
  26. package/dist/hooks.js.map +1 -1
  27. package/dist/install-hooks.d.ts +76 -24
  28. package/dist/install-hooks.js +301 -125
  29. package/dist/install-hooks.js.map +1 -1
  30. package/dist/interactive.d.ts +32 -1
  31. package/dist/interactive.js +165 -51
  32. package/dist/interactive.js.map +1 -1
  33. package/dist/main.js +14 -21
  34. package/dist/main.js.map +1 -1
  35. package/dist/native-skills.d.ts +1 -1
  36. package/dist/native-skills.js +23 -58
  37. package/dist/native-skills.js.map +1 -1
  38. package/dist/pack-feedback-slice.d.ts +35 -0
  39. package/dist/pack-feedback-slice.js +126 -0
  40. package/dist/pack-feedback-slice.js.map +1 -0
  41. package/dist/readiness.d.ts +20 -0
  42. package/dist/readiness.js +24 -0
  43. package/dist/readiness.js.map +1 -1
  44. package/dist/release.d.ts +31 -0
  45. package/dist/release.js +52 -0
  46. package/dist/release.js.map +1 -0
  47. package/dist/ui/theme.d.ts +1 -1
  48. package/package.json +11 -10
  49. package/src/commands.test.ts +386 -36
  50. package/src/commands.ts +473 -182
  51. package/src/config-schema.ts +2 -17
  52. package/src/config.test.ts +29 -4
  53. package/src/config.ts +67 -11
  54. package/src/harnesses.ts +7 -0
  55. package/src/hook-adapter.test.ts +43 -1
  56. package/src/hook-adapter.ts +83 -11
  57. package/src/hooks.test.ts +4 -5
  58. package/src/install-hooks.test.ts +154 -2
  59. package/src/install-hooks.ts +231 -44
  60. package/src/interactive.test.ts +338 -0
  61. package/src/interactive.ts +221 -49
  62. package/src/main.ts +14 -20
  63. package/src/native-skills.test.ts +75 -0
  64. package/src/native-skills.ts +31 -75
  65. package/src/pack-feedback-slice.test.ts +212 -0
  66. package/src/pack-feedback-slice.ts +187 -0
  67. package/src/readiness.test.ts +25 -0
  68. package/src/readiness.ts +38 -0
  69. package/src/release.ts +51 -0
  70. package/src/skill.test.ts +43 -0
  71. package/src/ui/config-view.test.ts +5 -4
  72. package/dist/idle.d.ts +0 -16
  73. package/dist/idle.js +0 -44
  74. package/dist/idle.js.map +0 -1
@@ -36,6 +36,7 @@ import {
36
36
  deviceInventory,
37
37
  doctorCommand,
38
38
  hooksInstallCommand,
39
+ hooksUninstallCommand,
39
40
  initCommand,
40
41
  loginCommand,
41
42
  logoutCommand,
@@ -43,6 +44,11 @@ import {
43
44
  type CommandDeps,
44
45
  } from './commands.js'
45
46
  import { CONFIG_KEYS, loadConfig, type CliConfig, type ConfigKey } from './config.js'
47
+ import {
48
+ detectedHarnesses,
49
+ findInstallations,
50
+ type Installation,
51
+ } from './install-hooks.js'
46
52
  import {
47
53
  CONFIG_GROUPS,
48
54
  acceptedValues,
@@ -53,15 +59,112 @@ import {
53
59
  formatValue,
54
60
  type ConfigGroup,
55
61
  } from './config-schema.js'
56
- import { firstBlocker, type Readiness, type ReadinessState } from './readiness.js'
62
+ import {
63
+ firstBlocker,
64
+ refreshAfterMenuAction,
65
+ type Readiness,
66
+ type ReadinessRefresh,
67
+ type ReadinessState,
68
+ } from './readiness.js'
57
69
  import { printBanner, tagline } from './ui/banner.js'
58
70
  import { card, glyph, mark, style, type Tone } from './ui/theme.js'
59
71
 
60
72
  /** Clack returns a symbol when the user pressed Ctrl-C or Escape. */
61
- function cancelled(value: unknown): boolean {
73
+ function cancelled(value: unknown): value is symbol {
62
74
  return clack.isCancel(value)
63
75
  }
64
76
 
77
+ export type HookScope = 'project' | 'machine'
78
+
79
+ export type RoutingAction = 'install' | 'uninstall' | 'settings' | 'back'
80
+
81
+ export interface MenuOption<Value extends string> {
82
+ value: Value
83
+ label: string
84
+ hint?: string
85
+ }
86
+
87
+ /**
88
+ * Which hook actions this directory and setup can actually perform.
89
+ *
90
+ * Install is offered only when a harness is detectable here. Uninstall is
91
+ * offered only for a scope that already has Notifai hooks, and the label names
92
+ * that scope so a machine-wide install is never stripped from a random folder
93
+ * under a generic "uninstall".
94
+ */
95
+ export function routingHookActions(input: {
96
+ canInstall: boolean
97
+ projectInstallations: readonly { harness: string }[]
98
+ machineInstallations: readonly { harness: string }[]
99
+ hooksReady: boolean
100
+ }): MenuOption<RoutingAction>[] {
101
+ const options: MenuOption<RoutingAction>[] = []
102
+ const hasProject = input.projectInstallations.length > 0
103
+ const hasMachine = input.machineInstallations.length > 0
104
+ const hasAny = hasProject || hasMachine
105
+
106
+ if (input.canInstall) {
107
+ options.push({
108
+ value: 'install',
109
+ label: input.hooksReady || hasAny ? 'Re-install hooks' : 'Install hooks',
110
+ hint: 'this project or this machine',
111
+ })
112
+ }
113
+
114
+ if (hasProject && hasMachine) {
115
+ options.push({
116
+ value: 'uninstall',
117
+ label: 'Uninstall hooks',
118
+ hint: 'this project or this machine',
119
+ })
120
+ } else if (hasProject) {
121
+ options.push({
122
+ value: 'uninstall',
123
+ label: 'Uninstall hooks for this project',
124
+ hint: harnessHint(input.projectInstallations),
125
+ })
126
+ } else if (hasMachine) {
127
+ options.push({
128
+ value: 'uninstall',
129
+ label: 'Uninstall hooks on this machine',
130
+ hint: harnessHint(input.machineInstallations),
131
+ })
132
+ }
133
+
134
+ options.push(
135
+ { value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
136
+ { value: 'back', label: '← Back' },
137
+ )
138
+ return options
139
+ }
140
+
141
+ /** Scope choices that would actually remove something. Project first. */
142
+ export function uninstallScopeOptions(
143
+ projectInstallations: readonly { harness: string }[],
144
+ machineInstallations: readonly { harness: string }[],
145
+ ): MenuOption<HookScope>[] {
146
+ const options: MenuOption<HookScope>[] = []
147
+ if (projectInstallations.length > 0) {
148
+ options.push({
149
+ value: 'project',
150
+ label: 'This project',
151
+ hint: 'remove hooks from this directory',
152
+ })
153
+ }
154
+ if (machineInstallations.length > 0) {
155
+ options.push({
156
+ value: 'machine',
157
+ label: 'This machine',
158
+ hint: 'remove hooks for every project here',
159
+ })
160
+ }
161
+ return options
162
+ }
163
+
164
+ function harnessHint(installations: readonly { harness: string }[]): string {
165
+ return [...new Set(installations.map((installation) => installation.harness))].join(', ')
166
+ }
167
+
65
168
  type Screen =
66
169
  | 'test'
67
170
  | 'devices'
@@ -87,6 +190,7 @@ export async function interactiveCommand(deps: CommandDeps, version: string): Pr
87
190
  message: 'What would you like to do?',
88
191
  options: menuFor(readiness),
89
192
  })
193
+ // Root is the only place Escape/Ctrl-C quits. Every nested prompt returns.
90
194
  if (cancelled(choice)) return farewell()
91
195
 
92
196
  switch (choice) {
@@ -94,28 +198,38 @@ export async function interactiveCommand(deps: CommandDeps, version: string): Pr
94
198
  return farewell()
95
199
  case 'setup':
96
200
  await initCommand(deps, {})
97
- readiness = await assess(deps)
201
+ readiness = await refresh(deps, readiness, refreshAfterMenuAction('setup', true))
98
202
  break
99
- case 'account':
100
- if (await accountScreen(deps, readiness)) readiness = await assess(deps)
203
+ case 'account': {
204
+ const changed = await accountScreen(deps, readiness)
205
+ readiness = await refresh(deps, readiness, refreshAfterMenuAction('account', changed))
101
206
  break
207
+ }
102
208
  case 'test':
103
209
  await testNotificationScreen(deps)
104
- readiness = await assess(deps)
105
210
  break
106
211
  case 'devices':
107
212
  await devicesScreen(deps)
108
213
  break
109
- case 'settings':
110
- await settingsScreen(deps)
111
- readiness = await assess(deps)
214
+ case 'settings': {
215
+ const result = await settingsScreen(deps)
216
+ readiness = await refresh(
217
+ deps,
218
+ readiness,
219
+ refreshAfterMenuAction('settings', result !== 'unchanged', { remote: result === 'remote' }),
220
+ )
112
221
  break
113
- case 'routing':
114
- if (await routingScreen(deps, readiness)) readiness = await assess(deps)
222
+ }
223
+ case 'routing': {
224
+ const changed = await routingScreen(deps, readiness)
225
+ readiness = await refresh(deps, readiness, refreshAfterMenuAction('routing', changed))
115
226
  break
227
+ }
116
228
  case 'doctor':
117
- await doctorCommand(deps, {})
229
+ // One assessment serves the report and the redraw. Doctor does not
230
+ // change setup, so running the graph again would only wait.
118
231
  readiness = await assess(deps)
232
+ await doctorCommand(deps, {}, { readiness })
119
233
  break
120
234
  }
121
235
  }
@@ -126,16 +240,27 @@ function farewell(): number {
126
240
  return EXIT.ok
127
241
  }
128
242
 
129
- async function assess(deps: CommandDeps): Promise<Readiness> {
243
+ async function assess(
244
+ deps: CommandDeps,
245
+ options: { previous?: Readiness; refresh?: readonly ReadinessRefresh[] } = {},
246
+ ): Promise<Readiness> {
130
247
  const spinner = clack.spinner()
131
248
  spinner.start('Checking your setup')
132
249
  try {
133
- return await assessReadiness(deps)
250
+ return await assessReadiness(deps, options)
134
251
  } finally {
135
252
  spinner.stop(style.dim('Setup checked'))
136
253
  }
137
254
  }
138
255
 
256
+ async function refresh(
257
+ deps: CommandDeps,
258
+ previous: Readiness,
259
+ next: readonly ReadinessRefresh[] | null,
260
+ ): Promise<Readiness> {
261
+ return next === null ? previous : assess(deps, { previous, refresh: next })
262
+ }
263
+
139
264
  // ---------------------------------------------------------------------------
140
265
  // Status
141
266
  // ---------------------------------------------------------------------------
@@ -294,23 +419,21 @@ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
294
419
  })
295
420
  if (cancelled(body)) return
296
421
 
297
- const sound = await clack.select<string>({
298
- message: 'Sound',
422
+ const kind = await clack.select<string>({
423
+ message: 'Kind',
299
424
  options: [
300
- { value: 'default', label: 'Default', hint: 'the standard tone' },
301
- { value: 'done', label: 'Done', hint: 'the completion chime' },
302
- { value: 'attention', label: 'Attention' },
303
- { value: 'none', label: 'Silent' },
425
+ { value: 'update', label: 'Update', hint: 'something happened' },
426
+ { value: 'done', label: 'Done', hint: 'work finished' },
304
427
  ],
305
- initialValue: 'default',
428
+ initialValue: 'done',
306
429
  })
307
- if (cancelled(sound)) return
430
+ if (cancelled(kind)) return
308
431
 
309
432
  clack.log.step('Sending, then waiting for your devices to confirm…')
310
433
  const code = await sendCommand(deps, {
311
434
  title: String(title),
312
435
  body: String(body),
313
- sound: String(sound),
436
+ kind: String(kind),
314
437
  ...(project !== null ? { project } : {}),
315
438
  })
316
439
  if (code === EXIT.ok) clack.log.success('Delivered. Check the device it landed on.')
@@ -359,7 +482,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
359
482
  // Settings
360
483
  // ---------------------------------------------------------------------------
361
484
 
362
- async function settingsScreen(deps: CommandDeps): Promise<void> {
485
+ async function settingsScreen(deps: CommandDeps): Promise<'unchanged' | 'local' | 'remote'> {
486
+ let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
363
487
  for (;;) {
364
488
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
365
489
  const group = await clack.select<ConfigGroup | 'back'>({
@@ -373,8 +497,10 @@ async function settingsScreen(deps: CommandDeps): Promise<void> {
373
497
  { value: 'back' as const, label: '← Back' },
374
498
  ],
375
499
  })
376
- if (cancelled(group) || group === 'back') return
377
- await settingsGroupScreen(deps, group as ConfigGroup)
500
+ if (cancelled(group) || group === 'back') return result
501
+ const next = await settingsGroupScreen(deps, group as ConfigGroup)
502
+ if (next === 'remote') result = 'remote'
503
+ else if (next === 'local' && result === 'unchanged') result = 'local'
378
504
  }
379
505
  }
380
506
 
@@ -385,7 +511,11 @@ function changedCount(config: CliConfig, group: ConfigGroup): string {
385
511
  return changed === 0 ? style.dim('all default') : style.dim(`${changed} changed`)
386
512
  }
387
513
 
388
- async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promise<void> {
514
+ async function settingsGroupScreen(
515
+ deps: CommandDeps,
516
+ group: ConfigGroup,
517
+ ): Promise<'unchanged' | 'local' | 'remote'> {
518
+ let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
389
519
  for (;;) {
390
520
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
391
521
  const info = CONFIG_GROUPS.find((entry) => entry.id === group)!
@@ -407,8 +537,10 @@ async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promi
407
537
  { value: 'back' as const, label: '← Back' },
408
538
  ],
409
539
  })
410
- if (cancelled(key) || key === 'back') return
411
- await settingDetailScreen(deps, key as ConfigKey)
540
+ if (cancelled(key) || key === 'back') return result
541
+ const next = await settingDetailScreen(deps, key as ConfigKey)
542
+ if (next === 'remote') result = 'remote'
543
+ else if (next === 'local' && result === 'unchanged') result = 'local'
412
544
  }
413
545
  }
414
546
 
@@ -419,7 +551,10 @@ async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promi
419
551
  * so that arriving here means the reader has already said which key they care
420
552
  * about.
421
553
  */
422
- async function settingDetailScreen(deps: CommandDeps, key: ConfigKey): Promise<void> {
554
+ async function settingDetailScreen(
555
+ deps: CommandDeps,
556
+ key: ConfigKey,
557
+ ): Promise<'unchanged' | 'local' | 'remote'> {
423
558
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
424
559
  const info = configInfo(key)
425
560
  const resolved = config[key]
@@ -444,20 +579,22 @@ async function settingDetailScreen(deps: CommandDeps, key: ConfigKey): Promise<v
444
579
  ],
445
580
  initialValue: 'back',
446
581
  })
447
- if (cancelled(action) || action === 'back') return
582
+ if (cancelled(action) || action === 'back') return 'unchanged'
448
583
 
449
584
  const value = await promptForValue(deps, key)
450
- if (value === null) return
585
+ if (value === null) return 'unchanged'
451
586
 
452
587
  const layer = await chooseLayer()
453
- if (layer === null) return
588
+ if (layer === null) return 'unchanged'
454
589
 
455
590
  const code = await configSetCommand(deps, key, value, {
456
591
  yes: true,
457
592
  ...(layer === 'project' ? { project: true } : {}),
458
593
  ...(layer === 'local' ? { local: true } : {}),
459
594
  })
460
- if (code === EXIT.ok) clack.log.success(`${info.label} is now ${formatValueRaw(key, value)}`)
595
+ if (code !== EXIT.ok) return 'unchanged'
596
+ clack.log.success(`${info.label} is now ${formatValueRaw(key, value)}`)
597
+ return 'local'
461
598
  }
462
599
 
463
600
  function formatValueRaw(key: ConfigKey, raw: string): string {
@@ -589,6 +726,9 @@ function wiringSummary(hooks: ReadinessState | undefined): string {
589
726
  async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
590
727
  const hooks = stateById(readiness, 'hooks')
591
728
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
729
+ const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
730
+ const projectInstallations = installations.filter((installation) => !installation.global)
731
+ const machineInstallations = installations.filter((installation) => installation.global)
592
732
 
593
733
  const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
594
734
  clack.note(
@@ -604,17 +744,14 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
604
744
  'Question routing',
605
745
  )
606
746
 
607
- const action = await clack.select<'install' | 'settings' | 'back'>({
747
+ const action = await clack.select<RoutingAction>({
608
748
  message: 'Question routing',
609
- options: [
610
- {
611
- value: 'install',
612
- label: hooks?.status === 'ready' ? 'Re-install hooks for this harness' : 'Wire up my harness',
613
- hint: 'Claude Code, Codex, Cursor, OpenCode',
614
- },
615
- { value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
616
- { value: 'back', label: '← Back' },
617
- ],
749
+ options: routingHookActions({
750
+ canInstall: detectedHarnesses(deps.cwd, deps.env).length > 0,
751
+ projectInstallations,
752
+ machineInstallations,
753
+ hooksReady: hooks?.status === 'ready',
754
+ }),
618
755
  })
619
756
  if (cancelled(action) || action === 'back') return false
620
757
 
@@ -623,16 +760,51 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
623
760
  return true
624
761
  }
625
762
 
626
- const scope = await clack.select<'project' | 'global'>({
627
- message: 'Install for',
763
+ if (action === 'install') return await installHooksInteractively(deps)
764
+ return await uninstallHooksInteractively(deps, projectInstallations, machineInstallations)
765
+ }
766
+
767
+ async function installHooksInteractively(deps: CommandDeps): Promise<boolean> {
768
+ const scope = await clack.select<HookScope>({
769
+ message: 'Install hooks for',
628
770
  options: [
629
- { value: 'project', label: 'This project only' },
630
- { value: 'global', label: 'Every project on this machine' },
771
+ { value: 'project', label: 'This project', hint: 'hooks only in this directory' },
772
+ { value: 'machine', label: 'This machine', hint: 'hooks for every project here' },
631
773
  ],
632
774
  initialValue: 'project',
633
775
  })
634
776
  if (cancelled(scope)) return false
635
- hooksInstallCommand(deps, scope === 'global' ? { global: true } : {})
777
+ hooksInstallCommand(deps, scope === 'machine' ? { global: true } : {})
778
+ return true
779
+ }
780
+
781
+ async function uninstallHooksInteractively(
782
+ deps: CommandDeps,
783
+ projectInstallations: Installation[],
784
+ machineInstallations: Installation[],
785
+ ): Promise<boolean> {
786
+ const scopes = uninstallScopeOptions(projectInstallations, machineInstallations)
787
+ if (scopes.length === 0) return false
788
+
789
+ let scope: HookScope
790
+ if (scopes.length === 1) {
791
+ scope = scopes[0]!.value
792
+ } else {
793
+ const picked = await clack.select<HookScope | 'back'>({
794
+ message: 'Uninstall hooks from',
795
+ options: [...scopes, { value: 'back', label: '← Back' }],
796
+ })
797
+ if (cancelled(picked) || picked === 'back') return false
798
+ scope = picked
799
+ }
800
+
801
+ const targets = scope === 'machine' ? machineInstallations : projectInstallations
802
+ for (const installation of targets) {
803
+ hooksUninstallCommand(deps, {
804
+ harness: installation.harness,
805
+ global: installation.global,
806
+ })
807
+ }
636
808
  return true
637
809
  }
638
810
 
package/src/main.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
3
  import { Command } from 'commander'
6
4
  import {
7
5
  askCommand,
@@ -30,6 +28,7 @@ import {
30
28
  type CommandDeps,
31
29
  } from './commands.js'
32
30
  import { defaultCredentialStore } from './credentials.js'
31
+ import { packageVersion } from './release.js'
33
32
  import { HARNESSES } from './install-hooks.js'
34
33
  import type { Platform } from '@raidiant/notifai-protocol'
35
34
  import { nativeSkills, type SkillScope } from './native-skills.js'
@@ -64,18 +63,13 @@ const deps: CommandDeps = {
64
63
  * does not hold — `package.json` moved and `notifai --version` kept reporting
65
64
  * the old number, which is exactly the string someone pastes into a bug report
66
65
  * to say what they are running.
66
+ *
67
+ * `unknown` is the display form of "this build cannot tell"; the skill pin
68
+ * derived from the same source refuses instead, because a wrong ref installs
69
+ * the wrong thing while a wrong version string only misinforms.
67
70
  */
68
71
  function version(): string {
69
- try {
70
- const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
71
- const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
72
- if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'string') {
73
- return (parsed as { version: string }).version
74
- }
75
- } catch {
76
- // Fall through: an unreadable manifest must not stop the CLI running.
77
- }
78
- return 'unknown'
72
+ return packageVersion() ?? 'unknown'
79
73
  }
80
74
 
81
75
  /**
@@ -205,7 +199,7 @@ program
205
199
  .summary('Sign in and pair this machine')
206
200
  .description('Pair this machine with your Notifai account via browser approval')
207
201
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
208
- .option('--base-url <url>', 'Notifai server URL')
202
+ .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
209
203
  .option('--no-open', 'do not open the approval page in a browser')
210
204
  .action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
211
205
  process.exit(await loginCommand(deps, opts))
@@ -281,8 +275,6 @@ const send = program
281
275
  .option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
282
276
  .option('--event <event>', 'agent event name, e.g. tests_passed')
283
277
  .optionsGroup(SEND_GROUP.presentation)
284
- .option('--sound <sound>', 'default | done | attention | alert | none')
285
- .option('--level <level>', 'interruption level: passive | active | time_sensitive')
286
278
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
287
279
  .option('--thread-id <id>', 'group related notifications')
288
280
  .option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
@@ -301,11 +293,13 @@ const send = program
301
293
  // a message pointing at `ask` instead of "unknown option".
302
294
  .option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
303
295
  .optionsGroup(SEND_GROUP.advanced)
296
+ .option('--sound <sound>', 'override kind profile / saved config: default | done | attention | alert | none')
297
+ .option('--level <level>', 'override kind profile / saved config: passive | active | time_sensitive')
304
298
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
305
299
  .option('--no-wait', 'return immediately after acceptance')
306
300
  .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
307
301
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
308
- .option('--base-url <url>', 'Notifai server URL')
302
+ .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
309
303
  .option('--json', 'print the full submission receipt as JSON')
310
304
  .action(async (opts: Record<string, unknown>) => {
311
305
  // commander maps --no-wait onto the same "wait" flag; disentangle.
@@ -342,7 +336,7 @@ const send = program
342
336
 
343
337
  send.addHelpText(
344
338
  'after',
345
- `\nKind profiles (used unless --sound, --level, or saved user config overrides them):\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
339
+ `\nKind profiles apply automatically. Pass --sound or --level only to override them or saved user config:\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
346
340
  )
347
341
 
348
342
  program
@@ -461,7 +455,7 @@ hooks
461
455
  .description('Wire this harness to route registered questions to your devices')
462
456
  .option(
463
457
  '--harness <name>',
464
- 'claude-code | codex | cursor | opencode (default: detected)',
458
+ 'claude-code | codex | cursor | opencode (default: every detected harness)',
465
459
  )
466
460
  .option('--global', 'install for every project instead of just this one')
467
461
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -531,7 +525,7 @@ config
531
525
  'Set a configuration value (choose a layer interactively; unattended defaults machine-global and requires --yes)',
532
526
  )
533
527
  .option('--project', 'write to the shared .notifai/config.toml instead')
534
- .option('--local', 'write to personal .notifai/config.local.toml (keep it gitignored)')
528
+ .option('--local', 'write a personal project preference on this machine (not in the repo)')
535
529
  .option('--session <id>', 'apply only to one session')
536
530
  .option('--yes', 'skip the confirmation gate')
537
531
  .action(
@@ -549,7 +543,7 @@ config
549
543
  'Remove a configuration value so the next layer or shipped default applies (choose a layer interactively; unattended defaults machine-global and requires --yes)',
550
544
  )
551
545
  .option('--project', 'remove from the shared .notifai/config.toml instead')
552
- .option('--local', 'remove from personal .notifai/config.local.toml')
546
+ .option('--local', 'remove a personal project preference stored on this machine')
553
547
  .option('--session <id>', 'remove from one session')
554
548
  .option('--yes', 'skip the confirmation gate')
555
549
  .action(
@@ -0,0 +1,75 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { nativeSkills } from './native-skills.js'
6
+
7
+ function writeLock(file: string, ref: string): void {
8
+ mkdirSync(path.dirname(file), { recursive: true })
9
+ writeFileSync(
10
+ file,
11
+ `${JSON.stringify({
12
+ version: 1,
13
+ skills: {
14
+ notifai: {
15
+ source: 'Raidiant-io/notifai',
16
+ sourceType: 'github',
17
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
18
+ ref,
19
+ },
20
+ },
21
+ })}\n`,
22
+ )
23
+ }
24
+
25
+ describe('nativeSkills.list', () => {
26
+ it('reads the project lock file instead of spawning npx', async () => {
27
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-project-'))
28
+ writeLock(path.join(cwd, 'skills-lock.json'), 'v0.5.1')
29
+
30
+ const result = await nativeSkills.list('project', cwd, { PATH: '/nonexistent' })
31
+ expect(result.error).toBeUndefined()
32
+ expect(result.skills).toEqual([
33
+ expect.objectContaining({
34
+ name: 'notifai',
35
+ scope: 'project',
36
+ source: 'Raidiant-io/notifai',
37
+ sourceType: 'github',
38
+ ref: 'v0.5.1',
39
+ }),
40
+ ])
41
+ })
42
+
43
+ it('reads the XDG global lock file instead of spawning npx', async () => {
44
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-xdg-'))
45
+ const state = path.join(cwd, 'state')
46
+ writeLock(path.join(state, 'skills', '.skill-lock.json'), 'v0.4.0')
47
+
48
+ const result = await nativeSkills.list('global', cwd, {
49
+ PATH: '/nonexistent',
50
+ XDG_STATE_HOME: state,
51
+ })
52
+ expect(result.error).toBeUndefined()
53
+ expect(result.skills).toEqual([
54
+ expect.objectContaining({ name: 'notifai', scope: 'global', ref: 'v0.4.0' }),
55
+ ])
56
+ })
57
+
58
+ it('falls back to the home-directory global lock', async () => {
59
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-home-'))
60
+ const home = path.join(cwd, 'home')
61
+ writeLock(path.join(home, '.agents', '.skill-lock.json'), 'v0.3.0')
62
+
63
+ const result = await nativeSkills.list('global', cwd, { PATH: '/nonexistent', HOME: home })
64
+ expect(result.error).toBeUndefined()
65
+ expect(result.skills).toEqual([
66
+ expect.objectContaining({ name: 'notifai', scope: 'global', ref: 'v0.3.0' }),
67
+ ])
68
+ })
69
+
70
+ it('treats a missing lock as an empty inventory, not an installer error', async () => {
71
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skills-missing-'))
72
+ const result = await nativeSkills.list('project', cwd, { PATH: '/nonexistent' })
73
+ expect(result).toEqual({ skills: [] })
74
+ })
75
+ })