@raidiant/notifai 0.1.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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. package/tsconfig.json +10 -0
@@ -0,0 +1,2201 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { spawn } from 'node:child_process'
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
7
+ import {
8
+ CAPABILITIES_V1,
9
+ REPLY_MAX_WINDOW_SECONDS,
10
+ validateDraft,
11
+ type ListRepliesResponse,
12
+ type Platform,
13
+ type ReplyView,
14
+ } from '@raidiant/notifai-protocol'
15
+ import { sha256Hex } from '@raidiant/notifai-protocol/node'
16
+ import {
17
+ ApiCallError,
18
+ NetworkError,
19
+ createClient,
20
+ type ApiClient,
21
+ type ClientOptions,
22
+ } from './client.js'
23
+ import {
24
+ BOOLEAN_CONFIG_KEYS,
25
+ CONFIG_KEYS,
26
+ NUMERIC_CONFIG_KEYS,
27
+ configBounds,
28
+ findProjectConfigPath,
29
+ findProjectLocalConfigPath,
30
+ globalConfigPath,
31
+ loadConfig,
32
+ sessionConfigPath,
33
+ type CliConfig,
34
+ type ConfigKey,
35
+ type FlagOverrides,
36
+ } from './config.js'
37
+ import type { CredentialStore, MachineCredential } from './credentials.js'
38
+ import { firstBlocker, openItems, type Readiness, type ReadinessState } from './readiness.js'
39
+ import {
40
+ handleSessionEnd,
41
+ handleStop,
42
+ handleUserPromptSubmit,
43
+ parseHookInput,
44
+ pruneAbandonedSessions,
45
+ readProjectSession,
46
+ registerQuestion,
47
+ type HookContext,
48
+ type HookEnvelope,
49
+ } from './hooks.js'
50
+ import { readIdleSeconds } from './idle.js'
51
+ import {
52
+ HARNESSES,
53
+ applyPlan,
54
+ blockingHookTimeoutSeconds,
55
+ buildCursorHookConfig,
56
+ buildHookConfig,
57
+ codexLayerDir,
58
+ codexProjectRoot,
59
+ detectHarness,
60
+ findInstallations,
61
+ handlerEvent,
62
+ loadCursorSettings,
63
+ loadSettings,
64
+ mergeCursorHooks,
65
+ mergeHooks,
66
+ removeCursorHooks,
67
+ removeHooks,
68
+ settingsFile,
69
+ type Harness,
70
+ type Installation,
71
+ } from './install-hooks.js'
72
+ import {
73
+ isOurOpencodePlugin,
74
+ opencodePluginSource,
75
+ } from './opencode-plugin.js'
76
+ import {
77
+ CHOICE_USAGE,
78
+ ambiguousChoiceSplit,
79
+ buildDraft,
80
+ formatReceipt,
81
+ parseChoices,
82
+ receiptExitCode,
83
+ type SendFlags,
84
+ } from './send.js'
85
+
86
+ export interface CommandIo {
87
+ out(line: string): void
88
+ err(line: string): void
89
+ /** Interactive confirmation; resolves `fallback` (default false) when not interactive. */
90
+ confirm(question: string, fallback?: boolean): Promise<boolean>
91
+ openUrl(url: string): void
92
+ /**
93
+ * True only when a human is demonstrably driving a terminal. Everything below
94
+ * is optional sugar that MUST only be called behind this flag: an agent that
95
+ * reaches an interactive prompt does not error, it hangs — the prompt
96
+ * libraries wait on stdin for ever — so the gate is bypass, not handling.
97
+ * Test fakes leave all of this undefined and exercise the plain paths.
98
+ */
99
+ interactive?: boolean
100
+ select?(message: string, options: { value: string; label: string; hint?: string }[]): Promise<string | null>
101
+ intro?(title: string): Promise<void>
102
+ outro?(message: string): Promise<void>
103
+ note?(message: string, title?: string): Promise<void>
104
+ spinner?(message: string): Promise<CommandSpinner | null>
105
+ check?(ok: boolean, message: string): Promise<void>
106
+ }
107
+
108
+ export interface CommandSpinner {
109
+ message(message: string): void
110
+ stop(message: string): void
111
+ error(message: string): void
112
+ }
113
+
114
+ export interface CommandDeps {
115
+ io: CommandIo
116
+ store: CredentialStore
117
+ env: NodeJS.ProcessEnv
118
+ cwd: string
119
+ /** Test seam; production uses fetch against base_url. */
120
+ clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
121
+ /** Test seam for bounded polling without wall-clock sleeps. */
122
+ now?: () => number
123
+ /** Test seam for retry/backoff timing. */
124
+ sleep?: (milliseconds: number) => Promise<void>
125
+ /** Test seam for the OS idle probe; production shells out to the platform. */
126
+ idleSeconds?: () => number | null
127
+ }
128
+
129
+ export const EXIT = {
130
+ ok: 0,
131
+ failed: 1,
132
+ usage: 2,
133
+ noReply: 3,
134
+ auth: 4,
135
+ network: 5,
136
+ } as const
137
+
138
+ function makeClient(
139
+ deps: CommandDeps,
140
+ baseUrl: string,
141
+ bearer: string | null,
142
+ options?: ClientOptions,
143
+ ): ApiClient {
144
+ return (deps.clientFactory ?? createClient)(baseUrl, bearer, options)
145
+ }
146
+
147
+ function resolvedBaseUrl(config: CliConfig, credential: MachineCredential | null): string {
148
+ return config.base_url.source === 'default' && credential ? credential.baseUrl : config.base_url.value
149
+ }
150
+
151
+ function authedClient(deps: CommandDeps, config: CliConfig): { client: ApiClient; baseUrl: string } | null {
152
+ const credential = deps.store.load()
153
+ if (!credential) {
154
+ deps.io.err('Not signed in. Run `notifai login` first.')
155
+ return null
156
+ }
157
+ const baseUrl = resolvedBaseUrl(config, credential)
158
+ return {
159
+ client: makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`),
160
+ baseUrl,
161
+ }
162
+ }
163
+
164
+ function reportError(deps: CommandDeps, err: unknown): number {
165
+ if (err instanceof ApiCallError) {
166
+ deps.io.err(`${err.code}: ${err.message}`)
167
+ if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
168
+ if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
169
+ return err.status >= 500 ? EXIT.network : EXIT.failed
170
+ }
171
+ if (err instanceof NetworkError) {
172
+ deps.io.err(err.message)
173
+ return EXIT.network
174
+ }
175
+ deps.io.err(String(err))
176
+ return EXIT.failed
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // login / logout / auth status
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export async function loginCommand(
184
+ deps: CommandDeps,
185
+ flags: { name?: string; baseUrl?: string; open?: boolean },
186
+ ): Promise<number> {
187
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
188
+ const baseUrl = config.base_url.value
189
+ const machineName = flags.name ?? os.hostname()
190
+ const secret = randomBytes(32).toString('base64url')
191
+ const pollVerifier = randomBytes(24).toString('base64url')
192
+ const client = makeClient(deps, baseUrl, null)
193
+
194
+ let begin
195
+ try {
196
+ begin = await client.beginPairing({
197
+ machine_name: machineName,
198
+ credential_hash: sha256Hex(secret),
199
+ poll_verifier_hash: sha256Hex(pollVerifier),
200
+ })
201
+ } catch (err) {
202
+ return reportError(deps, err)
203
+ }
204
+
205
+ const interactive = deps.io.interactive === true
206
+ if (interactive) {
207
+ await deps.io.intro?.('NotifAI sign in')
208
+ await deps.io.note?.(`Code: ${begin.code}\n${begin.approve_url}`, 'Approve this machine')
209
+ } else {
210
+ deps.io.out(`Pairing code: ${begin.code}`)
211
+ deps.io.out(`Approve this machine at: ${begin.approve_url}`)
212
+ deps.io.out('Waiting for approval…')
213
+ }
214
+ if (flags.open !== false) deps.io.openUrl(begin.approve_url)
215
+
216
+ const expiresAt = new Date(begin.expires_at).getTime()
217
+ const intervalMs = Math.max(begin.poll_interval_seconds, 1) * 1000
218
+ const now = deps.now ?? Date.now
219
+ const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
220
+ const spinner = interactive ? await deps.io.spinner?.('Waiting for approval…') : null
221
+ while (now() < expiresAt) {
222
+ await sleep(intervalMs)
223
+ let poll
224
+ try {
225
+ poll = await client.pollPairing(begin.pairing_id, pollVerifier)
226
+ } catch (err) {
227
+ if (err instanceof NetworkError) {
228
+ spinner?.message('Connection lost — retrying…')
229
+ continue
230
+ }
231
+ spinner?.error('Pairing failed')
232
+ return reportError(deps, err)
233
+ }
234
+ if (poll.status === 'approved' && poll.machine_id) {
235
+ deps.store.save({ machineId: poll.machine_id, secret, baseUrl, machineName })
236
+ if (interactive) {
237
+ spinner?.stop(`Machine "${machineName}" approved`)
238
+ await deps.io.outro?.(`Credential stored in ${deps.store.describe()}`)
239
+ } else {
240
+ deps.io.out(`Machine "${machineName}" approved. Credential stored in ${deps.store.describe()}.`)
241
+ }
242
+ return EXIT.ok
243
+ }
244
+ if (poll.status === 'denied') {
245
+ spinner?.error('Pairing denied')
246
+ deps.io.err('Pairing was denied from the dashboard.')
247
+ return EXIT.auth
248
+ }
249
+ if (poll.status === 'expired') break
250
+ spinner?.message('Waiting for approval…')
251
+ }
252
+ spinner?.error('Pairing expired')
253
+ deps.io.err('Pairing expired before it was approved. Run `notifai login` again.')
254
+ return EXIT.auth
255
+ }
256
+
257
+ export function logoutCommand(deps: CommandDeps): number {
258
+ deps.store.clear()
259
+ deps.io.out('Machine credential removed. Revoke it in the dashboard too if the machine is untrusted.')
260
+ return EXIT.ok
261
+ }
262
+
263
+ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }): number {
264
+ const credential = deps.store.load()
265
+ if (flags.json) {
266
+ deps.io.out(
267
+ JSON.stringify(
268
+ credential
269
+ ? {
270
+ signed_in: true,
271
+ machine_id: credential.machineId,
272
+ machine_name: credential.machineName,
273
+ base_url: credential.baseUrl,
274
+ store: deps.store.describe(),
275
+ }
276
+ : { signed_in: false },
277
+ null,
278
+ 2,
279
+ ),
280
+ )
281
+ return credential ? EXIT.ok : EXIT.auth
282
+ }
283
+ if (!credential) {
284
+ deps.io.err('Not signed in. Run `notifai login`.')
285
+ return EXIT.auth
286
+ }
287
+ deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`)
288
+ deps.io.out(`Server: ${credential.baseUrl}`)
289
+ deps.io.out(`Credential store: ${deps.store.describe()}`)
290
+ return EXIT.ok
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // devices / capabilities
295
+ // ---------------------------------------------------------------------------
296
+
297
+ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
298
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
299
+ const authed = authedClient(deps, config)
300
+ if (!authed) return EXIT.auth
301
+ try {
302
+ const result = await authed.client.listDevices()
303
+ if (flags.json) {
304
+ deps.io.out(JSON.stringify(result, null, 2))
305
+ return EXIT.ok
306
+ }
307
+ if (result.devices.length === 0) {
308
+ deps.io.out('No devices. Install a NotifAI companion app on a device and sign in.')
309
+ return EXIT.ok
310
+ }
311
+ for (const d of result.devices) {
312
+ deps.io.out(
313
+ `${d.device_id} ${d.display_name} ${d.platform} ${d.registration_healthy ? 'ready' : 'not ready'} (permission: ${d.permission_status})`,
314
+ )
315
+ }
316
+ return EXIT.ok
317
+ } catch (err) {
318
+ return reportError(deps, err)
319
+ }
320
+ }
321
+
322
+ export async function capabilitiesCommand(
323
+ deps: CommandDeps,
324
+ flags: { json?: boolean; platform?: Platform },
325
+ ): Promise<number> {
326
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
327
+ const credential = deps.store.load()
328
+ const baseUrl = resolvedBaseUrl(config, credential)
329
+ const client = makeClient(deps, baseUrl, null)
330
+ try {
331
+ const doc = await client.capabilities(flags.platform ?? 'ios')
332
+ if (flags.json) {
333
+ deps.io.out(JSON.stringify(doc, null, 2))
334
+ return EXIT.ok
335
+ }
336
+ deps.io.out(`${doc.platform} capability contract v${doc.schema_version} (payload limit ${doc.payload_limit_bytes} bytes)`)
337
+ for (const field of doc.fields) {
338
+ deps.io.out(` ${field.path}: ${field.status}${field.reason ? ` — ${field.reason}` : ''}`)
339
+ }
340
+ return EXIT.ok
341
+ } catch (err) {
342
+ return reportError(deps, err)
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // send / status
348
+ // ---------------------------------------------------------------------------
349
+
350
+ export async function sendCommand(
351
+ deps: CommandDeps,
352
+ flags: SendFlags & {
353
+ json?: boolean
354
+ wait?: number
355
+ noWait?: boolean
356
+ replyTimeout?: number
357
+ noBlock?: boolean
358
+ idempotencyKey?: string
359
+ baseUrl?: string
360
+ },
361
+ ): Promise<number> {
362
+ const hasReplyChoice = Array.isArray(flags.replyChoice)
363
+ ? flags.replyChoice.length > 0
364
+ : flags.replyChoice !== undefined
365
+ if (
366
+ !flags.reply &&
367
+ (flags.replyTimeout !== undefined || flags.replyWindow !== undefined || flags.noBlock || hasReplyChoice)
368
+ ) {
369
+ deps.io.err('Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.')
370
+ return EXIT.usage
371
+ }
372
+ const replyTimeout = flags.replyTimeout ?? 900
373
+ if (flags.reply && !isNonNegativeInteger(replyTimeout)) {
374
+ deps.io.err('--reply-timeout must be a non-negative integer number of seconds.')
375
+ return EXIT.usage
376
+ }
377
+ // Asking while declaring that nothing will wait for the answer. The reply is
378
+ // captured server-side and then unreachable: only a blocking send waits for
379
+ // it, and the hook path drains questions registered by `ask`, never a send's
380
+ // request id. So the user gets a real button, taps it, and nothing happens —
381
+ // worse than a banner that never asked, because it spends their attention
382
+ // and their trust in the channel.
383
+ //
384
+ // Both spellings of "do not wait" are rejected, because the defect is the
385
+ // zero wait and not the flag that produced it.
386
+ if (flags.reply && (flags.noBlock || replyTimeout === 0)) {
387
+ deps.io.err(
388
+ 'A question needs someone to hear the answer, so --reply cannot be combined ' +
389
+ 'with --no-block or --reply-timeout 0.\n' +
390
+ 'To ask and end the turn, use `notifai ask` — the turn-end hook returns the answer.\n' +
391
+ 'To announce finished work, drop --reply and its choices.',
392
+ )
393
+ return EXIT.usage
394
+ }
395
+ if (
396
+ flags.reply &&
397
+ flags.replyWindow !== undefined &&
398
+ (!Number.isInteger(flags.replyWindow) ||
399
+ flags.replyWindow < 60 ||
400
+ flags.replyWindow > REPLY_MAX_WINDOW_SECONDS)
401
+ ) {
402
+ deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
403
+ return EXIT.usage
404
+ }
405
+ const config = loadConfig({
406
+ cwd: deps.cwd,
407
+ env: deps.env,
408
+ flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
409
+ })
410
+ const authed = authedClient(deps, config)
411
+ if (!authed) return EXIT.auth
412
+ if (flags.image !== undefined && !flags.image.startsWith('med_')) {
413
+ const uploaded = await uploadImage(deps, authed.client, flags.image)
414
+ if (!uploaded.ok) {
415
+ deps.io.err(uploaded.error)
416
+ return uploaded.exit
417
+ }
418
+ flags = { ...flags, image: uploaded.mediaId }
419
+ }
420
+ const build = buildDraft(config, flags)
421
+ if (!build.ok) {
422
+ deps.io.err(build.error)
423
+ return EXIT.usage
424
+ }
425
+ const capabilities = CAPABILITIES_V1.describe(build.platform)
426
+ if (!capabilities) {
427
+ deps.io.err(`No capability contract is available for ${build.platform}.`)
428
+ return EXIT.usage
429
+ }
430
+ const validation = validateDraft(build.draft, capabilities)
431
+ if (!validation.ok) {
432
+ for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
433
+ return EXIT.usage
434
+ }
435
+ if (
436
+ !flags.reply &&
437
+ (flags.title.trim().endsWith('?') || flags.body.trim().endsWith('?'))
438
+ ) {
439
+ deps.io.err(
440
+ 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.',
441
+ )
442
+ }
443
+ const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
444
+ const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
445
+ try {
446
+ const receipt = await authed.client.submit(
447
+ { idempotency_key: idempotencyKey, draft: build.draft },
448
+ waitSeconds,
449
+ )
450
+ const receiptExit = receiptExitCode(receipt)
451
+ if (!flags.json) deps.io.out(formatReceipt(receipt))
452
+
453
+ // A zero wait can no longer reach here: --reply guarantees a positive one.
454
+ if (!flags.reply || receiptExit !== EXIT.ok) {
455
+ if (flags.json) {
456
+ deps.io.out(JSON.stringify(flags.reply ? { receipt, replies: [] } : receipt, null, 2))
457
+ }
458
+ return receiptExit
459
+ }
460
+
461
+ const result = await waitForReply(authed.client, receipt.request_id, {
462
+ timeoutSeconds: replyTimeout,
463
+ afterSeq: 0,
464
+ now: deps.now,
465
+ sleep: deps.sleep,
466
+ })
467
+ if (flags.json) {
468
+ deps.io.out(
469
+ JSON.stringify(
470
+ { receipt, replies: result.response.replies, degraded: result.degraded },
471
+ null,
472
+ 2,
473
+ ),
474
+ )
475
+ } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
476
+ else printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
477
+ if (result.degraded) {
478
+ deps.io.err(DEGRADED_WAIT_WARNING)
479
+ return EXIT.network
480
+ }
481
+ return result.timedOut ? EXIT.noReply : EXIT.ok
482
+ } catch (err) {
483
+ return reportError(deps, err)
484
+ }
485
+ }
486
+
487
+ export async function repliesCommand(
488
+ deps: CommandDeps,
489
+ requestId: string,
490
+ flags: { wait?: number; after?: number; json?: boolean },
491
+ ): Promise<number> {
492
+ const waitSeconds = flags.wait ?? 0
493
+ const afterSeq = flags.after ?? 0
494
+ if (!isNonNegativeInteger(waitSeconds)) {
495
+ deps.io.err('--wait must be a non-negative integer number of seconds.')
496
+ return EXIT.usage
497
+ }
498
+ if (!isNonNegativeInteger(afterSeq)) {
499
+ deps.io.err('--after must be a non-negative integer sequence number.')
500
+ return EXIT.usage
501
+ }
502
+
503
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
504
+ const authed = authedClient(deps, config)
505
+ if (!authed) return EXIT.auth
506
+ try {
507
+ const result = await waitForReply(authed.client, requestId, {
508
+ timeoutSeconds: waitSeconds,
509
+ afterSeq,
510
+ now: deps.now,
511
+ sleep: deps.sleep,
512
+ })
513
+ if (flags.json) {
514
+ deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2))
515
+ } else if (result.response.replies.length > 0) printReplies(deps, result.response.replies)
516
+ else printNoReply(deps, requestId, result.response.reply_expires_at)
517
+ if (result.degraded) {
518
+ deps.io.err(DEGRADED_WAIT_WARNING)
519
+ return EXIT.network
520
+ }
521
+ return result.timedOut ? EXIT.noReply : EXIT.ok
522
+ } catch (err) {
523
+ return reportError(deps, err)
524
+ }
525
+ }
526
+
527
+ interface ReplyWaitOptions {
528
+ timeoutSeconds: number
529
+ afterSeq: number
530
+ now?: (() => number) | undefined
531
+ sleep?: ((milliseconds: number) => Promise<void>) | undefined
532
+ }
533
+
534
+ interface ReplyWaitResult {
535
+ response: ListRepliesResponse
536
+ timedOut: boolean
537
+ /**
538
+ * The wait ended while polls were failing, so silence is unproven: the user
539
+ * may well have answered and we could not see it.
540
+ */
541
+ degraded: boolean
542
+ }
543
+
544
+ /** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
545
+ export async function waitForReply(
546
+ client: ApiClient,
547
+ requestId: string,
548
+ options: ReplyWaitOptions,
549
+ ): Promise<ReplyWaitResult> {
550
+ const now = options.now ?? Date.now
551
+ const sleep = options.sleep ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)))
552
+ const deadline = now() + options.timeoutSeconds * 1000
553
+ let lastResponse: ListRepliesResponse | null = null
554
+ let lastNetworkError: NetworkError | null = null
555
+ let consecutiveNetworkErrors = 0
556
+ let firstPoll = true
557
+
558
+ while (firstPoll || now() < deadline) {
559
+ firstPoll = false
560
+ const remainingMs = Math.max(0, deadline - now())
561
+ const waitSeconds = Math.min(25, Math.floor(remainingMs / 1000))
562
+ try {
563
+ const response = await client.replies(requestId, {
564
+ waitSeconds,
565
+ afterSeq: options.afterSeq,
566
+ })
567
+ lastResponse = response
568
+ lastNetworkError = null
569
+ consecutiveNetworkErrors = 0
570
+ if (response.replies.length > 0) return { response, timedOut: false, degraded: false }
571
+
572
+ const pauseMs = Math.min(250, Math.max(0, deadline - now()))
573
+ if (pauseMs > 0) await sleep(pauseMs)
574
+ } catch (err) {
575
+ if (!(err instanceof NetworkError)) throw err
576
+ lastNetworkError = err
577
+ consecutiveNetworkErrors += 1
578
+ const remainingAfterError = Math.max(0, deadline - now())
579
+ if (remainingAfterError === 0) break
580
+ const backoffMs = Math.min(250 * 2 ** (consecutiveNetworkErrors - 1), 2_000, remainingAfterError)
581
+ await sleep(backoffMs)
582
+ }
583
+ }
584
+
585
+ if (!lastResponse && lastNetworkError) throw lastNetworkError
586
+ return {
587
+ response:
588
+ lastResponse ??
589
+ ({ request_id: requestId, reply_expires_at: null, replies: [] } satisfies ListRepliesResponse),
590
+ timedOut: true,
591
+ // A poll succeeded at some point, so we do not throw — but the last thing
592
+ // we know is that we could not reach the server. Reporting that as a plain
593
+ // "no reply" would let an agent treat an unseen refusal as consent.
594
+ degraded: lastNetworkError !== null,
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Shared by every surface that waits: "the user did not answer" and "I could
600
+ * not find out" must not look the same, because agents branch on the exit code
601
+ * and one of those two branches is safe to proceed from.
602
+ */
603
+ const DEGRADED_WAIT_WARNING =
604
+ 'notifai: the wait ended during a network outage, so this is "could not find out", ' +
605
+ 'not "no answer" — the reply may already be waiting. Retry with `notifai replies <id>`.'
606
+
607
+ function isNonNegativeInteger(value: number): boolean {
608
+ return Number.isInteger(value) && value >= 0
609
+ }
610
+
611
+ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
612
+ for (const reply of replies) deps.io.out(`reply from ${reply.device_name}: ${reply.text}`)
613
+ const contradiction = contradictingAnswer(replies)
614
+ if (contradiction !== null) deps.io.err(contradiction)
615
+ }
616
+
617
+ /**
618
+ * The other half of first-reply-wins.
619
+ *
620
+ * A blocking send returns the first non-empty batch and acts on it, so a later
621
+ * answer that disagrees is silently ignored. That is the right default — an
622
+ * agent wants one answer, not a quorum — but silently is the wrong way to do
623
+ * it. The server now retires the question on the other devices the moment one
624
+ * answers, which makes this rare; it is still reachable, because a device can
625
+ * answer between the first reply landing and its retirement arriving.
626
+ *
627
+ * Ordered by seq, so `replies[0]` is the one that won.
628
+ */
629
+ export function contradictingAnswer(replies: ReplyView[]): string | null {
630
+ const winner = replies[0]
631
+ if (winner === undefined || replies.length < 2) return null
632
+ const disagreeing = replies
633
+ .slice(1)
634
+ .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text))
635
+ if (disagreeing.length === 0) return null
636
+ const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ')
637
+ return (
638
+ `note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
639
+ `${names} answered differently afterwards and that answer was not used.`
640
+ )
641
+ }
642
+
643
+ function printNoReply(deps: CommandDeps, requestId: string, expiresAt?: string | null): void {
644
+ // A harness hook may have retired this question, in which case promising an
645
+ // open window would send the caller back to wait for an answer the server
646
+ // will now refuse.
647
+ const closed = expiresAt != null && Date.parse(expiresAt) <= Date.now()
648
+ deps.io.out(
649
+ closed
650
+ ? `no reply for request ${requestId}; the reply window has closed`
651
+ : `no reply yet for request ${requestId}; the reply window remains open`,
652
+ )
653
+ }
654
+
655
+ export async function statusCommand(
656
+ deps: CommandDeps,
657
+ requestId: string,
658
+ flags: { json?: boolean },
659
+ ): Promise<number> {
660
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
661
+ const authed = authedClient(deps, config)
662
+ if (!authed) return EXIT.auth
663
+ try {
664
+ const snapshot = await authed.client.evidence(requestId)
665
+ if (flags.json) {
666
+ deps.io.out(JSON.stringify(snapshot, null, 2))
667
+ return EXIT.ok
668
+ }
669
+ deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
670
+ for (const d of snapshot.deliveries) {
671
+ deps.io.out(` ${d.device_name}: ${d.state} after ${d.attempts} attempt(s)`)
672
+ for (const e of d.events) {
673
+ deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
674
+ }
675
+ }
676
+ return EXIT.ok
677
+ } catch (err) {
678
+ return reportError(deps, err)
679
+ }
680
+ }
681
+
682
+ const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
683
+ '.jpg': 'image/jpeg',
684
+ '.jpeg': 'image/jpeg',
685
+ '.png': 'image/png',
686
+ '.gif': 'image/gif',
687
+ }
688
+
689
+ type UploadResult =
690
+ | { ok: true; mediaId: string }
691
+ | { ok: false; error: string; exit: number }
692
+
693
+ /** `--image` accepts a media id, a local file path, or an http(s) URL. */
694
+ async function uploadImage(deps: CommandDeps, client: ApiClient, source: string): Promise<UploadResult> {
695
+ let bytes: Uint8Array
696
+ let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | undefined
697
+ if (/^https?:\/\//.test(source)) {
698
+ try {
699
+ const response = await fetch(source)
700
+ if (!response.ok) return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage }
701
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
702
+ mediaType = (['image/jpeg', 'image/png', 'image/gif'] as const).find((t) => t === contentType)
703
+ bytes = new Uint8Array(await response.arrayBuffer())
704
+ } catch (err) {
705
+ return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network }
706
+ }
707
+ } else {
708
+ if (!existsSync(source)) {
709
+ return { ok: false, error: `--image: "${source}" is not a media id, file, or URL.`, exit: EXIT.usage }
710
+ }
711
+ bytes = new Uint8Array(readFileSync(source))
712
+ mediaType = MEDIA_TYPES[path.extname(source).toLowerCase()]
713
+ }
714
+ if (!mediaType) {
715
+ return { ok: false, error: 'Images must be JPEG, PNG, or GIF.', exit: EXIT.usage }
716
+ }
717
+ try {
718
+ const grant = await client.createMediaUpload({
719
+ media_type: mediaType,
720
+ size_bytes: bytes.byteLength,
721
+ sha256: sha256Hex(bytes),
722
+ })
723
+ await client.uploadMedia(grant, bytes)
724
+ return { ok: true, mediaId: grant.media_id }
725
+ } catch (err) {
726
+ return { ok: false, error: err instanceof Error ? err.message : String(err), exit: EXIT.network }
727
+ }
728
+ }
729
+
730
+ // ---------------------------------------------------------------------------
731
+ // hook / ask / close — harness integration
732
+ // ---------------------------------------------------------------------------
733
+
734
+ export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
735
+ export type HookEvent = (typeof HOOK_EVENTS)[number]
736
+
737
+ /**
738
+ * Runs one harness hook. Contract with every harness: hook JSON arrives on
739
+ * stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
740
+ * exit 0 with no stdout means "no decision — carry on as normal".
741
+ *
742
+ * Every failure path in here must reach that no-decision state. A hook that
743
+ * throws, or that blocks past the harness's timeout, degrades the agent for a
744
+ * feature the user only asked to make it more convenient.
745
+ */
746
+ export async function hookRunCommand(
747
+ deps: CommandDeps,
748
+ event: string,
749
+ readStdin: () => Promise<string>,
750
+ harness?: 'cursor',
751
+ ): Promise<number> {
752
+ if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
753
+ deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
754
+ return EXIT.usage
755
+ }
756
+ let envelope: HookEnvelope
757
+ try {
758
+ envelope = parseHookInput(await readStdin())
759
+ if (harness === 'cursor') {
760
+ const sessionId = envelope.session_id ?? envelope.conversation_id
761
+ const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
762
+ envelope = {
763
+ ...envelope,
764
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
765
+ ...(cwd === undefined ? {} : { cwd }),
766
+ stop_hook_active:
767
+ envelope.stop_hook_active ??
768
+ (typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
769
+ }
770
+ }
771
+ } catch {
772
+ return EXIT.ok
773
+ }
774
+
775
+ // Everything below is inside one fail-open boundary. Config parsing,
776
+ // credential loading and client construction can all throw, and a hook that
777
+ // exits non-zero makes the harness report a failure — strictly worse for the
778
+ // user than not having installed the hook at all.
779
+ try {
780
+ if (event === 'session-end') {
781
+ const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
782
+ for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
783
+ return EXIT.ok
784
+ }
785
+
786
+ // Resolve config against the session's project rather than our own working
787
+ // directory. `cwd` is in the payload precisely because which project a
788
+ // session belongs to is the harness's business, not ours.
789
+ const cwd = envelope.cwd ?? deps.cwd
790
+ const config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
791
+ const credential = deps.store.load()
792
+ if (!credential) return EXIT.ok
793
+ // Pin authenticated traffic to the origin the credential was issued for. A
794
+ // repository can commit `.notifai/config.toml`, and honouring a base_url
795
+ // from it would hand this machine's bearer token to whatever host it names.
796
+ const baseUrl = credential.baseUrl
797
+ if (config.base_url.source !== 'default' && config.base_url.value !== baseUrl) {
798
+ deps.io.err(
799
+ `notifai: ignoring base_url from ${config.base_url.source}; hooks only talk to ${baseUrl}`,
800
+ )
801
+ }
802
+ // UserPromptSubmit runs in front of the user's own prompt under a 15s
803
+ // harness ceiling and can make two calls, so each gets a small slice of it;
804
+ // Stop is allowed to block and keeps the ordinary budget.
805
+ const client = makeClient(
806
+ deps,
807
+ baseUrl,
808
+ `Bearer nfm_${credential.machineId}.${credential.secret}`,
809
+ { timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000 },
810
+ )
811
+ const now = deps.now ?? Date.now
812
+ const ctx: HookContext = {
813
+ client,
814
+ config,
815
+ env: deps.env,
816
+ now,
817
+ idleSeconds: deps.idleSeconds ?? (() => readIdleSeconds()),
818
+ sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
819
+ waitForFirstReply: async (requestId, timeoutSeconds) => {
820
+ const result = await waitForReply(client, requestId, {
821
+ timeoutSeconds,
822
+ afterSeq: 0,
823
+ now: deps.now,
824
+ sleep: deps.sleep,
825
+ })
826
+ return {
827
+ replies: result.response.replies,
828
+ timedOut: result.timedOut,
829
+ degraded: result.degraded,
830
+ }
831
+ },
832
+ }
833
+
834
+ // Real clock, deliberately, not `deps.now`. This compares against file
835
+ // mtimes, which are wall-clock facts — handing it a virtual or skewed clock
836
+ // would have it delete live session state as "abandoned".
837
+ // Rate-limited to once a day by its own stamp file, so the common cost is
838
+ // one stat on a hook that sits on the critical path of every turn.
839
+ pruneAbandonedSessions(deps.env)
840
+
841
+ const outcome =
842
+ event === 'user-prompt-submit'
843
+ ? await handleUserPromptSubmit(ctx, envelope)
844
+ : await handleStop(ctx, envelope)
845
+ for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
846
+ if (outcome.stdout !== undefined) {
847
+ let stdout = outcome.stdout
848
+ if (harness === 'cursor' && event === 'stop') {
849
+ const decision = JSON.parse(outcome.stdout) as { decision?: unknown; reason?: unknown }
850
+ if (decision.decision === 'block' && typeof decision.reason === 'string') {
851
+ stdout = JSON.stringify({ followup_message: decision.reason })
852
+ }
853
+ }
854
+ deps.io.out(stdout)
855
+ }
856
+ return EXIT.ok
857
+ } catch (err) {
858
+ for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
859
+ return EXIT.ok
860
+ }
861
+ }
862
+
863
+ /**
864
+ * What went wrong, in terms of what to do about it.
865
+ *
866
+ * On 2026-08-03 a contract change shipped without the server deploy that goes
867
+ * with it. The CLI stamped `lifecycle` on every question draft, the deployed
868
+ * server rejected the unknown field, and escalation stopped working entirely —
869
+ * announced as "hook failed, deferring to the terminal", which reads like a
870
+ * flaky network. The information needed to diagnose it in one second was
871
+ * already in hand: a 422 whose details name the offending path. It was being
872
+ * thrown away by `String(err)`.
873
+ *
874
+ * A hook still exits 0 whatever this says. Handing the terminal back is always
875
+ * right; the only question is whether the user is told anything they can use.
876
+ */
877
+ export function describeHookFailure(err: unknown): string[] {
878
+ if (!(err instanceof ApiCallError)) {
879
+ return [`hook failed, deferring to the terminal (${String(err)})`]
880
+ }
881
+ const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`]
882
+ const paths = rejectedPaths(err.details)
883
+ if (paths.length > 0) lines.push(`the server rejected: ${paths.join(', ')}`)
884
+ // A 422 on a draft this CLI built is not a user error — this CLI's own
885
+ // contract produced it. Either the server is behind, or the two disagree.
886
+ if (err.status === 422) {
887
+ lines.push(
888
+ 'this build sent a field the server did not accept, which usually means the server ' +
889
+ 'is older than this CLI — check with `notifai doctor`',
890
+ )
891
+ }
892
+ return lines
893
+ }
894
+
895
+ function rejectedPaths(details: unknown): string[] {
896
+ if (!Array.isArray(details)) return []
897
+ return details
898
+ .map((issue) =>
899
+ typeof issue === 'object' && issue !== null && typeof (issue as { path?: unknown }).path === 'string'
900
+ ? (issue as { path: string }).path
901
+ : null,
902
+ )
903
+ .filter((path): path is string => path !== null && path !== '')
904
+ .slice(0, 5)
905
+ }
906
+
907
+ export interface AskFlags {
908
+ choice?: string | string[]
909
+ session?: string
910
+ }
911
+
912
+ /**
913
+ * Registers a question for turn-end routing under the user's presence config.
914
+ * Returns immediately so the agent can ask in prose and end its turn. With the
915
+ * default presence gate, recent keyboard or mouse activity keeps it local;
916
+ * `require_idle = false` intentionally permits a push while the user is active.
917
+ */
918
+ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
919
+ // An agent calling this gets no hook payload and no harness exports its
920
+ // session id, so the UserPromptSubmit hook leaves a pointer keyed on the
921
+ // project directory and we read it back here. The pointer outranks the
922
+ // NOTIFAI_SESSION fallback deliberately: the exported id is often a chosen
923
+ // label rather than the harness's own id, and the hooks key state by the
924
+ // latter — the env var is only trusted when no hook has spoken.
925
+ const now = (deps.now ?? Date.now)()
926
+ const sessionId =
927
+ flags.session ??
928
+ readProjectSession(deps.cwd, deps.env, now) ??
929
+ deps.env['NOTIFAI_SESSION'] ??
930
+ undefined
931
+ if (!sessionId) {
932
+ for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
933
+ return EXIT.usage
934
+ }
935
+ if (question.trim() === '') {
936
+ deps.io.err('The question cannot be empty.')
937
+ return EXIT.usage
938
+ }
939
+ // Validate here, not at push time. The push happens inside a hook, where a
940
+ // rejection becomes a stderr note the agent never reads — so a malformed
941
+ // choice set would look like it registered fine and then silently never ask.
942
+ const choices = parseChoices(flags.choice)
943
+ if (choices === 'invalid') {
944
+ deps.io.err(CHOICE_USAGE)
945
+ return EXIT.usage
946
+ }
947
+ registerQuestion(
948
+ sessionId,
949
+ deps.env,
950
+ {
951
+ question: question.trim(),
952
+ ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
953
+ },
954
+ (deps.now ?? Date.now)(),
955
+ )
956
+ if (choices !== null) {
957
+ deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`)
958
+ }
959
+ // On stdout, not stderr: the agent reads the former and this is only useful
960
+ // if it is read while there is still time to fix the question.
961
+ const ambiguous = ambiguousChoiceSplit(flags.choice)
962
+ if (ambiguous !== null) deps.io.out(ambiguous)
963
+ deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.')
964
+ return EXIT.ok
965
+ }
966
+
967
+ /**
968
+ * Why `ask` cannot see a session, in terms of what to do about it.
969
+ *
970
+ * Only a UserPromptSubmit hook firing produces the pointer this reads, and the
971
+ * old message answered every cause with "run `notifai hooks install` and send
972
+ * one prompt". The useful next action depends on the harness: some reload
973
+ * project hook files, OpenCode loads its plugin at startup, and Codex should be
974
+ * checked after a prompt before assuming that a new session is required.
975
+ */
976
+ function diagnoseMissingSession(deps: CommandDeps): string[] {
977
+ const installations = findInstallations(deps.cwd, deps.env)
978
+ if (installations.length === 0) {
979
+ return [
980
+ 'Could not tell which harness session this is: no NotifAI hooks are installed for this project.',
981
+ 'Run `notifai hooks install`, then follow the activation instruction it prints.',
982
+ ]
983
+ }
984
+ const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
985
+ return [
986
+ `Could not tell which harness session this is. NotifAI hooks are installed (${where}),`,
987
+ 'but no usable session pointer from the last 24 hours exists here.',
988
+ hookActivationAdvice(installations),
989
+ 'To ask from this session anyway, pass --session <id>.',
990
+ ]
991
+ }
992
+
993
+ /** The least disruptive verified way to make each installed adapter run once. */
994
+ function hookActivationAdvice(installations: Installation[]): string {
995
+ const harnesses = new Set(installations.map((installation) => installation.harness))
996
+ const advice: string[] = []
997
+ if (
998
+ installations.some(
999
+ (installation) => installation.harness === 'claude-code' && !installation.global,
1000
+ )
1001
+ ) {
1002
+ advice.push('Claude Code: send one new prompt; project hook files reload without a restart')
1003
+ }
1004
+ if (
1005
+ installations.some(
1006
+ (installation) => installation.harness === 'claude-code' && installation.global,
1007
+ )
1008
+ ) {
1009
+ advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire')
1010
+ }
1011
+ if (
1012
+ installations.some(
1013
+ (installation) => installation.harness === 'cursor' && !installation.global,
1014
+ )
1015
+ ) {
1016
+ advice.push(
1017
+ 'Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired',
1018
+ )
1019
+ }
1020
+ if (
1021
+ installations.some(
1022
+ (installation) => installation.harness === 'cursor' && installation.global,
1023
+ )
1024
+ ) {
1025
+ advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire')
1026
+ }
1027
+ if (harnesses.has('codex')) {
1028
+ advice.push(
1029
+ 'Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired',
1030
+ )
1031
+ }
1032
+ if (harnesses.has('opencode')) {
1033
+ advice.push('OpenCode: restart it, then send one prompt; plugins load at startup')
1034
+ }
1035
+ return `${advice.join('. ')}.`
1036
+ }
1037
+
1038
+ /** Retire a question so a late answer is rejected rather than silently lost. */
1039
+ export async function closeCommand(deps: CommandDeps, requestId: string): Promise<number> {
1040
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1041
+ const authed = authedClient(deps, config)
1042
+ if (!authed) return EXIT.auth
1043
+ try {
1044
+ await authed.client.closeReplies(requestId)
1045
+ deps.io.out(`Closed the reply window for ${requestId}.`)
1046
+ return EXIT.ok
1047
+ } catch (err) {
1048
+ return reportError(deps, err)
1049
+ }
1050
+ }
1051
+
1052
+ export interface HooksInstallFlags {
1053
+ harness?: string
1054
+ global?: boolean
1055
+ /** Test seam; production resolves the running CLI. */
1056
+ execPath?: string
1057
+ scriptPath?: string
1058
+ }
1059
+
1060
+ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
1061
+ const harness = resolveHarness(deps, flags.harness)
1062
+ if (!harness) return EXIT.usage
1063
+ const execPath = flags.execPath ?? process.execPath
1064
+ const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
1065
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1066
+ const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
1067
+
1068
+ // OpenCode's adapter is a generated plugin module rather than a handler
1069
+ // merged into a settings document, so it owns the whole file.
1070
+ if (harness === 'opencode') {
1071
+ return installOpencodePlugin(deps, file, {
1072
+ execPath,
1073
+ scriptPath,
1074
+ timeoutSeconds: blockingHookTimeoutSeconds(
1075
+ config.ask_grace_seconds.value,
1076
+ config.hook_reply_timeout_seconds.value,
1077
+ ),
1078
+ })
1079
+ }
1080
+
1081
+ if (harness === 'cursor') {
1082
+ let document
1083
+ try {
1084
+ document = loadCursorSettings(file)
1085
+ } catch (err) {
1086
+ deps.io.err(String(err))
1087
+ return EXIT.failed
1088
+ }
1089
+ const merged = mergeCursorHooks(
1090
+ document,
1091
+ buildCursorHookConfig({
1092
+ execPath,
1093
+ scriptPath,
1094
+ replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1095
+ graceSeconds: config.ask_grace_seconds.value,
1096
+ }),
1097
+ scriptPath,
1098
+ )
1099
+ try {
1100
+ applyPlan(file, merged.document)
1101
+ } catch (err) {
1102
+ deps.io.err(String(err))
1103
+ return EXIT.failed
1104
+ }
1105
+ deps.io.out(`Installed ${harness} hooks in ${file}`)
1106
+ if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
1107
+ if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
1108
+ if (flags.global) {
1109
+ deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
1110
+ deps.io.out('start a new Cursor session and try one prompt again.')
1111
+ } else {
1112
+ deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
1113
+ deps.io.out('start a new Cursor session and try one prompt again.')
1114
+ }
1115
+ deps.io.out('A companion-device answer is submitted as one')
1116
+ deps.io.out('follow-up user message; loop_limit = 1 prevents repeated answer turns.')
1117
+ return EXIT.ok
1118
+ }
1119
+
1120
+ let document
1121
+ try {
1122
+ document = loadSettings(file)
1123
+ } catch (err) {
1124
+ deps.io.err(String(err))
1125
+ return EXIT.failed
1126
+ }
1127
+ const merged = mergeHooks(
1128
+ document,
1129
+ buildHookConfig({
1130
+ execPath,
1131
+ scriptPath,
1132
+ replyTimeoutSeconds: config.hook_reply_timeout_seconds.value,
1133
+ graceSeconds: config.ask_grace_seconds.value,
1134
+ }),
1135
+ scriptPath,
1136
+ )
1137
+ try {
1138
+ applyPlan(file, merged.document)
1139
+ } catch (err) {
1140
+ deps.io.err(String(err))
1141
+ return EXIT.failed
1142
+ }
1143
+
1144
+ deps.io.out(`Installed ${harness} hooks in ${file}`)
1145
+ if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
1146
+ if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
1147
+ if (merged.removed.length > 0) {
1148
+ deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
1149
+ }
1150
+ deps.io.out('')
1151
+ deps.io.out(
1152
+ config.require_idle.value
1153
+ ? `While keyboard or mouse idle time stays below ${config.away_after_seconds.value}s, ` +
1154
+ `nothing is pushed. A question registered with \`notifai ask\` stays in the terminal ` +
1155
+ `until its ${config.ask_grace_seconds.value}s grace window, counted from registration, ` +
1156
+ `has elapsed; it goes to your devices only while the machine also meets the idle threshold. ` +
1157
+ `Set \`require_idle = false\` to be notified even while you are working.`
1158
+ : `A question registered with \`notifai ask\` stays in the terminal for ` +
1159
+ `${config.ask_grace_seconds.value}s from registration and then goes to your devices ` +
1160
+ `whether or not you are at this machine ` +
1161
+ `(\`require_idle = false\`).`,
1162
+ )
1163
+ if (config.require_idle.value) {
1164
+ deps.io.out(
1165
+ 'If this OS exposes no keyboard/mouse idle signal, the hook falls back to prompt silence and skips the blocking grace once it decides you are away.',
1166
+ )
1167
+ }
1168
+ if (harness === 'codex') {
1169
+ const layer = flags.global ? null : codexLayerDir(deps.cwd)
1170
+ if (layer !== null) {
1171
+ // Codex reads project hooks from the main repository but only looks when
1172
+ // a `.codex` directory sits at or above cwd, so a worktree install has to
1173
+ // write one file and create one directory in two different places. Doing
1174
+ // it silently would leave the next person deriving this the hard way
1175
+ //.
1176
+ mkdirSync(layer, { recursive: true })
1177
+ deps.io.out('')
1178
+ deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,')
1179
+ deps.io.out(`so they were written to ${file}. ${layer} was created so this`)
1180
+ deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`')
1181
+ deps.io.out('directory; rerun this installer from that worktree to create it.')
1182
+ }
1183
+ }
1184
+ deps.io.out('')
1185
+ if (harness === 'claude-code' && flags.global !== true) {
1186
+ deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,')
1187
+ deps.io.out('then check `notifai doctor` to confirm that the hook fired.')
1188
+ } else if (harness === 'claude-code') {
1189
+ deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has')
1190
+ deps.io.out('not fired, start a new Claude Code session and try one prompt again.')
1191
+ } else {
1192
+ deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,')
1193
+ deps.io.out('start a new Codex session and try one prompt again.')
1194
+ }
1195
+ return EXIT.ok
1196
+ }
1197
+
1198
+ /**
1199
+ * Writes the OpenCode plugin, replacing any NotifAI plugin already there —
1200
+ * including one a different checkout wrote, matched on the managed marker for
1201
+ * the same reason command hooks are.
1202
+ */
1203
+ function installOpencodePlugin(
1204
+ deps: CommandDeps,
1205
+ file: string,
1206
+ options: { execPath: string; scriptPath: string; timeoutSeconds: number },
1207
+ ): number {
1208
+ if (existsSync(file)) {
1209
+ const existing = readFileSync(file, 'utf8')
1210
+ if (!isOurOpencodePlugin(existing)) {
1211
+ deps.io.err(`${file} exists and was not written by NotifAI; move it aside first.`)
1212
+ return EXIT.failed
1213
+ }
1214
+ }
1215
+ try {
1216
+ mkdirSync(path.dirname(file), { recursive: true })
1217
+ writeFileSync(file, opencodePluginSource(options), { mode: 0o644 })
1218
+ } catch (err) {
1219
+ deps.io.err(String(err))
1220
+ return EXIT.failed
1221
+ }
1222
+ deps.io.out(`Installed the OpenCode plugin at ${file}`)
1223
+ deps.io.out('')
1224
+ deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
1225
+ deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
1226
+ deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
1227
+ deps.io.out('OpenCode cannot reliably resume an idle agent turn with a device answer; use')
1228
+ deps.io.out('`notifai send --reply` when the answer must return to the agent.')
1229
+ deps.io.out('')
1230
+ deps.io.out('Restart OpenCode: it loads plugins once at start.')
1231
+ return EXIT.ok
1232
+ }
1233
+
1234
+ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
1235
+ const harness = resolveHarness(deps, flags.harness)
1236
+ if (!harness) return EXIT.usage
1237
+ const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
1238
+ const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
1239
+ if (!existsSync(file)) {
1240
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
1241
+ return EXIT.ok
1242
+ }
1243
+ if (harness === 'opencode') {
1244
+ // We own the whole file, but only if we wrote it.
1245
+ if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
1246
+ deps.io.out(`Left ${file} alone: NotifAI did not write it.`)
1247
+ return EXIT.ok
1248
+ }
1249
+ rmSync(file, { force: true })
1250
+ deps.io.out(`Removed the NotifAI OpenCode plugin at ${file}`)
1251
+ return EXIT.ok
1252
+ }
1253
+ if (harness === 'cursor') {
1254
+ let document
1255
+ try {
1256
+ document = loadCursorSettings(file)
1257
+ } catch (err) {
1258
+ deps.io.err(String(err))
1259
+ return EXIT.failed
1260
+ }
1261
+ const stripped = removeCursorHooks(document, scriptPath)
1262
+ try {
1263
+ applyPlan(file, stripped.document)
1264
+ } catch (err) {
1265
+ deps.io.err(String(err))
1266
+ return EXIT.failed
1267
+ }
1268
+ deps.io.out(
1269
+ stripped.replaced.length > 0
1270
+ ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1271
+ : `No NotifAI hooks found in ${file}`,
1272
+ )
1273
+ return EXIT.ok
1274
+ }
1275
+ let document
1276
+ try {
1277
+ document = loadSettings(file)
1278
+ } catch (err) {
1279
+ deps.io.err(String(err))
1280
+ return EXIT.failed
1281
+ }
1282
+ const stripped = removeHooks(document, scriptPath)
1283
+ try {
1284
+ applyPlan(file, stripped.document)
1285
+ } catch (err) {
1286
+ deps.io.err(String(err))
1287
+ return EXIT.failed
1288
+ }
1289
+ deps.io.out(
1290
+ stripped.replaced.length > 0
1291
+ ? `Removed NotifAI hooks (${stripped.replaced.join(', ')}) from ${file}`
1292
+ : `No NotifAI hooks found in ${file}`,
1293
+ )
1294
+ return EXIT.ok
1295
+ }
1296
+
1297
+ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
1298
+ if (requested !== undefined) {
1299
+ if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
1300
+ deps.io.err(
1301
+ `Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`,
1302
+ )
1303
+ return null
1304
+ }
1305
+ const detected = detectHarness(deps.cwd)
1306
+ if (!detected) {
1307
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
1308
+ return null
1309
+ }
1310
+ return detected
1311
+ }
1312
+
1313
+ // ---------------------------------------------------------------------------
1314
+ // config show / set
1315
+ // ---------------------------------------------------------------------------
1316
+
1317
+ export function configShowCommand(
1318
+ deps: CommandDeps,
1319
+ flags: { json?: boolean; explain?: boolean },
1320
+ ): number {
1321
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1322
+ if (flags.json) {
1323
+ const output = Object.fromEntries(
1324
+ CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
1325
+ )
1326
+ deps.io.out(JSON.stringify(output, null, 2))
1327
+ return EXIT.ok
1328
+ }
1329
+ for (const key of CONFIG_KEYS) {
1330
+ const entry = config[key]
1331
+ const provenance = flags.explain ? ` [${entry.source}]` : ''
1332
+ deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`)
1333
+ }
1334
+ return EXIT.ok
1335
+ }
1336
+
1337
+ export async function configSetCommand(
1338
+ deps: CommandDeps,
1339
+ key: string,
1340
+ rawValue: string,
1341
+ flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
1342
+ ): Promise<number> {
1343
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1344
+ deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`)
1345
+ return EXIT.usage
1346
+ }
1347
+ let value: unknown = rawValue
1348
+ if (NUMERIC_CONFIG_KEYS.includes(key as ConfigKey)) {
1349
+ const numeric = Number(rawValue)
1350
+ if (!Number.isInteger(numeric)) {
1351
+ deps.io.err(`"${rawValue}" is not an integer.`)
1352
+ return EXIT.usage
1353
+ }
1354
+ const bounds = configBounds(key as ConfigKey)
1355
+ if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1356
+ deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
1357
+ return EXIT.usage
1358
+ }
1359
+ value = numeric
1360
+ }
1361
+ if (BOOLEAN_CONFIG_KEYS.includes(key as ConfigKey)) {
1362
+ if (rawValue !== 'true' && rawValue !== 'false') {
1363
+ deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
1364
+ return EXIT.usage
1365
+ }
1366
+ value = rawValue === 'true'
1367
+ }
1368
+ if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
1369
+
1370
+ let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
1371
+ if (
1372
+ flags.session === undefined &&
1373
+ flags.local !== true &&
1374
+ flags.project !== true &&
1375
+ flags.yes !== true &&
1376
+ deps.io.interactive === true &&
1377
+ deps.io.select
1378
+ ) {
1379
+ const selected = await deps.io.select('Where should this setting live?', [
1380
+ { value: 'global', label: 'This machine', hint: 'applies across projects' },
1381
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
1382
+ { value: 'local', label: 'This project (personal)', hint: 'keep config.local.toml gitignored' },
1383
+ ])
1384
+ if (selected === null) {
1385
+ deps.io.err('No configuration layer selected.')
1386
+ return EXIT.usage
1387
+ }
1388
+ layer = selected
1389
+ }
1390
+
1391
+ const targetPath = flags.session
1392
+ ? sessionConfigPath(flags.session, deps.env)
1393
+ : layer === 'local'
1394
+ ? (findProjectLocalConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.local.toml'))
1395
+ : layer === 'project'
1396
+ ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
1397
+ : globalConfigPath(deps.env)
1398
+
1399
+ if (!flags.yes) {
1400
+ const confirmed = await deps.io.confirm(`Set ${key} = ${JSON.stringify(value)} in ${targetPath}?`)
1401
+ if (!confirmed) {
1402
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
1403
+ return EXIT.usage
1404
+ }
1405
+ }
1406
+
1407
+ const existing = existsSync(targetPath)
1408
+ ? (parseToml(readFileSync(targetPath, 'utf8')) as Record<string, unknown>)
1409
+ : {}
1410
+ existing[key] = value
1411
+ mkdirSync(path.dirname(targetPath), { recursive: true })
1412
+ writeFileSync(targetPath, `${stringifyToml(existing)}\n`)
1413
+ deps.io.out(`Wrote ${key} to ${targetPath}`)
1414
+ return EXIT.ok
1415
+ }
1416
+
1417
+ // ---------------------------------------------------------------------------
1418
+ // init
1419
+ // ---------------------------------------------------------------------------
1420
+
1421
+ /**
1422
+ * Where `npx skills add` fetches the optional agent skill from. The skill's
1423
+ * canonical home is the public repository (RafaelVidaurre/notifai), which has
1424
+ * nothing pushed to it yet, so no source can resolve today; the guard below
1425
+ * keeps `--skills` from installing anything until a tagged release exists to
1426
+ * pin this to. Never point this at the private repository — the installer
1427
+ * command is printed to users, and only public sources belong in it.
1428
+ */
1429
+ const SKILLS_SOURCE = ''
1430
+
1431
+ /** Derive a contract-valid project slug from a directory name. */
1432
+ export function projectSlugFrom(name: string): string {
1433
+ const slug = name
1434
+ .toLowerCase()
1435
+ .replaceAll(/[^a-z0-9._-]+/g, '-')
1436
+ .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
1437
+ .slice(0, 64)
1438
+ return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project'
1439
+ }
1440
+
1441
+ export interface InitFlags {
1442
+ projectId?: string
1443
+ /**
1444
+ * Install the agent guidance skill. Tri-state on purpose:
1445
+ * true installs, false skips silently, and undefined means "offer it when a
1446
+ * human is present, do nothing when one is not" — an unattended run must
1447
+ * never spawn npx against the network by default.
1448
+ */
1449
+ skills?: boolean
1450
+ /** Same tri-state, for the harness hooks. */
1451
+ hooks?: boolean
1452
+ }
1453
+
1454
+ /**
1455
+ * The setup coordinator that observes each prerequisite and advances the ones
1456
+ * this build can perform.
1457
+ *
1458
+ * Idempotent by construction: every step first observes, then acts only on the
1459
+ * gap, so re-running is how you check the setup as much as how you create it.
1460
+ * With a human at a terminal it walks them through the missing pieces; run by
1461
+ * an agent it never prompts — each optional step is answered by a flag, and
1462
+ * whatever only the user can do (signing in, pairing a companion device) is printed as
1463
+ * the exact command to hand back to them.
1464
+ */
1465
+ /**
1466
+ * Close a gap the CLI is allowed to close on its own, without asking.
1467
+ *
1468
+ * Only reached for `by: 'cli'` remedies, which by definition need no human, so
1469
+ * this stays silent about what it did — the re-assessment that follows reports
1470
+ * the new state, and narrating both is how a setup log becomes unreadable.
1471
+ *
1472
+ * Returns false when the attempt failed, which is different from declining:
1473
+ * a decline leaves an optional gap open and is fine, a failure is not.
1474
+ */
1475
+ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
1476
+ if (state.id === 'project') {
1477
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
1478
+ const existing = existsSync(configPath)
1479
+ ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
1480
+ : {}
1481
+ existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
1482
+ mkdirSync(path.dirname(configPath), { recursive: true })
1483
+ writeFileSync(configPath, `${stringifyToml(existing)}\n`)
1484
+ return true
1485
+ }
1486
+
1487
+ if (state.id === 'hooks') {
1488
+ let harness = detectHarness(deps.cwd)
1489
+ if (harness === null && deps.io.interactive === true && deps.io.select) {
1490
+ const picked = await deps.io.select(
1491
+ 'Which agent harness do you use here?',
1492
+ HARNESSES.map((name) => ({ value: name, label: name })),
1493
+ )
1494
+ if (picked !== null) harness = picked as Harness
1495
+ }
1496
+ if (harness === null) {
1497
+ deps.io.err(
1498
+ `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
1499
+ )
1500
+ return false
1501
+ }
1502
+ return hooksInstallCommand(deps, { harness }) === EXIT.ok
1503
+ }
1504
+
1505
+ if (state.id === 'skill') {
1506
+ deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`)
1507
+ const code = await new Promise<number>((resolve) => {
1508
+ const child = spawn('npx', ['-y', 'skills', 'add', SKILLS_SOURCE, '--skill', 'notifai'], {
1509
+ cwd: deps.cwd,
1510
+ stdio: 'inherit',
1511
+ })
1512
+ child.on('error', () => resolve(1))
1513
+ child.on('exit', (exitCode) => resolve(exitCode ?? 1))
1514
+ })
1515
+ if (code !== 0) {
1516
+ deps.io.err('Skill installation failed — run it manually with:')
1517
+ deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`)
1518
+ }
1519
+ return code === 0
1520
+ }
1521
+
1522
+ return false
1523
+ }
1524
+
1525
+ /** Whether an optional gap should be closed, given flags and who is watching. */
1526
+ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
1527
+ // Naming the project is init's whole reason to touch the filesystem, costs
1528
+ // nothing, and is undone by editing one line — so it is done rather than
1529
+ // asked about, for a human and an agent alike.
1530
+ if (state.id === 'project') return Promise.resolve(true)
1531
+ const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined
1532
+ if (explicit !== undefined) return Promise.resolve(explicit)
1533
+ // An agent is never asked, and never assumed into a change it did not
1534
+ // request: silence means no, and the summary says what was skipped.
1535
+ if (deps.io.interactive !== true) return Promise.resolve(false)
1536
+ const question =
1537
+ state.id === 'hooks'
1538
+ ? 'Install harness hooks, so questions reach your devices when you are away?'
1539
+ : 'Install/update the agent guidance skill in this repo?'
1540
+ return deps.io.confirm(question, true)
1541
+ }
1542
+
1543
+ /**
1544
+ * Setup as one step at a time.
1545
+ *
1546
+ * The old version ran five steps in a fixed order and ended with a list of
1547
+ * everything still outstanding. That is a report, and a report is the wrong
1548
+ * output here: someone handed five things to do does none of them, and the
1549
+ * order was the script's rather than the dependency graph's — it offered to
1550
+ * install hooks after a sign-in that had just failed.
1551
+ *
1552
+ * So this closes what it can, then surfaces exactly one thing, the first that
1553
+ * stands in the way. Re-running advances by one. Idempotence stops being a
1554
+ * property to preserve and becomes the mechanism: every decision is derived
1555
+ * from observed state, so a partial run, a second project, a fresh worktree
1556
+ * and a revoked credential are the same code path arriving at different
1557
+ * states rather than four branches to enumerate.
1558
+ */
1559
+ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
1560
+ await deps.io.intro?.('NotifAI setup')
1561
+
1562
+ if (SKILLS_SOURCE === '' && flags.skills === true) {
1563
+ deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.')
1564
+ return EXIT.failed
1565
+ }
1566
+
1567
+ let readiness = await assessReadiness(deps)
1568
+ let failed = false
1569
+
1570
+ // One pass is enough: closing a gap never opens an earlier one, and the
1571
+ // states that remain are exactly those needing someone who is not the CLI.
1572
+ for (const state of readiness.states) {
1573
+ const remedy = state.remedy
1574
+ if (remedy === undefined) continue
1575
+
1576
+ // Its to launch, theirs to complete. Offering to start the sign-in is the
1577
+ // single most useful thing this command does for a person, so it is worth
1578
+ // the extra branch — but only when someone is actually watching, since an
1579
+ // agent that reached a browser prompt would simply hang.
1580
+ if (remedy.by === 'user-here' && remedy.interactive === true) {
1581
+ if (state.status !== 'gap' || deps.io.interactive !== true) continue
1582
+ if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) continue
1583
+ if ((await loginCommand(deps, {})) !== EXIT.ok) failed = true
1584
+ continue
1585
+ }
1586
+
1587
+ if (remedy.by !== 'cli') continue
1588
+ if (state.status === 'gap') {
1589
+ if (!(await closeGap(deps, state, flags))) failed = true
1590
+ } else if (state.status === 'optional-gap' && (await wantsOptional(deps, state, flags))) {
1591
+ if (!(await closeGap(deps, state, flags))) failed = true
1592
+ }
1593
+ }
1594
+
1595
+ readiness = await assessReadiness(deps)
1596
+ for (const state of readiness.states.filter((s) => s.status === 'ready')) {
1597
+ deps.io.out(`${state.title}: ${state.detail}`)
1598
+ }
1599
+
1600
+ const blocker = firstBlocker(readiness)
1601
+ if (blocker === null) {
1602
+ const skipped = openItems(readiness)
1603
+ for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
1604
+ deps.io.out('All set. Agents in this project can notify you and ask questions.')
1605
+ await deps.io.outro?.('All set ✨')
1606
+ return failed ? EXIT.failed : EXIT.ok
1607
+ }
1608
+
1609
+ // Exactly one. Everything else waits until this is done, because the next
1610
+ // gap is frequently a consequence of this one and naming it now would send
1611
+ // the reader off to fix something that is not actually wrong.
1612
+ deps.io.out('')
1613
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
1614
+ deps.io.out(` ${remedyLine(blocker)}`)
1615
+ if (blocker.remedy?.by === 'user-elsewhere') {
1616
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
1617
+ }
1618
+ await deps.io.outro?.('One step remains (above)')
1619
+ return failed ? EXIT.failed : EXIT.ok
1620
+ }
1621
+
1622
+ // ---------------------------------------------------------------------------
1623
+ // doctor
1624
+ // ---------------------------------------------------------------------------
1625
+
1626
+ /**
1627
+ * Whether the deployed server understands the contract this build speaks
1628
+ *.
1629
+ *
1630
+ * Every test in the suite runs the CLI and the server from the same commit, so
1631
+ * a client sending a field the deployed server has not learned yet is
1632
+ * structurally invisible to it. That gap shipped a silent production outage on
1633
+ * 2026-08-03: questions stopped reaching devices entirely and nothing said so.
1634
+ *
1635
+ * The capability document already carries a schema version and is served
1636
+ * unauthenticated, so a single GET answers the question. This does not claim to
1637
+ * catch every skew — an additive field inside the same schema version would
1638
+ * still pass — but it catches the one that has actually happened, and it names
1639
+ * the remedy rather than leaving someone to derive it.
1640
+ */
1641
+ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boolean; detail: string }> {
1642
+ const local = CAPABILITIES_V1.describe('ios')?.schema_version
1643
+ try {
1644
+ const remote = (await client.capabilities('ios')).schema_version
1645
+ if (local === remote) {
1646
+ return { name: 'contract', ok: true, detail: `server and CLI both speak schema v${remote}` }
1647
+ }
1648
+ return {
1649
+ name: 'contract',
1650
+ ok: false,
1651
+ detail:
1652
+ local !== undefined && remote < local
1653
+ ? `server speaks schema v${remote}, this CLI speaks v${local} — the server needs deploying, ` +
1654
+ 'until then sends carrying newer fields are rejected'
1655
+ : `server speaks schema v${remote}, this CLI speaks v${local} — update the CLI`,
1656
+ }
1657
+ } catch (err) {
1658
+ return {
1659
+ name: 'contract',
1660
+ ok: false,
1661
+ detail: `could not read the server capability document (${err instanceof ApiCallError ? err.code : String(err)})`,
1662
+ }
1663
+ }
1664
+ }
1665
+
1666
+ /**
1667
+ * Read the whole setup once, in dependency order.
1668
+ *
1669
+ * Descent stops where a prerequisite is missing: without a credential there is
1670
+ * nothing to ask the server with, and without a reachable server a contract
1671
+ * mismatch is unknowable rather than absent. Those downstream states report
1672
+ * `unknown`, which is the honest answer and keeps a network outage from
1673
+ * looking like a broken install.
1674
+ */
1675
+ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
1676
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1677
+ const states: ReadinessState[] = []
1678
+
1679
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
1680
+ const projectSlug = config.project.value
1681
+ states.push(
1682
+ projectSlug !== null
1683
+ ? {
1684
+ id: 'project',
1685
+ title: 'Project identity',
1686
+ status: 'ready',
1687
+ detail: `"${projectSlug}" (${config.project.source})`,
1688
+ }
1689
+ : {
1690
+ id: 'project',
1691
+ title: 'Project identity',
1692
+ // Not a blocker: a send without a project simply carries no project
1693
+ // identity. init always sets one because it is free and reversible,
1694
+ // but an unlabelled setup works, so this must not go red.
1695
+ status: 'optional-gap',
1696
+ detail: `not set in ${configPath} — sends from here carry no project identity`,
1697
+ remedy: {
1698
+ by: 'cli',
1699
+ summary: 'name this project after its directory',
1700
+ command: 'notifai init',
1701
+ },
1702
+ },
1703
+ )
1704
+
1705
+ const credential = deps.store.load()
1706
+ states.push(
1707
+ credential
1708
+ ? {
1709
+ id: 'credential',
1710
+ title: 'This machine',
1711
+ status: 'ready',
1712
+ detail: `paired as "${credential.machineName}" (${deps.store.describe()})`,
1713
+ }
1714
+ : {
1715
+ id: 'credential',
1716
+ title: 'This machine',
1717
+ status: 'gap',
1718
+ detail: 'not paired with your account',
1719
+ remedy: {
1720
+ by: 'user-here',
1721
+ summary: 'sign in — this opens your browser to approve the machine',
1722
+ command: 'notifai login',
1723
+ interactive: true,
1724
+ },
1725
+ },
1726
+ )
1727
+
1728
+ const baseUrl = resolvedBaseUrl(config, credential)
1729
+ const anon = makeClient(deps, baseUrl, null)
1730
+ // A probe that throws is unreachable, not a crash: this runs against a
1731
+ // half-configured machine by definition, which is where a client that
1732
+ // cannot even be constructed properly shows up.
1733
+ let reachable = false
1734
+ try {
1735
+ reachable = await anon.health()
1736
+ } catch {
1737
+ reachable = false
1738
+ }
1739
+ states.push(
1740
+ reachable
1741
+ ? { id: 'server', title: 'Service', status: 'ready', detail: `${baseUrl} reachable` }
1742
+ : {
1743
+ id: 'server',
1744
+ title: 'Service',
1745
+ status: 'gap',
1746
+ detail: `cannot reach ${baseUrl} (${config.base_url.source})`,
1747
+ remedy: {
1748
+ by: 'user-here',
1749
+ summary: 'check your network, or the base_url shown above',
1750
+ command: 'notifai doctor',
1751
+ },
1752
+ },
1753
+ )
1754
+
1755
+ if (!reachable) {
1756
+ states.push({
1757
+ id: 'contract',
1758
+ title: 'Protocol version',
1759
+ status: 'unknown',
1760
+ detail: 'not checked — the server is unreachable',
1761
+ })
1762
+ } else {
1763
+ const contract = await contractCheck(anon)
1764
+ states.push(
1765
+ contract.ok
1766
+ ? { id: 'contract', title: 'Protocol version', status: 'ready', detail: contract.detail }
1767
+ : {
1768
+ id: 'contract',
1769
+ title: 'Protocol version',
1770
+ status: 'gap',
1771
+ detail: contract.detail,
1772
+ remedy: {
1773
+ by: 'user-here',
1774
+ summary: 'the CLI and server disagree; the detail above says which to move',
1775
+ command: 'notifai doctor',
1776
+ },
1777
+ },
1778
+ )
1779
+ }
1780
+
1781
+ if (!credential || !reachable) {
1782
+ const why = !credential ? 'this machine is not paired' : 'the server is unreachable'
1783
+ states.push({ id: 'auth', title: 'Account', status: 'unknown', detail: `not checked — ${why}` })
1784
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
1785
+ } else {
1786
+ const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
1787
+ try {
1788
+ const { devices } = await client.listDevices()
1789
+ const ready = devices.filter((d) => d.registration_healthy)
1790
+ states.push({
1791
+ id: 'auth',
1792
+ title: 'Account',
1793
+ status: 'ready',
1794
+ detail: `machine ${credential.machineId} accepted`,
1795
+ })
1796
+ states.push(
1797
+ ready.length > 0
1798
+ ? {
1799
+ id: 'devices',
1800
+ title: 'Your devices',
1801
+ status: 'ready',
1802
+ detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
1803
+ }
1804
+ : {
1805
+ id: 'devices',
1806
+ title: 'Your devices',
1807
+ status: 'gap',
1808
+ // The one gap that cannot be closed from this terminal, and the
1809
+ // likeliest place a first setup is abandoned. Naming which of
1810
+ // the three sub-states it is matters: "install the app" is
1811
+ // useless advice to someone who installed it and denied the
1812
+ // permission prompt.
1813
+ detail:
1814
+ devices.length === 0
1815
+ ? 'nothing registered yet'
1816
+ : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
1817
+ remedy: {
1818
+ by: 'user-elsewhere',
1819
+ summary:
1820
+ devices.length === 0
1821
+ ? 'install the companion app on your phone, sign in with the same account, and allow notifications'
1822
+ : 'allow notifications for NotifAI in that device’s system settings',
1823
+ },
1824
+ },
1825
+ )
1826
+ } catch (err) {
1827
+ // A credential the server rejects is revocation, not absence, and the
1828
+ // remedy is the same sign-in either way.
1829
+ states.push({
1830
+ id: 'auth',
1831
+ title: 'Account',
1832
+ status: 'gap',
1833
+ detail: err instanceof ApiCallError ? `${err.code}: ${err.message}` : String(err),
1834
+ remedy: {
1835
+ by: 'user-here',
1836
+ summary: 'this machine is no longer recognised; pair it again',
1837
+ command: 'notifai login',
1838
+ },
1839
+ })
1840
+ states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
1841
+ }
1842
+ }
1843
+
1844
+ states.push(...hookStates(deps))
1845
+
1846
+ states.push(
1847
+ SKILLS_SOURCE === ''
1848
+ ? {
1849
+ id: 'skill',
1850
+ title: 'Agent guidance skill',
1851
+ // Nothing outstanding, because nothing can be done: this build has
1852
+ // no published skill source configured. Silence here would read as
1853
+ // an omission to anyone who had seen it mentioned in the docs.
1854
+ status: 'ready',
1855
+ detail: 'not applicable — this build has no published skill source configured',
1856
+ }
1857
+ : {
1858
+ id: 'skill',
1859
+ title: 'Agent guidance skill',
1860
+ status: 'optional-gap',
1861
+ detail: 'not installed in this project',
1862
+ remedy: {
1863
+ by: 'cli',
1864
+ summary: 'install the skill agents follow when deciding to notify',
1865
+ command: 'notifai init --skills',
1866
+ },
1867
+ },
1868
+ )
1869
+
1870
+ return { states }
1871
+ }
1872
+
1873
+ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
1874
+ const readiness = await assessReadiness(deps)
1875
+ const blocker = firstBlocker(readiness)
1876
+ const ok = blocker === null
1877
+
1878
+ if (flags.json) {
1879
+ deps.io.out(JSON.stringify({ ok, states: readiness.states }, null, 2))
1880
+ return ok ? EXIT.ok : EXIT.failed
1881
+ }
1882
+
1883
+ const line = (s: ReadinessState) => `${s.title}: ${s.detail}`
1884
+ // Doctor reports everything — that is the difference from init, which acts
1885
+ // on one thing. It still names where to start, because a list of five
1886
+ // problems in dependency order has an obvious first move and saying so
1887
+ // costs nothing.
1888
+ if (deps.io.interactive === true && deps.io.check) {
1889
+ await deps.io.intro?.('NotifAI doctor')
1890
+ for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
1891
+ await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
1892
+ } else {
1893
+ for (const s of readiness.states) {
1894
+ const mark = s.status === 'gap' ? 'FAIL' : s.status === 'unknown' ? ' ? ' : 'ok '
1895
+ deps.io.out(`${mark} ${line(s)}`)
1896
+ }
1897
+ if (!ok) deps.io.out(`\nStart with: ${remedyLine(blocker)}`)
1898
+ }
1899
+ return ok ? EXIT.ok : EXIT.failed
1900
+ }
1901
+
1902
+ /** One line telling the reader what to actually do about a state. */
1903
+ function remedyLine(state: ReadinessState): string {
1904
+ const remedy = state.remedy
1905
+ if (!remedy) return state.detail
1906
+ if (remedy.by === 'user-elsewhere') return remedy.summary
1907
+ return remedy.command === undefined
1908
+ ? remedy.summary
1909
+ : `${remedy.summary} — run \`${remedy.command}\``
1910
+ }
1911
+
1912
+ /**
1913
+ * Whether the hook installation is internally ready, plus evidence that a
1914
+ * project session has fired it before. This cannot prove future execution or
1915
+ * end-to-end notification delivery without a live harness and device test.
1916
+ *
1917
+ * Every failure mode here was found the expensive way, by spawning a session
1918
+ * and watching nothing happen: hooks not installed, installed but never fired,
1919
+ * or left behind by an older build that named events this one does not serve.
1920
+ */
1921
+ /**
1922
+ * Hook diagnostics as readiness states.
1923
+ *
1924
+ * A thin adapter over `hookChecks`, whose every branch was found the expensive
1925
+ * way and is not worth re-deriving. The judgment added here is which failures
1926
+ * actually stand in the way.
1927
+ *
1928
+ * Two do not. A hook that has never fired is the normal condition of an
1929
+ * install thirty seconds old, and OpenCode's inability to resume an idle turn
1930
+ * is a property of that harness rather than a fault in this setup. Treating
1931
+ * either as blocking would mean `init` could never finish for an OpenCode
1932
+ * user, or could only finish after a session had already run — so both report
1933
+ * as things worth knowing rather than things to fix.
1934
+ */
1935
+ function hookStates(deps: CommandDeps): ReadinessState[] {
1936
+ const installations = findInstallations(deps.cwd, deps.env)
1937
+ if (installations.length === 0) {
1938
+ return [
1939
+ {
1940
+ id: 'hooks',
1941
+ title: 'Question routing',
1942
+ status: 'optional-gap',
1943
+ detail: 'hooks not installed, so questions stay in the terminal',
1944
+ remedy: {
1945
+ by: 'cli',
1946
+ summary: 'install harness hooks so questions reach your devices when you are away',
1947
+ command: 'notifai hooks install',
1948
+ },
1949
+ },
1950
+ ]
1951
+ }
1952
+
1953
+ /** Real but not in the way; see the note above. */
1954
+ const informational = new Set(['hooks (fired)', 'hooks (opencode continuation)'])
1955
+ return hookChecks(deps).map((check) => ({
1956
+ id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
1957
+ title: check.name === 'hooks' ? 'Question routing' : check.name,
1958
+ status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
1959
+ detail: check.detail,
1960
+ ...(check.ok
1961
+ ? {}
1962
+ : {
1963
+ remedy: {
1964
+ by: 'user-here' as const,
1965
+ summary: 'the detail above names what to change',
1966
+ command: 'notifai hooks install',
1967
+ },
1968
+ }),
1969
+ }))
1970
+ }
1971
+
1972
+ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
1973
+ const checks: { name: string; ok: boolean; detail: string }[] = []
1974
+ const installations = findInstallations(deps.cwd, deps.env)
1975
+
1976
+ // Not having hooks is a setup someone chose, not a fault: `send` works
1977
+ // without them. A setup that cannot work is what deserves to go red.
1978
+ if (installations.length === 0) {
1979
+ checks.push({
1980
+ name: 'hooks',
1981
+ ok: true,
1982
+ detail: 'not installed (optional) — `notifai hooks install` adds question routing',
1983
+ })
1984
+ return checks
1985
+ }
1986
+ checks.push({
1987
+ name: 'hooks',
1988
+ ok: true,
1989
+ detail: installations
1990
+ .map((i) => `${i.harness} ${i.global ? 'global' : 'project'} (${i.file})`)
1991
+ .join(', '),
1992
+ })
1993
+
1994
+ // A handler naming an event this build dropped exits 2 every time the harness
1995
+ // fires it, which the harness reports as a hook failure.
1996
+ const stale = installations.flatMap((i) =>
1997
+ i.handlers
1998
+ .filter((h) => {
1999
+ const event = handlerEvent(h.command)
2000
+ return event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)
2001
+ })
2002
+ .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
2003
+ )
2004
+ checks.push({
2005
+ name: 'hooks (stale)',
2006
+ ok: stale.length === 0,
2007
+ detail:
2008
+ stale.length === 0
2009
+ ? 'every installed handler names an event this build serves'
2010
+ : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
2011
+ })
2012
+
2013
+ const adapterProblems = installations.flatMap((installation) =>
2014
+ (installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
2015
+ )
2016
+ if (adapterProblems.length > 0) {
2017
+ checks.push({
2018
+ name: 'hooks (adapter)',
2019
+ ok: false,
2020
+ detail: adapterProblems.join('; '),
2021
+ })
2022
+ }
2023
+
2024
+ // Two checkouts each installing hooks means both fire for the same event, and
2025
+ // the user gets every question twice.
2026
+ //
2027
+ // Compared *within* a harness, not across. Only one harness runs a given
2028
+ // session, so having Claude Code and OpenCode both set up is the ordinary
2029
+ // case and not a duplicate — comparing them turned a healthy machine red.
2030
+ const duplicated = [...new Set(installations.map((i) => i.harness))]
2031
+ .map((harness) => ({
2032
+ harness,
2033
+ scripts: new Set(
2034
+ installations
2035
+ .filter((i) => i.harness === harness)
2036
+ .flatMap((i) => i.handlers.map((h) => h.command.split(' hook ')[0] ?? '')),
2037
+ ),
2038
+ }))
2039
+ .filter((entry) => entry.scripts.size > 1)
2040
+ if (duplicated.length > 0) {
2041
+ checks.push({
2042
+ name: 'hooks (duplicates)',
2043
+ ok: false,
2044
+ detail: duplicated
2045
+ .map(
2046
+ (entry) =>
2047
+ `${entry.harness}: ${entry.scripts.size} different NotifAI builds are installed, so each event will fire all of them. Uninstall the ones you do not want: ${[...entry.scripts].join(', ')}`,
2048
+ )
2049
+ .join('; '),
2050
+ })
2051
+ }
2052
+
2053
+ const fired = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)()) !== null
2054
+ checks.push({
2055
+ name: 'hooks (fired)',
2056
+ ok: fired,
2057
+ detail: fired
2058
+ ? 'a session in this directory has run them'
2059
+ : `no session pointer from the last 24 hours — ${hookActivationAdvice(installations)}`,
2060
+ })
2061
+
2062
+ if (installations.some((installation) => installation.harness === 'opencode')) {
2063
+ checks.push({
2064
+ name: 'hooks (opencode continuation)',
2065
+ ok: false,
2066
+ detail:
2067
+ 'question routing is installed, but OpenCode cannot reliably resume an idle agent turn with the answer; use `notifai send --reply` for decisions',
2068
+ })
2069
+ }
2070
+
2071
+ const stray = codexStrayWorktreeCheck(deps)
2072
+ if (stray !== null) checks.push(stray)
2073
+
2074
+ return checks
2075
+ }
2076
+
2077
+ /**
2078
+ * A Codex hooks file sitting in a worktree, which Codex will never read.
2079
+ *
2080
+ * `settingsFile` now writes to the main repository, so this only fires for a
2081
+ * file an older build left behind — but that file is indistinguishable from a
2082
+ * working install if you go looking, and it is exactly what made this bug take
2083
+ * a day to find. Omitted entirely when there is nothing to say.
2084
+ */
2085
+ function codexStrayWorktreeCheck(
2086
+ deps: CommandDeps,
2087
+ ): { name: string; ok: boolean; detail: string } | null {
2088
+ const layer = codexLayerDir(deps.cwd)
2089
+ if (layer === null) return null
2090
+ const root = codexProjectRoot(deps.cwd)
2091
+ const stray = path.join(path.dirname(layer), '.codex', 'hooks.json')
2092
+ if (!existsSync(path.join(root, '.codex', 'hooks.json'))) return null
2093
+ const problems: string[] = []
2094
+ if (!existsSync(layer)) {
2095
+ problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
2096
+ }
2097
+ if (existsSync(stray)) {
2098
+ problems.push(`${stray} is never read — Codex reads ${root}/.codex/hooks.json instead`)
2099
+ }
2100
+ return {
2101
+ name: 'hooks (codex worktree)',
2102
+ ok: problems.length === 0,
2103
+ detail:
2104
+ problems.length === 0
2105
+ ? `worktree wired to the main repository at ${root}`
2106
+ : `${problems.join('; ')}. Re-run \`notifai hooks install\` to fix.`,
2107
+ }
2108
+ }
2109
+
2110
+ // ---------------------------------------------------------------------------
2111
+ // production IO
2112
+ // ---------------------------------------------------------------------------
2113
+
2114
+ /**
2115
+ * Whether a human is driving this terminal.
2116
+ *
2117
+ * A TTY alone is NOT that evidence: agent harnesses frequently allocate a PTY
2118
+ * for the commands they run, and a prompt shown to an agent does not fail — it
2119
+ * hangs, because every prompt library waits on stdin rather than erroring. So
2120
+ * this also honours `CI` and an explicit `NOTIFAI_NO_INPUT=1` escape hatch,
2121
+ * and every interactive affordance stays strictly optional: anything `init`
2122
+ * can ask, a flag can answer.
2123
+ */
2124
+ function isHumanTerminal(env: NodeJS.ProcessEnv): boolean {
2125
+ return (
2126
+ process.stdin.isTTY === true &&
2127
+ process.stdout.isTTY === true &&
2128
+ (env['CI'] ?? '') === '' &&
2129
+ (env['NOTIFAI_NO_INPUT'] ?? '') === ''
2130
+ )
2131
+ }
2132
+
2133
+ /**
2134
+ * Lazy on purpose: the hook path runs in front of every prompt the user types,
2135
+ * and must not pay for a prompt library it will never show.
2136
+ */
2137
+ async function clack() {
2138
+ return await import('@clack/prompts')
2139
+ }
2140
+
2141
+ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
2142
+ const interactive = () => isHumanTerminal(env)
2143
+ return {
2144
+ out: (line) => console.log(line),
2145
+ err: (line) => console.error(line),
2146
+ get interactive() {
2147
+ return interactive()
2148
+ },
2149
+ confirm: async (question, fallback = false) => {
2150
+ if (!interactive()) return fallback
2151
+ const p = await clack()
2152
+ const answer = await p.confirm({ message: question, initialValue: fallback })
2153
+ // Ctrl-C mid-prompt arrives as a cancel symbol, not a SIGINT; treat it
2154
+ // as the safe answer rather than letting a Symbol escape into logic.
2155
+ return p.isCancel(answer) ? false : answer
2156
+ },
2157
+ select: async (message, options) => {
2158
+ if (!interactive()) return null
2159
+ const p = await clack()
2160
+ const answer = await p.select({ message, options })
2161
+ return p.isCancel(answer) ? null : (answer as string)
2162
+ },
2163
+ intro: async (title) => {
2164
+ if (!interactive()) return
2165
+ ;(await clack()).intro(title)
2166
+ },
2167
+ outro: async (message) => {
2168
+ if (!interactive()) return
2169
+ ;(await clack()).outro(message)
2170
+ },
2171
+ note: async (message, title) => {
2172
+ if (!interactive()) return
2173
+ ;(await clack()).note(message, title)
2174
+ },
2175
+ spinner: async (message) => {
2176
+ if (!interactive()) return null
2177
+ const progress = (await clack()).spinner()
2178
+ progress.start(message)
2179
+ return {
2180
+ message: (next) => progress.message(next),
2181
+ stop: (next) => progress.stop(next),
2182
+ error: (next) => progress.error(next),
2183
+ }
2184
+ },
2185
+ check: async (ok, message) => {
2186
+ if (!interactive()) return
2187
+ const { log } = await clack()
2188
+ if (ok) log.success(message)
2189
+ else log.error(message)
2190
+ },
2191
+ openUrl: (url) => {
2192
+ const command =
2193
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
2194
+ try {
2195
+ spawn(command, [url], { stdio: 'ignore', detached: true }).unref()
2196
+ } catch {
2197
+ // Browser opening is best-effort; the URL is printed anyway.
2198
+ }
2199
+ },
2200
+ }
2201
+ }