@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
@@ -1,858 +0,0 @@
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
- * First-run is `init`, not a second sign-in/device split. An unsigned machine
19
- * enters that journey immediately; a remaining blocker is offered as Finish
20
- * setup. Once send-prerequisites are ready, the menu leads with the test
21
- * notification, because seeing the thing arrive on a device is what makes the
22
- * product real.
23
- *
24
- * Detail is earned, not given. A settings group shows fourteen one-line
25
- * summaries, and the paragraph explaining a key appears once the reader has
26
- * picked that key.
27
- *
28
- * Cancelling always goes back one level and never leaves the terminal in a
29
- * strange state.
30
- */
31
- import * as clack from '@clack/prompts'
32
- import {
33
- EXIT,
34
- assessReadiness,
35
- canDeviceReceive,
36
- configSetCommand,
37
- deviceInventory,
38
- doctorCommand,
39
- hooksInstallCommand,
40
- hooksUninstallCommand,
41
- initCommand,
42
- loginCommand,
43
- logoutCommand,
44
- sendCommand,
45
- type CommandDeps,
46
- } from './commands.js'
47
- import { CONFIG_KEYS, loadConfig, type CliConfig, type ConfigKey } from './config.js'
48
- import {
49
- detectedHarnesses,
50
- findInstallations,
51
- type Installation,
52
- } from './install-hooks.js'
53
- import {
54
- CONFIG_GROUPS,
55
- acceptedValues,
56
- boundsHint,
57
- configInfo,
58
- configKeysByGroup,
59
- describeSource,
60
- formatValue,
61
- type ConfigGroup,
62
- } from './config-schema.js'
63
- import {
64
- firstBlocker,
65
- refreshAfterMenuAction,
66
- type Readiness,
67
- type ReadinessRefresh,
68
- type ReadinessState,
69
- } from './readiness.js'
70
- import { printBanner, tagline } from './ui/banner.js'
71
- import { card, glyph, mark, style, type Tone } from './ui/theme.js'
72
-
73
- /** Clack returns a symbol when the user pressed Ctrl-C or Escape. */
74
- function cancelled(value: unknown): value is symbol {
75
- return clack.isCancel(value)
76
- }
77
-
78
- export type HookScope = 'project' | 'machine'
79
-
80
- export type RoutingAction = 'install' | 'uninstall' | 'settings' | 'back'
81
-
82
- export interface MenuOption<Value extends string> {
83
- value: Value
84
- label: string
85
- hint?: string
86
- }
87
-
88
- /**
89
- * Which hook actions this directory and setup can actually perform.
90
- *
91
- * Install is offered only when a harness is detectable here. Uninstall is
92
- * offered only for a scope that already has Notifai hooks, and the label names
93
- * that scope so a machine-wide install is never stripped from a random folder
94
- * under a generic "uninstall".
95
- */
96
- export function routingHookActions(input: {
97
- canInstall: boolean
98
- projectInstallations: readonly { harness: string }[]
99
- machineInstallations: readonly { harness: string }[]
100
- hooksReady: boolean
101
- }): MenuOption<RoutingAction>[] {
102
- const options: MenuOption<RoutingAction>[] = []
103
- const hasProject = input.projectInstallations.length > 0
104
- const hasMachine = input.machineInstallations.length > 0
105
- const hasAny = hasProject || hasMachine
106
-
107
- if (input.canInstall) {
108
- options.push({
109
- value: 'install',
110
- label: input.hooksReady || hasAny ? 'Re-install hooks' : 'Install hooks',
111
- hint: 'this project or this machine',
112
- })
113
- }
114
-
115
- if (hasProject && hasMachine) {
116
- options.push({
117
- value: 'uninstall',
118
- label: 'Uninstall hooks',
119
- hint: 'this project or this machine',
120
- })
121
- } else if (hasProject) {
122
- options.push({
123
- value: 'uninstall',
124
- label: 'Uninstall hooks for this project',
125
- hint: harnessHint(input.projectInstallations),
126
- })
127
- } else if (hasMachine) {
128
- options.push({
129
- value: 'uninstall',
130
- label: 'Uninstall hooks on this machine',
131
- hint: harnessHint(input.machineInstallations),
132
- })
133
- }
134
-
135
- options.push(
136
- { value: 'settings', label: 'Change question settings', hint: 'when a question may leave this terminal' },
137
- { value: 'back', label: '← Back' },
138
- )
139
- return options
140
- }
141
-
142
- /** Scope choices that would actually remove something. Project first. */
143
- export function uninstallScopeOptions(
144
- projectInstallations: readonly { harness: string }[],
145
- machineInstallations: readonly { harness: string }[],
146
- ): MenuOption<HookScope>[] {
147
- const options: MenuOption<HookScope>[] = []
148
- if (projectInstallations.length > 0) {
149
- options.push({
150
- value: 'project',
151
- label: 'This project',
152
- hint: 'remove hooks from this directory',
153
- })
154
- }
155
- if (machineInstallations.length > 0) {
156
- options.push({
157
- value: 'machine',
158
- label: 'This machine',
159
- hint: 'remove hooks for every project here',
160
- })
161
- }
162
- return options
163
- }
164
-
165
- function harnessHint(installations: readonly { harness: string }[]): string {
166
- return [...new Set(installations.map((installation) => installation.harness))].join(', ')
167
- }
168
-
169
- type Screen =
170
- | 'test'
171
- | 'devices'
172
- | 'settings'
173
- | 'routing'
174
- | 'doctor'
175
- | 'account'
176
- | 'setup'
177
- | 'quit'
178
-
179
- export async function interactiveCommand(deps: CommandDeps, version: string): Promise<number> {
180
- await printBanner()
181
- process.stdout.write(` ${tagline(version)}\n`)
182
-
183
- let readiness = await assess(deps)
184
- if (stateById(readiness, 'credential')?.status !== 'ready') {
185
- await initCommand(deps, {})
186
- readiness = await assess(deps)
187
- }
188
- let first = true
189
-
190
- for (;;) {
191
- renderStatus(readiness, { compact: !first })
192
- first = false
193
-
194
- const choice = await clack.select<Screen>({
195
- message: 'What would you like to do?',
196
- options: menuFor(readiness),
197
- })
198
- // Root is the only place Escape/Ctrl-C quits. Every nested prompt returns.
199
- if (cancelled(choice)) return farewell()
200
-
201
- switch (choice) {
202
- case 'quit':
203
- return farewell()
204
- case 'setup':
205
- await initCommand(deps, {})
206
- readiness = await refresh(deps, readiness, refreshAfterMenuAction('setup', true))
207
- break
208
- case 'account': {
209
- const changed = await accountScreen(deps, readiness)
210
- readiness = await refresh(deps, readiness, refreshAfterMenuAction('account', changed))
211
- break
212
- }
213
- case 'test':
214
- await testNotificationScreen(deps)
215
- break
216
- case 'devices':
217
- await devicesScreen(deps)
218
- break
219
- case 'settings': {
220
- const result = await settingsScreen(deps)
221
- readiness = await refresh(
222
- deps,
223
- readiness,
224
- refreshAfterMenuAction('settings', result !== 'unchanged', { remote: result === 'remote' }),
225
- )
226
- break
227
- }
228
- case 'routing': {
229
- const changed = await routingScreen(deps, readiness)
230
- readiness = await refresh(deps, readiness, refreshAfterMenuAction('routing', changed))
231
- break
232
- }
233
- case 'doctor':
234
- // One assessment serves the report and the redraw. Doctor does not
235
- // change setup, so running the graph again would only wait.
236
- readiness = await assess(deps)
237
- await doctorCommand(deps, {}, { readiness })
238
- break
239
- }
240
- }
241
- }
242
-
243
- function farewell(): number {
244
- clack.outro(style.dim(`${glyph.bullet} bye`))
245
- return EXIT.ok
246
- }
247
-
248
- async function assess(
249
- deps: CommandDeps,
250
- options: { previous?: Readiness; refresh?: readonly ReadinessRefresh[] } = {},
251
- ): Promise<Readiness> {
252
- const spinner = clack.spinner()
253
- spinner.start('Checking your setup')
254
- try {
255
- return await assessReadiness(deps, options)
256
- } finally {
257
- spinner.stop(style.dim('Setup checked'))
258
- }
259
- }
260
-
261
- async function refresh(
262
- deps: CommandDeps,
263
- previous: Readiness,
264
- next: readonly ReadinessRefresh[] | null,
265
- ): Promise<Readiness> {
266
- return next === null ? previous : assess(deps, { previous, refresh: next })
267
- }
268
-
269
- // ---------------------------------------------------------------------------
270
- // Status
271
- // ---------------------------------------------------------------------------
272
-
273
- function stateById(readiness: Readiness, id: string): ReadinessState | undefined {
274
- return readiness.states.find((state) => state.id === id)
275
- }
276
-
277
- function toneOf(state: ReadinessState | undefined): Tone {
278
- if (state === undefined) return 'pending'
279
- switch (state.status) {
280
- case 'ready':
281
- return 'ok'
282
- case 'gap':
283
- return 'bad'
284
- case 'optional-gap':
285
- return 'warn'
286
- case 'unknown':
287
- return 'pending'
288
- }
289
- }
290
-
291
- /**
292
- * The status card: four lines that answer "is this working", with the detail
293
- * behind `doctor` rather than in front of everyone.
294
- *
295
- * `doctor` reports fourteen states because that is what a report is for. Home
296
- * shows the four that change what you would do next, which is why this reads
297
- * the same readiness model rather than a second one that could disagree.
298
- */
299
- function renderStatus(readiness: Readiness, options: { compact?: boolean }): void {
300
- const rows: { label: string; state: ReadinessState | undefined; summary: (s?: ReadinessState) => string }[] = [
301
- {
302
- label: 'Account',
303
- state: stateById(readiness, 'credential'),
304
- summary: (s) => (s?.status === 'ready' ? accountSummary(readiness) : 'not signed in'),
305
- },
306
- {
307
- label: 'Devices',
308
- state: stateById(readiness, 'devices'),
309
- summary: (s) => shorten(s?.detail ?? 'unknown'),
310
- },
311
- {
312
- label: 'Project',
313
- state: stateById(readiness, 'project'),
314
- summary: (s) => (s?.status === 'ready' ? firstQuoted(s.detail) : 'not named'),
315
- },
316
- {
317
- label: 'Questions',
318
- state: stateById(readiness, 'hooks'),
319
- summary: (s) => (s?.status === 'ready' ? 'routing to your devices' : shorten(s?.detail ?? 'not wired')),
320
- },
321
- ]
322
-
323
- if (options.compact === true) {
324
- const summary = rows.map((row) => `${mark(toneOf(row.state))} ${style.dim(row.label)}`).join(' ')
325
- process.stdout.write(`\n ${summary}\n\n`)
326
- return
327
- }
328
-
329
- const labelWidth = Math.max(...rows.map((row) => row.label.length))
330
- const body = rows.map(
331
- (row) => `${mark(toneOf(row.state))} ${style.dim(row.label.padEnd(labelWidth))} ${row.summary(row.state)}`,
332
- )
333
- process.stdout.write('\n')
334
- for (const line of card('Status', body)) process.stdout.write(`${line}\n`)
335
- process.stdout.write('\n')
336
- }
337
-
338
- function accountSummary(readiness: Readiness): string {
339
- const auth = stateById(readiness, 'auth')
340
- const email = auth?.detail.match(/\(([^)]+@[^)]+)\)/)?.[1]
341
- return email ?? 'signed in'
342
- }
343
-
344
- function firstQuoted(text: string): string {
345
- return text.match(/"([^"]+)"/)?.[1] ?? text
346
- }
347
-
348
- function shorten(text: string, limit = 46): string {
349
- const flat = text.replace(/\s+/g, ' ').trim()
350
- const clipped = flat.split(' — ')[0] ?? flat
351
- return clipped.length > limit ? `${clipped.slice(0, limit - 1)}…` : clipped
352
- }
353
-
354
- /**
355
- * The menu, ordered by what is actually in the way.
356
- *
357
- * A fixed menu would put "Send a test notification" first for someone who has
358
- * not signed in — an action that cannot succeed, offered ahead of the one that
359
- * would unblock it.
360
- */
361
- function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: string }[] {
362
- const signedIn = stateById(readiness, 'credential')?.status === 'ready'
363
- const devices = stateById(readiness, 'devices')
364
- const hasDevice = devices?.status === 'ready'
365
- const routing = stateById(readiness, 'hooks')
366
- const blocker = firstBlocker(readiness)
367
-
368
- const options: { value: Screen; label: string; hint?: string }[] = []
369
-
370
- if (blocker !== null) {
371
- options.push({
372
- value: 'setup',
373
- label: 'Finish setup',
374
- hint: shorten(blocker.title.toLowerCase(), 40),
375
- })
376
- } else if (signedIn && hasDevice) {
377
- options.push({
378
- value: 'test',
379
- label: 'Send a test notification',
380
- hint: 'prove the whole path, end to end',
381
- })
382
- }
383
-
384
- options.push(
385
- { value: 'settings', label: 'Settings', hint: 'sounds, timing, presence, project' },
386
- {
387
- value: 'routing',
388
- label: 'Question routing',
389
- hint: routing?.status === 'ready' ? 'wired — review or change' : 'let agents reach you when you step away',
390
- },
391
- )
392
- if (signedIn && hasDevice) {
393
- options.push({ value: 'devices', label: 'Devices', hint: 'what can receive right now' })
394
- }
395
- options.push({ value: 'doctor', label: 'Run diagnostics', hint: 'check every part of the setup' })
396
- if (signedIn) {
397
- options.push({ value: 'account', label: 'Account', hint: 'plan, machine, sign out' })
398
- }
399
- options.push({ value: 'quit', label: 'Quit' })
400
- return options
401
- }
402
-
403
- // ---------------------------------------------------------------------------
404
- // Test notification
405
- // ---------------------------------------------------------------------------
406
-
407
- async function testNotificationScreen(deps: CommandDeps): Promise<void> {
408
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
409
- const project = config.project.value
410
-
411
- const title = await clack.text({
412
- message: 'Title',
413
- placeholder: 'Test from Notifai',
414
- defaultValue: 'Test from Notifai',
415
- })
416
- if (cancelled(title)) return
417
-
418
- const body = await clack.text({
419
- message: 'Body',
420
- placeholder: 'If you can read this on your device, it works.',
421
- defaultValue: 'If you can read this on your device, it works.',
422
- })
423
- if (cancelled(body)) return
424
-
425
- const kind = await clack.select<string>({
426
- message: 'Kind',
427
- options: [
428
- { value: 'update', label: 'Update', hint: 'something happened' },
429
- { value: 'done', label: 'Done', hint: 'work finished' },
430
- ],
431
- initialValue: 'done',
432
- })
433
- if (cancelled(kind)) return
434
-
435
- clack.log.step('Sending, then waiting for your devices to confirm…')
436
- const code = await sendCommand(deps, {
437
- title: String(title),
438
- body: String(body),
439
- kind: String(kind),
440
- ...(project !== null ? { project } : {}),
441
- })
442
- if (code === EXIT.ok) clack.log.success('Delivered. Check the device it landed on.')
443
- else clack.log.error('That did not get through — `Run diagnostics` will say why.')
444
- }
445
-
446
- // ---------------------------------------------------------------------------
447
- // Devices
448
- // ---------------------------------------------------------------------------
449
-
450
- async function devicesScreen(deps: CommandDeps): Promise<void> {
451
- const spinner = clack.spinner()
452
- spinner.start('Loading your devices')
453
- const devices = await deviceInventory(deps)
454
- spinner.stop(style.dim('Devices loaded'))
455
-
456
- if (devices === null) {
457
- clack.log.warn('Could not reach the service. Sign in, or check the connection.')
458
- return
459
- }
460
- if (devices.length === 0) {
461
- clack.note(
462
- 'No devices are registered yet.\n\n' +
463
- 'Install a supported Notifai Companion App, sign in with the same\n' +
464
- 'Account, and allow notifications when it asks.',
465
- 'No devices',
466
- )
467
- return
468
- }
469
-
470
- const body = devices
471
- .map((device) => {
472
- const ready = canDeviceReceive(device)
473
- const detail = ready ? 'ready' : `not ready ${glyph.bullet} permission: ${device.permission_status}`
474
- return (
475
- `${mark(ready ? 'ok' : 'warn')} ${style.value(device.display_name)} ${style.dim(`(${device.platform})`)}\n` +
476
- ` ${style.dim(detail)}\n` +
477
- ` ${style.dim(device.device_id)}`
478
- )
479
- })
480
- .join('\n\n')
481
- clack.note(body, `${devices.length} device${devices.length === 1 ? '' : 's'}`)
482
- }
483
-
484
- // ---------------------------------------------------------------------------
485
- // Settings
486
- // ---------------------------------------------------------------------------
487
-
488
- async function settingsScreen(deps: CommandDeps): Promise<'unchanged' | 'local' | 'remote'> {
489
- let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
490
- for (;;) {
491
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
492
- const group = await clack.select<ConfigGroup | 'back'>({
493
- message: 'Settings',
494
- options: [
495
- ...CONFIG_GROUPS.map((entry) => ({
496
- value: entry.id,
497
- label: entry.title,
498
- hint: changedCount(config, entry.id),
499
- })),
500
- { value: 'back' as const, label: '← Back' },
501
- ],
502
- })
503
- if (cancelled(group) || group === 'back') return result
504
- const next = await settingsGroupScreen(deps, group as ConfigGroup)
505
- if (next === 'remote') result = 'remote'
506
- else if (next === 'local' && result === 'unchanged') result = 'local'
507
- }
508
- }
509
-
510
- /** "3 changed" beats repeating the group blurb the reader already saw. */
511
- function changedCount(config: CliConfig, group: ConfigGroup): string {
512
- const keys = configKeysByGroup(group)
513
- const changed = keys.filter((info) => config[info.key].source !== 'default').length
514
- return changed === 0 ? style.dim('all default') : style.dim(`${changed} changed`)
515
- }
516
-
517
- async function settingsGroupScreen(
518
- deps: CommandDeps,
519
- group: ConfigGroup,
520
- ): Promise<'unchanged' | 'local' | 'remote'> {
521
- let result: 'unchanged' | 'local' | 'remote' = 'unchanged'
522
- for (;;) {
523
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
524
- const info = CONFIG_GROUPS.find((entry) => entry.id === group)!
525
- const keys = configKeysByGroup(group)
526
-
527
- const key = await clack.select<ConfigKey | 'back'>({
528
- message: info.title,
529
- options: [
530
- ...keys.map((entry) => {
531
- const resolved = config[entry.key]
532
- const source = describeSource(resolved.source)
533
- const value = formatValue(entry.key, resolved.value)
534
- return {
535
- value: entry.key,
536
- label: entry.label,
537
- hint: source.label === 'default' ? style.dim(value) : `${value} ${style.dim(`(${source.label})`)}`,
538
- }
539
- }),
540
- { value: 'back' as const, label: '← Back' },
541
- ],
542
- })
543
- if (cancelled(key) || key === 'back') return result
544
- const next = await settingDetailScreen(deps, key as ConfigKey)
545
- if (next === 'remote') result = 'remote'
546
- else if (next === 'local' && result === 'unchanged') result = 'local'
547
- }
548
- }
549
-
550
- /**
551
- * One key, fully explained, with the option to change it.
552
- *
553
- * This is where the paragraph lives. Everything above stayed one line per key
554
- * so that arriving here means the reader has already said which key they care
555
- * about.
556
- */
557
- async function settingDetailScreen(
558
- deps: CommandDeps,
559
- key: ConfigKey,
560
- ): Promise<'unchanged' | 'local' | 'remote'> {
561
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
562
- const info = configInfo(key)
563
- const resolved = config[key]
564
- const source = describeSource(resolved.source)
565
-
566
- const lines = [
567
- info.detail,
568
- '',
569
- `${style.dim('Now')} ${style.value(formatValue(key, resolved.value))} ${style.dim(`${glyph.bullet} ${source.label}`)}`,
570
- `${style.dim('Accepts')} ${acceptedValues(key)}`,
571
- `${style.dim('Key')} ${style.code(key)}`,
572
- ]
573
- clack.note(lines.join('\n'), info.label)
574
-
575
- const action = await clack.select<'change' | 'back'>({
576
- message: 'Change it?',
577
- options: [
578
- // Not lower-cased: "Change only when i have stepped away" is what
579
- // `toLowerCase` makes of a label that starts a sentence.
580
- { value: 'change', label: `Change this setting`, hint: style.code(info.key) },
581
- { value: 'back', label: '← Back' },
582
- ],
583
- initialValue: 'back',
584
- })
585
- if (cancelled(action) || action === 'back') return 'unchanged'
586
-
587
- const value = await promptForValue(deps, key)
588
- if (value === null) return 'unchanged'
589
-
590
- const layer = await chooseLayer()
591
- if (layer === null) return 'unchanged'
592
-
593
- const code = await configSetCommand(deps, key, value, {
594
- yes: true,
595
- ...(layer === 'project' ? { project: true } : {}),
596
- ...(layer === 'local' ? { local: true } : {}),
597
- })
598
- if (code !== EXIT.ok) return 'unchanged'
599
- clack.log.success(`${info.label} is now ${formatValueRaw(key, value)}`)
600
- return 'local'
601
- }
602
-
603
- function formatValueRaw(key: ConfigKey, raw: string): string {
604
- const info = configInfo(key)
605
- if (info.kind === 'boolean') return raw === 'true' ? 'yes' : 'no'
606
- return raw
607
- }
608
-
609
- /** The prompt shape follows the key's type; validation stays in `config set`. */
610
- async function promptForValue(deps: CommandDeps, key: ConfigKey): Promise<string | null> {
611
- const info = configInfo(key)
612
-
613
- if (info.kind === 'boolean') {
614
- const answer = await clack.select<string>({
615
- message: info.label,
616
- options: [
617
- { value: 'true', label: 'Yes' },
618
- { value: 'false', label: 'No' },
619
- ],
620
- })
621
- return cancelled(answer) ? null : String(answer)
622
- }
623
-
624
- if (info.kind === 'enum') {
625
- const answer = await clack.select<string>({
626
- message: info.label,
627
- options: (info.choices ?? []).map((choice) => ({
628
- value: choice,
629
- label: choice,
630
- ...(info.choiceHints?.[choice] !== undefined ? { hint: info.choiceHints[choice]! } : {}),
631
- })),
632
- })
633
- return cancelled(answer) ? null : String(answer)
634
- }
635
-
636
- // The one key whose legal values the machine knows better than the reader.
637
- if (key === 'devices') return await promptForDevices(deps)
638
-
639
- const bounds = boundsHint(key)
640
- const answer = await clack.text({
641
- message: info.label,
642
- ...(info.example !== undefined ? { placeholder: info.example } : {}),
643
- validate: (input) => {
644
- const trimmed = (input ?? '').trim()
645
- if (trimmed === '') return 'Enter a value, or press Escape to go back.'
646
- if (info.kind === 'integer') {
647
- if (!/^-?\d+$/.test(trimmed)) return `${info.label} is a whole number of seconds.`
648
- const range = boundsRange(key)
649
- if (bounds !== null && range !== null) {
650
- const parsed = Number(trimmed)
651
- if (parsed < range.min || parsed > range.max) return `Must be between ${bounds}.`
652
- }
653
- }
654
- if (info.kind === 'url' && !/^https?:\/\//.test(trimmed)) return 'Must start with http:// or https://.'
655
- return undefined
656
- },
657
- })
658
- return cancelled(answer) ? null : String(answer).trim()
659
- }
660
-
661
- function boundsRange(key: ConfigKey): { min: number; max: number } | null {
662
- const hint = boundsHint(key)
663
- if (hint === null) return null
664
- const [min, max] = hint.replace(/[^\d–]/g, '').split('–')
665
- if (min === undefined || max === undefined) return null
666
- return { min: Number(min), max: Number(max) }
667
- }
668
-
669
- async function promptForDevices(deps: CommandDeps): Promise<string | null> {
670
- const spinner = clack.spinner()
671
- spinner.start('Loading your devices')
672
- const devices = await deviceInventory(deps)
673
- spinner.stop(style.dim('Devices loaded'))
674
- if (devices === null || devices.length === 0) {
675
- clack.log.warn('No devices to choose from yet.')
676
- return null
677
- }
678
- const picked = await clack.multiselect<string>({
679
- message: 'Which devices should receive by default?',
680
- options: devices.map((device) => ({
681
- value: device.device_id,
682
- label: device.display_name,
683
- hint: canDeviceReceive(device) ? device.platform : `${device.platform} ${glyph.bullet} not ready`,
684
- })),
685
- required: true,
686
- })
687
- return cancelled(picked) ? null : (picked as string[]).join(',')
688
- }
689
-
690
- /**
691
- * Which layer the value lands in.
692
- *
693
- * Asked every time rather than defaulting, because the difference between
694
- * "my machine" and "everyone who clones this repository" is not recoverable by
695
- * guessing, and the shared file is the one that ends up in a commit.
696
- */
697
- async function chooseLayer(): Promise<'global' | 'project' | 'local' | null> {
698
- const layer = await clack.select<'global' | 'project' | 'local'>({
699
- message: 'Where should this apply?',
700
- options: [
701
- { value: 'global', label: 'This machine', hint: 'every project you work on here' },
702
- { value: 'project', label: 'This project, shared', hint: '.notifai/config.toml — commit it' },
703
- { value: 'local', label: 'This project, just me', hint: 'stored on this machine, not in the repo' },
704
- ],
705
- initialValue: 'global',
706
- })
707
- return cancelled(layer) ? null : (layer as 'global' | 'project' | 'local')
708
- }
709
-
710
- // ---------------------------------------------------------------------------
711
- // Question routing
712
- // ---------------------------------------------------------------------------
713
-
714
- /**
715
- * Which harnesses are wired, without the install paths.
716
- *
717
- * The underlying state lists every installation with its absolute file path,
718
- * which is the right level of detail for `doctor` and far too much for a
719
- * summary card — four paths wrap across six lines and push the settings
720
- * underneath them out of alignment.
721
- */
722
- function wiringSummary(hooks: ReadinessState | undefined): string {
723
- if (hooks === undefined) return 'unknown'
724
- const named = hooks.detail.replace(/\s*\([^)]*\)/g, '').trim()
725
- return named === '' ? hooks.detail : named
726
- }
727
-
728
- /** Returns true when something changed and the status card needs refreshing. */
729
- async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
730
- const hooks = stateById(readiness, 'hooks')
731
- const config = loadConfig({ cwd: deps.cwd, env: deps.env })
732
- const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
733
- const projectInstallations = installations.filter((installation) => !installation.global)
734
- const machineInstallations = installations.filter((installation) => installation.global)
735
-
736
- const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
737
- clack.note(
738
- 'When an agent registers a question, it appears in your terminal first.\n' +
739
- 'After the grace below, it goes to your Companion devices as a\n' +
740
- 'notification with a supported native or in-app answer surface, and\n' +
741
- 'your answer comes back to the agent —\n' +
742
- 'whether or not you are still at this machine.\n\n' +
743
- [
744
- row('Wired to', wiringSummary(hooks)),
745
- row('Send', config.ask_notifications.value ? 'yes' : 'no — questions stay in the terminal'),
746
- row('Grace', formatValue('ask_grace_seconds', config.ask_grace_seconds.value)),
747
- ].join('\n'),
748
- 'Question routing',
749
- )
750
-
751
- const action = await clack.select<RoutingAction>({
752
- message: 'Question routing',
753
- options: routingHookActions({
754
- canInstall: detectedHarnesses(deps.cwd, deps.env).length > 0,
755
- projectInstallations,
756
- machineInstallations,
757
- hooksReady: hooks?.status === 'ready',
758
- }),
759
- })
760
- if (cancelled(action) || action === 'back') return false
761
-
762
- if (action === 'settings') {
763
- await settingsGroupScreen(deps, 'questions')
764
- return true
765
- }
766
-
767
- if (action === 'install') return await installHooksInteractively(deps)
768
- return await uninstallHooksInteractively(deps, projectInstallations, machineInstallations)
769
- }
770
-
771
- async function installHooksInteractively(deps: CommandDeps): Promise<boolean> {
772
- const scope = await clack.select<HookScope>({
773
- message: 'Install hooks for',
774
- options: [
775
- { value: 'project', label: 'This project', hint: 'hooks only in this directory' },
776
- { value: 'machine', label: 'This machine', hint: 'hooks for every project here' },
777
- ],
778
- initialValue: 'project',
779
- })
780
- if (cancelled(scope)) return false
781
- hooksInstallCommand(deps, scope === 'machine' ? { global: true } : {})
782
- return true
783
- }
784
-
785
- async function uninstallHooksInteractively(
786
- deps: CommandDeps,
787
- projectInstallations: Installation[],
788
- machineInstallations: Installation[],
789
- ): Promise<boolean> {
790
- const scopes = uninstallScopeOptions(projectInstallations, machineInstallations)
791
- if (scopes.length === 0) return false
792
-
793
- let scope: HookScope
794
- if (scopes.length === 1) {
795
- scope = scopes[0]!.value
796
- } else {
797
- const picked = await clack.select<HookScope | 'back'>({
798
- message: 'Uninstall hooks from',
799
- options: [...scopes, { value: 'back', label: '← Back' }],
800
- })
801
- if (cancelled(picked) || picked === 'back') return false
802
- scope = picked
803
- }
804
-
805
- const targets = scope === 'machine' ? machineInstallations : projectInstallations
806
- for (const installation of targets) {
807
- hooksUninstallCommand(deps, {
808
- harness: installation.harness,
809
- global: installation.global,
810
- })
811
- }
812
- return true
813
- }
814
-
815
- // ---------------------------------------------------------------------------
816
- // Account
817
- // ---------------------------------------------------------------------------
818
-
819
- /** Returns true when something changed and the status card needs refreshing. */
820
- async function accountScreen(deps: CommandDeps, readiness: Readiness): Promise<boolean> {
821
- const signedIn = stateById(readiness, 'credential')?.status === 'ready'
822
-
823
- if (!signedIn) {
824
- await initCommand(deps, {})
825
- return true
826
- }
827
-
828
- const auth = stateById(readiness, 'auth')
829
- const credential = stateById(readiness, 'credential')
830
- clack.note(
831
- `${style.dim('Machine')} ${credential?.detail ?? 'unknown'}\n${style.dim('Account')} ${auth?.detail ?? 'unknown'}`,
832
- 'Account',
833
- )
834
-
835
- const action = await clack.select<'relogin' | 'logout' | 'back'>({
836
- message: 'Account',
837
- options: [
838
- { value: 'relogin', label: 'Pair this machine again' },
839
- { value: 'logout', label: 'Sign out', hint: 'removes the stored credential' },
840
- { value: 'back', label: '← Back' },
841
- ],
842
- initialValue: 'back',
843
- })
844
- if (cancelled(action) || action === 'back') return false
845
-
846
- if (action === 'logout') {
847
- const sure = await clack.confirm({ message: 'Remove the stored credential?', initialValue: false })
848
- if (cancelled(sure) || sure !== true) return false
849
- logoutCommand(deps)
850
- return true
851
- }
852
-
853
- await loginCommand(deps, {})
854
- return true
855
- }
856
-
857
- /** Exported for the settings command to reuse the same key vocabulary. */
858
- export const INTERACTIVE_CONFIG_KEYS = CONFIG_KEYS