@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
@@ -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
@@ -199,7 +199,7 @@ program
199
199
  .summary('Sign in and pair this machine')
200
200
  .description('Pair this machine with your Notifai account via browser approval')
201
201
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
202
- .option('--base-url <url>', 'Notifai server URL')
202
+ .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
203
203
  .option('--no-open', 'do not open the approval page in a browser')
204
204
  .action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
205
205
  process.exit(await loginCommand(deps, opts))
@@ -275,8 +275,6 @@ const send = program
275
275
  .option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
276
276
  .option('--event <event>', 'agent event name, e.g. tests_passed')
277
277
  .optionsGroup(SEND_GROUP.presentation)
278
- .option('--sound <sound>', 'default | done | attention | alert | none')
279
- .option('--level <level>', 'interruption level: passive | active | time_sensitive')
280
278
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
281
279
  .option('--thread-id <id>', 'group related notifications')
282
280
  .option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
@@ -295,11 +293,13 @@ const send = program
295
293
  // a message pointing at `ask` instead of "unknown option".
296
294
  .option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
297
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')
298
298
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
299
299
  .option('--no-wait', 'return immediately after acceptance')
300
300
  .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
301
301
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
302
- .option('--base-url <url>', 'Notifai server URL')
302
+ .option('--base-url <url>', 'developer override for the service origin (also NOTIFAI_BASE_URL)')
303
303
  .option('--json', 'print the full submission receipt as JSON')
304
304
  .action(async (opts: Record<string, unknown>) => {
305
305
  // commander maps --no-wait onto the same "wait" flag; disentangle.
@@ -336,7 +336,7 @@ const send = program
336
336
 
337
337
  send.addHelpText(
338
338
  'after',
339
- `\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`,
340
340
  )
341
341
 
342
342
  program
@@ -455,7 +455,7 @@ hooks
455
455
  .description('Wire this harness to route registered questions to your devices')
456
456
  .option(
457
457
  '--harness <name>',
458
- 'claude-code | codex | cursor | opencode (default: detected)',
458
+ 'claude-code | codex | cursor | opencode (default: every detected harness)',
459
459
  )
460
460
  .option('--global', 'install for every project instead of just this one')
461
461
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -525,7 +525,7 @@ config
525
525
  'Set a configuration value (choose a layer interactively; unattended defaults machine-global and requires --yes)',
526
526
  )
527
527
  .option('--project', 'write to the shared .notifai/config.toml instead')
528
- .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)')
529
529
  .option('--session <id>', 'apply only to one session')
530
530
  .option('--yes', 'skip the confirmation gate')
531
531
  .action(
@@ -543,7 +543,7 @@ config
543
543
  'Remove a configuration value so the next layer or shipped default applies (choose a layer interactively; unattended defaults machine-global and requires --yes)',
544
544
  )
545
545
  .option('--project', 'remove from the shared .notifai/config.toml instead')
546
- .option('--local', 'remove from personal .notifai/config.local.toml')
546
+ .option('--local', 'remove a personal project preference stored on this machine')
547
547
  .option('--session <id>', 'remove from one session')
548
548
  .option('--yes', 'skip the confirmation gate')
549
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
+ })