@raidiant/notifai 0.2.1 → 0.4.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 (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * The interactive app — what `notifai` does when a person runs it.
3
+ *
4
+ * Everything here is a shell around commands that already exist. `init`,
5
+ * `login`, `doctor`, `send`, `hooks install` and `config set` keep their
6
+ * behaviour, their validation and their exit codes; this module decides what to
7
+ * offer, in what order, and how to say it. That is deliberate: a second
8
+ * implementation of "set a config value" would be a second set of bugs, and the
9
+ * menu would slowly stop agreeing with the flags.
10
+ *
11
+ * The module is imported dynamically, only after the caller is known to be a
12
+ * human at a terminal. Nothing an agent runs — `send`, `ask`, and above all the
13
+ * hook that runs in front of every prompt the user types — may pay to load a
14
+ * prompt library it will never draw.
15
+ *
16
+ * Three things shape the layout:
17
+ *
18
+ * The menu leads with whatever is actually in the way. Someone who is not
19
+ * signed in is offered sign-in first; someone with no device that can receive
20
+ * is offered a device; everyone else is offered the test notification, because
21
+ * seeing the thing arrive on your phone is what makes the product real.
22
+ *
23
+ * Detail is earned, not given. A settings group shows fourteen one-line
24
+ * summaries, and the paragraph explaining a key appears once the reader has
25
+ * picked that key.
26
+ *
27
+ * Cancelling always goes back one level and never leaves the terminal in a
28
+ * strange state.
29
+ */
30
+ import * as clack from '@clack/prompts'
31
+ import {
32
+ EXIT,
33
+ assessReadiness,
34
+ canDeviceReceive,
35
+ configSetCommand,
36
+ deviceInventory,
37
+ doctorCommand,
38
+ hooksInstallCommand,
39
+ initCommand,
40
+ loginCommand,
41
+ logoutCommand,
42
+ sendCommand,
43
+ type CommandDeps,
44
+ } from './commands.js'
45
+ import { CONFIG_KEYS, loadConfig, type CliConfig, type ConfigKey } from './config.js'
46
+ import {
47
+ CONFIG_GROUPS,
48
+ acceptedValues,
49
+ boundsHint,
50
+ configInfo,
51
+ configKeysByGroup,
52
+ describeSource,
53
+ formatValue,
54
+ type ConfigGroup,
55
+ } from './config-schema.js'
56
+ import { firstBlocker, type Readiness, type ReadinessState } from './readiness.js'
57
+ import { printBanner, tagline } from './ui/banner.js'
58
+ import { card, glyph, mark, style, type Tone } from './ui/theme.js'
59
+
60
+ /** Clack returns a symbol when the user pressed Ctrl-C or Escape. */
61
+ function cancelled(value: unknown): boolean {
62
+ return clack.isCancel(value)
63
+ }
64
+
65
+ type Screen =
66
+ | 'test'
67
+ | 'devices'
68
+ | 'settings'
69
+ | 'routing'
70
+ | 'doctor'
71
+ | 'account'
72
+ | 'setup'
73
+ | 'quit'
74
+
75
+ export async function interactiveCommand(deps: CommandDeps, version: string): Promise<number> {
76
+ await printBanner()
77
+ process.stdout.write(` ${tagline(version)}\n`)
78
+
79
+ let readiness = await assess(deps)
80
+ let first = true
81
+
82
+ for (;;) {
83
+ renderStatus(readiness, { compact: !first })
84
+ first = false
85
+
86
+ const choice = await clack.select<Screen>({
87
+ message: 'What would you like to do?',
88
+ options: menuFor(readiness),
89
+ })
90
+ if (cancelled(choice)) return farewell()
91
+
92
+ switch (choice) {
93
+ case 'quit':
94
+ return farewell()
95
+ case 'setup':
96
+ await initCommand(deps, {})
97
+ readiness = await assess(deps)
98
+ break
99
+ case 'account':
100
+ if (await accountScreen(deps, readiness)) readiness = await assess(deps)
101
+ break
102
+ case 'test':
103
+ await testNotificationScreen(deps)
104
+ readiness = await assess(deps)
105
+ break
106
+ case 'devices':
107
+ await devicesScreen(deps)
108
+ break
109
+ case 'settings':
110
+ await settingsScreen(deps)
111
+ readiness = await assess(deps)
112
+ break
113
+ case 'routing':
114
+ if (await routingScreen(deps, readiness)) readiness = await assess(deps)
115
+ break
116
+ case 'doctor':
117
+ await doctorCommand(deps, {})
118
+ readiness = await assess(deps)
119
+ break
120
+ }
121
+ }
122
+ }
123
+
124
+ function farewell(): number {
125
+ clack.outro(style.dim(`${glyph.bullet} bye`))
126
+ return EXIT.ok
127
+ }
128
+
129
+ async function assess(deps: CommandDeps): Promise<Readiness> {
130
+ const spinner = clack.spinner()
131
+ spinner.start('Checking your setup')
132
+ try {
133
+ return await assessReadiness(deps)
134
+ } finally {
135
+ spinner.stop(style.dim('Setup checked'))
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Status
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function stateById(readiness: Readiness, id: string): ReadinessState | undefined {
144
+ return readiness.states.find((state) => state.id === id)
145
+ }
146
+
147
+ function toneOf(state: ReadinessState | undefined): Tone {
148
+ if (state === undefined) return 'pending'
149
+ switch (state.status) {
150
+ case 'ready':
151
+ return 'ok'
152
+ case 'gap':
153
+ return 'bad'
154
+ case 'optional-gap':
155
+ return 'warn'
156
+ case 'unknown':
157
+ return 'pending'
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The status card: four lines that answer "is this working", with the detail
163
+ * behind `doctor` rather than in front of everyone.
164
+ *
165
+ * `doctor` reports fourteen states because that is what a report is for. Home
166
+ * shows the four that change what you would do next, which is why this reads
167
+ * the same readiness model rather than a second one that could disagree.
168
+ */
169
+ function renderStatus(readiness: Readiness, options: { compact?: boolean }): void {
170
+ const rows: { label: string; state: ReadinessState | undefined; summary: (s?: ReadinessState) => string }[] = [
171
+ {
172
+ label: 'Account',
173
+ state: stateById(readiness, 'credential'),
174
+ summary: (s) => (s?.status === 'ready' ? accountSummary(readiness) : 'not signed in'),
175
+ },
176
+ {
177
+ label: 'Devices',
178
+ state: stateById(readiness, 'devices'),
179
+ summary: (s) => shorten(s?.detail ?? 'unknown'),
180
+ },
181
+ {
182
+ label: 'Project',
183
+ state: stateById(readiness, 'project'),
184
+ summary: (s) => (s?.status === 'ready' ? firstQuoted(s.detail) : 'not named'),
185
+ },
186
+ {
187
+ label: 'Questions',
188
+ state: stateById(readiness, 'hooks'),
189
+ summary: (s) => (s?.status === 'ready' ? 'routing to your devices' : shorten(s?.detail ?? 'not wired')),
190
+ },
191
+ ]
192
+
193
+ if (options.compact === true) {
194
+ const summary = rows.map((row) => `${mark(toneOf(row.state))} ${style.dim(row.label)}`).join(' ')
195
+ process.stdout.write(`\n ${summary}\n\n`)
196
+ return
197
+ }
198
+
199
+ const labelWidth = Math.max(...rows.map((row) => row.label.length))
200
+ const body = rows.map(
201
+ (row) => `${mark(toneOf(row.state))} ${style.dim(row.label.padEnd(labelWidth))} ${row.summary(row.state)}`,
202
+ )
203
+ process.stdout.write('\n')
204
+ for (const line of card('Status', body)) process.stdout.write(`${line}\n`)
205
+ process.stdout.write('\n')
206
+ }
207
+
208
+ function accountSummary(readiness: Readiness): string {
209
+ const auth = stateById(readiness, 'auth')
210
+ const email = auth?.detail.match(/\(([^)]+@[^)]+)\)/)?.[1]
211
+ return email ?? 'signed in'
212
+ }
213
+
214
+ function firstQuoted(text: string): string {
215
+ return text.match(/"([^"]+)"/)?.[1] ?? text
216
+ }
217
+
218
+ function shorten(text: string, limit = 46): string {
219
+ const flat = text.replace(/\s+/g, ' ').trim()
220
+ const clipped = flat.split(' — ')[0] ?? flat
221
+ return clipped.length > limit ? `${clipped.slice(0, limit - 1)}…` : clipped
222
+ }
223
+
224
+ /**
225
+ * The menu, ordered by what is actually in the way.
226
+ *
227
+ * A fixed menu would put "Send a test notification" first for someone who has
228
+ * not signed in — an action that cannot succeed, offered ahead of the one that
229
+ * would unblock it.
230
+ */
231
+ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: string }[] {
232
+ const signedIn = stateById(readiness, 'credential')?.status === 'ready'
233
+ const devices = stateById(readiness, 'devices')
234
+ const hasDevice = devices?.status === 'ready'
235
+ const routing = stateById(readiness, 'hooks')
236
+ const blocker = firstBlocker(readiness)
237
+
238
+ const options: { value: Screen; label: string; hint?: string }[] = []
239
+
240
+ if (!signedIn) {
241
+ options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
242
+ } else if (!hasDevice) {
243
+ options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your phone or Mac' })
244
+ } else {
245
+ options.push({
246
+ value: 'test',
247
+ label: 'Send a test notification',
248
+ hint: 'prove the whole path, end to end',
249
+ })
250
+ }
251
+
252
+ if (blocker !== null && signedIn) {
253
+ options.push({ value: 'setup', label: 'Finish setup', hint: shorten(blocker.title.toLowerCase(), 40) })
254
+ }
255
+
256
+ options.push(
257
+ { value: 'settings', label: 'Settings', hint: 'sounds, timing, presence, project' },
258
+ {
259
+ value: 'routing',
260
+ label: 'Question routing',
261
+ hint: routing?.status === 'ready' ? 'wired — review or change' : 'let agents reach you when you step away',
262
+ },
263
+ )
264
+ if (signedIn && hasDevice) {
265
+ options.push({ value: 'devices', label: 'Devices', hint: 'what can receive right now' })
266
+ }
267
+ options.push(
268
+ { value: 'doctor', label: 'Run diagnostics', hint: 'check every part of the setup' },
269
+ { value: 'account', label: 'Account', hint: signedIn ? 'plan, machine, sign out' : 'sign in' },
270
+ { value: 'quit', label: 'Quit' },
271
+ )
272
+ return options
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Test notification
277
+ // ---------------------------------------------------------------------------
278
+
279
+ async function testNotificationScreen(deps: CommandDeps): Promise<void> {
280
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
281
+ const project = config.project.value
282
+
283
+ const title = await clack.text({
284
+ message: 'Title',
285
+ placeholder: 'Test from Notifai',
286
+ defaultValue: 'Test from Notifai',
287
+ })
288
+ if (cancelled(title)) return
289
+
290
+ const body = await clack.text({
291
+ message: 'Body',
292
+ placeholder: 'If you can read this on your phone, it works.',
293
+ defaultValue: 'If you can read this on your phone, it works.',
294
+ })
295
+ if (cancelled(body)) return
296
+
297
+ const sound = await clack.select<string>({
298
+ message: 'Sound',
299
+ 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' },
304
+ ],
305
+ initialValue: 'default',
306
+ })
307
+ if (cancelled(sound)) return
308
+
309
+ clack.log.step('Sending, then waiting for your devices to confirm…')
310
+ const code = await sendCommand(deps, {
311
+ title: String(title),
312
+ body: String(body),
313
+ sound: String(sound),
314
+ ...(project !== null ? { project } : {}),
315
+ })
316
+ if (code === EXIT.ok) clack.log.success('Delivered. Check the device it landed on.')
317
+ else clack.log.error('That did not get through — `Run diagnostics` will say why.')
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Devices
322
+ // ---------------------------------------------------------------------------
323
+
324
+ async function devicesScreen(deps: CommandDeps): Promise<void> {
325
+ const spinner = clack.spinner()
326
+ spinner.start('Loading your devices')
327
+ const devices = await deviceInventory(deps)
328
+ spinner.stop(style.dim('Devices loaded'))
329
+
330
+ if (devices === null) {
331
+ clack.log.warn('Could not reach the service. Sign in, or check the connection.')
332
+ return
333
+ }
334
+ if (devices.length === 0) {
335
+ clack.note(
336
+ 'No devices are registered yet.\n\n' +
337
+ 'Install the Notifai companion app on your iPhone or Mac, sign in with the\n' +
338
+ 'same account, and allow notifications when it asks.',
339
+ 'No devices',
340
+ )
341
+ return
342
+ }
343
+
344
+ const body = devices
345
+ .map((device) => {
346
+ const ready = canDeviceReceive(device)
347
+ const detail = ready ? 'ready' : `not ready ${glyph.bullet} permission: ${device.permission_status}`
348
+ return (
349
+ `${mark(ready ? 'ok' : 'warn')} ${style.value(device.display_name)} ${style.dim(`(${device.platform})`)}\n` +
350
+ ` ${style.dim(detail)}\n` +
351
+ ` ${style.dim(device.device_id)}`
352
+ )
353
+ })
354
+ .join('\n\n')
355
+ clack.note(body, `${devices.length} device${devices.length === 1 ? '' : 's'}`)
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Settings
360
+ // ---------------------------------------------------------------------------
361
+
362
+ async function settingsScreen(deps: CommandDeps): Promise<void> {
363
+ for (;;) {
364
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
365
+ const group = await clack.select<ConfigGroup | 'back'>({
366
+ message: 'Settings',
367
+ options: [
368
+ ...CONFIG_GROUPS.map((entry) => ({
369
+ value: entry.id,
370
+ label: entry.title,
371
+ hint: changedCount(config, entry.id),
372
+ })),
373
+ { value: 'back' as const, label: '← Back' },
374
+ ],
375
+ })
376
+ if (cancelled(group) || group === 'back') return
377
+ await settingsGroupScreen(deps, group as ConfigGroup)
378
+ }
379
+ }
380
+
381
+ /** "3 changed" beats repeating the group blurb the reader already saw. */
382
+ function changedCount(config: CliConfig, group: ConfigGroup): string {
383
+ const keys = configKeysByGroup(group)
384
+ const changed = keys.filter((info) => config[info.key].source !== 'default').length
385
+ return changed === 0 ? style.dim('all default') : style.dim(`${changed} changed`)
386
+ }
387
+
388
+ async function settingsGroupScreen(deps: CommandDeps, group: ConfigGroup): Promise<void> {
389
+ for (;;) {
390
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
391
+ const info = CONFIG_GROUPS.find((entry) => entry.id === group)!
392
+ const keys = configKeysByGroup(group)
393
+
394
+ const key = await clack.select<ConfigKey | 'back'>({
395
+ message: info.title,
396
+ options: [
397
+ ...keys.map((entry) => {
398
+ const resolved = config[entry.key]
399
+ const source = describeSource(resolved.source)
400
+ const value = formatValue(entry.key, resolved.value)
401
+ return {
402
+ value: entry.key,
403
+ label: entry.label,
404
+ hint: source.label === 'default' ? style.dim(value) : `${value} ${style.dim(`(${source.label})`)}`,
405
+ }
406
+ }),
407
+ { value: 'back' as const, label: '← Back' },
408
+ ],
409
+ })
410
+ if (cancelled(key) || key === 'back') return
411
+ await settingDetailScreen(deps, key as ConfigKey)
412
+ }
413
+ }
414
+
415
+ /**
416
+ * One key, fully explained, with the option to change it.
417
+ *
418
+ * This is where the paragraph lives. Everything above stayed one line per key
419
+ * so that arriving here means the reader has already said which key they care
420
+ * about.
421
+ */
422
+ async function settingDetailScreen(deps: CommandDeps, key: ConfigKey): Promise<void> {
423
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
424
+ const info = configInfo(key)
425
+ const resolved = config[key]
426
+ const source = describeSource(resolved.source)
427
+
428
+ const lines = [
429
+ info.detail,
430
+ '',
431
+ `${style.dim('Now')} ${style.value(formatValue(key, resolved.value))} ${style.dim(`${glyph.bullet} ${source.label}`)}`,
432
+ `${style.dim('Accepts')} ${acceptedValues(key)}`,
433
+ `${style.dim('Key')} ${style.code(key)}`,
434
+ ]
435
+ clack.note(lines.join('\n'), info.label)
436
+
437
+ const action = await clack.select<'change' | 'back'>({
438
+ message: 'Change it?',
439
+ options: [
440
+ // Not lower-cased: "Change only when i have stepped away" is what
441
+ // `toLowerCase` makes of a label that starts a sentence.
442
+ { value: 'change', label: `Change this setting`, hint: style.code(info.key) },
443
+ { value: 'back', label: '← Back' },
444
+ ],
445
+ initialValue: 'back',
446
+ })
447
+ if (cancelled(action) || action === 'back') return
448
+
449
+ const value = await promptForValue(deps, key)
450
+ if (value === null) return
451
+
452
+ const layer = await chooseLayer()
453
+ if (layer === null) return
454
+
455
+ const code = await configSetCommand(deps, key, value, {
456
+ yes: true,
457
+ ...(layer === 'project' ? { project: true } : {}),
458
+ ...(layer === 'local' ? { local: true } : {}),
459
+ })
460
+ if (code === EXIT.ok) clack.log.success(`${info.label} is now ${formatValueRaw(key, value)}`)
461
+ }
462
+
463
+ function formatValueRaw(key: ConfigKey, raw: string): string {
464
+ const info = configInfo(key)
465
+ if (info.kind === 'boolean') return raw === 'true' ? 'yes' : 'no'
466
+ return raw
467
+ }
468
+
469
+ /** The prompt shape follows the key's type; validation stays in `config set`. */
470
+ async function promptForValue(deps: CommandDeps, key: ConfigKey): Promise<string | null> {
471
+ const info = configInfo(key)
472
+
473
+ if (info.kind === 'boolean') {
474
+ const answer = await clack.select<string>({
475
+ message: info.label,
476
+ options: [
477
+ { value: 'true', label: 'Yes' },
478
+ { value: 'false', label: 'No' },
479
+ ],
480
+ })
481
+ return cancelled(answer) ? null : String(answer)
482
+ }
483
+
484
+ if (info.kind === 'enum') {
485
+ const answer = await clack.select<string>({
486
+ message: info.label,
487
+ options: (info.choices ?? []).map((choice) => ({
488
+ value: choice,
489
+ label: choice,
490
+ ...(info.choiceHints?.[choice] !== undefined ? { hint: info.choiceHints[choice]! } : {}),
491
+ })),
492
+ })
493
+ return cancelled(answer) ? null : String(answer)
494
+ }
495
+
496
+ // The one key whose legal values the machine knows better than the reader.
497
+ if (key === 'devices') return await promptForDevices(deps)
498
+
499
+ const bounds = boundsHint(key)
500
+ const answer = await clack.text({
501
+ message: info.label,
502
+ ...(info.example !== undefined ? { placeholder: info.example } : {}),
503
+ validate: (input) => {
504
+ const trimmed = (input ?? '').trim()
505
+ if (trimmed === '') return 'Enter a value, or press Escape to go back.'
506
+ if (info.kind === 'integer') {
507
+ if (!/^-?\d+$/.test(trimmed)) return `${info.label} is a whole number of seconds.`
508
+ const range = boundsRange(key)
509
+ if (bounds !== null && range !== null) {
510
+ const parsed = Number(trimmed)
511
+ if (parsed < range.min || parsed > range.max) return `Must be between ${bounds}.`
512
+ }
513
+ }
514
+ if (info.kind === 'url' && !/^https?:\/\//.test(trimmed)) return 'Must start with http:// or https://.'
515
+ return undefined
516
+ },
517
+ })
518
+ return cancelled(answer) ? null : String(answer).trim()
519
+ }
520
+
521
+ function boundsRange(key: ConfigKey): { min: number; max: number } | null {
522
+ const hint = boundsHint(key)
523
+ if (hint === null) return null
524
+ const [min, max] = hint.replace(/[^\d–]/g, '').split('–')
525
+ if (min === undefined || max === undefined) return null
526
+ return { min: Number(min), max: Number(max) }
527
+ }
528
+
529
+ async function promptForDevices(deps: CommandDeps): Promise<string | null> {
530
+ const spinner = clack.spinner()
531
+ spinner.start('Loading your devices')
532
+ const devices = await deviceInventory(deps)
533
+ spinner.stop(style.dim('Devices loaded'))
534
+ if (devices === null || devices.length === 0) {
535
+ clack.log.warn('No devices to choose from yet.')
536
+ return null
537
+ }
538
+ const picked = await clack.multiselect<string>({
539
+ message: 'Which devices should receive by default?',
540
+ options: devices.map((device) => ({
541
+ value: device.device_id,
542
+ label: device.display_name,
543
+ hint: canDeviceReceive(device) ? device.platform : `${device.platform} ${glyph.bullet} not ready`,
544
+ })),
545
+ required: true,
546
+ })
547
+ return cancelled(picked) ? null : (picked as string[]).join(',')
548
+ }
549
+
550
+ /**
551
+ * Which layer the value lands in.
552
+ *
553
+ * Asked every time rather than defaulting, because the difference between
554
+ * "my machine" and "everyone who clones this repository" is not recoverable by
555
+ * guessing, and the shared file is the one that ends up in a commit.
556
+ */
557
+ async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
558
+ const layer = await clack.select<'global' | 'project' | 'local'>({
559
+ message: 'Where should this apply?',
560
+ options: [
561
+ { value: 'global', label: 'This machine', hint: 'every project you work on here' },
562
+ { value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
563
+ { value: 'local', label: 'This project, just me', hint: 'config.local.toml — keep it gitignored' },
564
+ ],
565
+ initialValue: 'global',
566
+ })
567
+ return cancelled(layer) ? null : (layer as 'global' | 'project' | 'local')
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Question routing
572
+ // ---------------------------------------------------------------------------
573
+
574
+ /**
575
+ * Which harnesses are wired, without the install paths.
576
+ *
577
+ * The underlying state lists every installation with its absolute file path,
578
+ * which is the right level of detail for `doctor` and far too much for a
579
+ * summary card — four paths wrap across six lines and push the settings
580
+ * underneath them out of alignment.
581
+ */
582
+ function wiringSummary(hooks: ReadinessState | undefined): string {
583
+ if (hooks === undefined) return 'unknown'
584
+ const named = hooks.detail.replace(/\s*\([^)]*\)/g, '').trim()
585
+ return named === '' ? hooks.detail : named
586
+ }
587
+
588
+ /** Returns true when something changed and the status card needs refreshing. */
589
+ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
590
+ const hooks = stateById(readiness, 'hooks')
591
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
592
+
593
+ const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
594
+ clack.note(
595
+ 'When an agent registers a question, it appears in your terminal first.\n' +
596
+ 'After the grace below, it goes to your phone and Mac as a notification\n' +
597
+ 'with real answer buttons, and your answer comes back to the agent. Turn\n' +
598
+ 'on “Only when away” if local activity should keep it here instead.\n\n' +
599
+ [
600
+ row('Wired to', wiringSummary(hooks)),
601
+ row('Send', config.ask_notifications.value ? 'yes' : 'no — questions stay in the terminal'),
602
+ row(
603
+ 'Only when away',
604
+ config.require_idle.value ? 'yes' : 'no — reach me even while I am working',
605
+ ),
606
+ row('Away after', formatValue('away_after_seconds', config.away_after_seconds.value)),
607
+ row('Grace', formatValue('ask_grace_seconds', config.ask_grace_seconds.value)),
608
+ ].join('\n'),
609
+ 'Question routing',
610
+ )
611
+
612
+ const action = await clack.select<'install' | 'settings' | 'back'>({
613
+ message: 'Question routing',
614
+ options: [
615
+ {
616
+ value: 'install',
617
+ label: hooks?.status === 'ready' ? 'Re-install hooks for this harness' : 'Wire up my harness',
618
+ hint: 'Claude Code, Codex, Cursor, OpenCode',
619
+ },
620
+ { value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
621
+ { value: 'back', label: '← Back' },
622
+ ],
623
+ })
624
+ if (cancelled(action) || action === 'back') return false
625
+
626
+ if (action === 'settings') {
627
+ await settingsGroupScreen(deps, 'questions')
628
+ return true
629
+ }
630
+
631
+ const scope = await clack.select<'project' | 'global'>({
632
+ message: 'Install for',
633
+ options: [
634
+ { value: 'project', label: 'This project only' },
635
+ { value: 'global', label: 'Every project on this machine' },
636
+ ],
637
+ initialValue: 'project',
638
+ })
639
+ if (cancelled(scope)) return false
640
+ hooksInstallCommand(deps, scope === 'global' ? { global: true } : {})
641
+ return true
642
+ }
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // Account
646
+ // ---------------------------------------------------------------------------
647
+
648
+ /** Returns true when something changed and the status card needs refreshing. */
649
+ async function accountScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
650
+ const signedIn = stateById(readiness, 'credential')?.status === 'ready'
651
+
652
+ if (!signedIn) {
653
+ const go = await clack.confirm({
654
+ message: 'Sign in now? This opens your browser to approve this machine.',
655
+ initialValue: true,
656
+ })
657
+ if (cancelled(go) || go !== true) return false
658
+ await loginCommand(deps, {})
659
+ return true
660
+ }
661
+
662
+ const auth = stateById(readiness, 'auth')
663
+ const credential = stateById(readiness, 'credential')
664
+ clack.note(
665
+ `${style.dim('Machine')} ${credential?.detail ?? 'unknown'}\n${style.dim('Account')} ${auth?.detail ?? 'unknown'}`,
666
+ 'Account',
667
+ )
668
+
669
+ const action = await clack.select<'relogin' | 'logout' | 'back'>({
670
+ message: 'Account',
671
+ options: [
672
+ { value: 'relogin', label: 'Pair this machine again' },
673
+ { value: 'logout', label: 'Sign out', hint: 'removes the stored credential' },
674
+ { value: 'back', label: '← Back' },
675
+ ],
676
+ initialValue: 'back',
677
+ })
678
+ if (cancelled(action) || action === 'back') return false
679
+
680
+ if (action === 'logout') {
681
+ const sure = await clack.confirm({ message: 'Remove the stored credential?', initialValue: false })
682
+ if (cancelled(sure) || sure !== true) return false
683
+ logoutCommand(deps)
684
+ return true
685
+ }
686
+
687
+ await loginCommand(deps, {})
688
+ return true
689
+ }
690
+
691
+ /** Exported for the settings command to reuse the same key vocabulary. */
692
+ export const INTERACTIVE_CONFIG_KEYS = CONFIG_KEYS