@raidiant/notifai 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -2
  3. package/dist/atomic-file.js +13 -0
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +8 -3
  6. package/dist/client.js +24 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands-auth.d.ts +14 -0
  9. package/dist/commands-auth.js +154 -0
  10. package/dist/commands-auth.js.map +1 -0
  11. package/dist/commands-config.d.ts +31 -0
  12. package/dist/commands-config.js +246 -0
  13. package/dist/commands-config.js.map +1 -0
  14. package/dist/commands-core.d.ts +102 -0
  15. package/dist/commands-core.js +135 -0
  16. package/dist/commands-core.js.map +1 -0
  17. package/dist/commands-devices.d.ts +31 -0
  18. package/dist/commands-devices.js +124 -0
  19. package/dist/commands-devices.js.map +1 -0
  20. package/dist/commands-doctor.d.ts +24 -0
  21. package/dist/commands-doctor.js +1011 -0
  22. package/dist/commands-doctor.js.map +1 -0
  23. package/dist/commands-harness-context.d.ts +65 -0
  24. package/dist/commands-harness-context.js +97 -0
  25. package/dist/commands-harness-context.js.map +1 -0
  26. package/dist/commands-hook-shape.d.ts +17 -0
  27. package/dist/commands-hook-shape.js +37 -0
  28. package/dist/commands-hook-shape.js.map +1 -0
  29. package/dist/commands-hooks.d.ts +115 -0
  30. package/dist/commands-hooks.js +1225 -0
  31. package/dist/commands-hooks.js.map +1 -0
  32. package/dist/commands-init.d.ts +35 -0
  33. package/dist/commands-init.js +580 -0
  34. package/dist/commands-init.js.map +1 -0
  35. package/dist/commands-io.d.ts +2 -0
  36. package/dist/commands-io.js +110 -0
  37. package/dist/commands-io.js.map +1 -0
  38. package/dist/commands-logs.d.ts +30 -0
  39. package/dist/commands-logs.js +201 -0
  40. package/dist/commands-logs.js.map +1 -0
  41. package/dist/commands-send-support.d.ts +44 -0
  42. package/dist/commands-send-support.js +183 -0
  43. package/dist/commands-send-support.js.map +1 -0
  44. package/dist/commands-send.d.ts +33 -0
  45. package/dist/commands-send.js +519 -0
  46. package/dist/commands-send.js.map +1 -0
  47. package/dist/commands-setup-proof.d.ts +15 -0
  48. package/dist/commands-setup-proof.js +53 -0
  49. package/dist/commands-setup-proof.js.map +1 -0
  50. package/dist/commands-skill.d.ts +10 -0
  51. package/dist/commands-skill.js +71 -0
  52. package/dist/commands-skill.js.map +1 -0
  53. package/dist/commands.d.ts +12 -387
  54. package/dist/commands.js +12 -4536
  55. package/dist/commands.js.map +1 -1
  56. package/dist/config-schema.js +1 -1
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +4 -4
  59. package/dist/config.js +6 -4
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.js +13 -3
  62. package/dist/credentials.js.map +1 -1
  63. package/dist/generated-session-label.d.ts +2 -0
  64. package/dist/generated-session-label.js +152 -0
  65. package/dist/generated-session-label.js.map +1 -0
  66. package/dist/hooks.d.ts +2 -2
  67. package/dist/hooks.js +127 -99
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/interactive.js +4 -4
  70. package/dist/interactive.js.map +1 -1
  71. package/dist/invocation-context.d.ts +3 -0
  72. package/dist/invocation-context.js +23 -6
  73. package/dist/invocation-context.js.map +1 -1
  74. package/dist/logging.js +1 -0
  75. package/dist/logging.js.map +1 -1
  76. package/dist/main.js +3 -3
  77. package/dist/main.js.map +1 -1
  78. package/dist/opencode-plugin.js +18 -3
  79. package/dist/opencode-plugin.js.map +1 -1
  80. package/dist/readiness.d.ts +3 -1
  81. package/dist/readiness.js.map +1 -1
  82. package/dist/session-labels.d.ts +30 -0
  83. package/dist/session-labels.js +278 -0
  84. package/dist/session-labels.js.map +1 -0
  85. package/package.json +2 -2
  86. package/src/atomic-file.ts +12 -0
  87. package/src/client.test.ts +90 -1
  88. package/src/client.ts +35 -2
  89. package/src/commands-auth.ts +174 -0
  90. package/src/commands-config.ts +307 -0
  91. package/src/commands-core.ts +240 -0
  92. package/src/commands-devices.ts +150 -0
  93. package/src/commands-doctor.ts +1209 -0
  94. package/src/commands-harness-context.ts +134 -0
  95. package/src/commands-hook-shape.ts +53 -0
  96. package/src/commands-hooks.ts +1586 -0
  97. package/src/commands-init.ts +721 -0
  98. package/src/commands-io.ts +108 -0
  99. package/src/commands-logs.ts +243 -0
  100. package/src/commands-send-support.ts +227 -0
  101. package/src/commands-send.ts +623 -0
  102. package/src/commands-setup-proof.ts +71 -0
  103. package/src/commands-skill.ts +84 -0
  104. package/src/commands.test.ts +567 -42
  105. package/src/commands.ts +58 -5614
  106. package/src/config-schema.ts +1 -1
  107. package/src/config.test.ts +9 -0
  108. package/src/config.ts +10 -5
  109. package/src/credentials.test.ts +25 -1
  110. package/src/credentials.ts +13 -3
  111. package/src/generated-session-label.ts +155 -0
  112. package/src/hooks.test.ts +355 -16
  113. package/src/hooks.ts +145 -97
  114. package/src/install-hooks.test.ts +84 -1
  115. package/src/interactive.test.ts +19 -1
  116. package/src/interactive.ts +4 -4
  117. package/src/invocation-context.test.ts +115 -16
  118. package/src/invocation-context.ts +32 -11
  119. package/src/logging.test.ts +15 -2
  120. package/src/logging.ts +1 -0
  121. package/src/main.ts +9 -3
  122. package/src/opencode-plugin.ts +18 -3
  123. package/src/readiness.ts +3 -1
  124. package/src/session-labels.test.ts +303 -0
  125. package/src/session-labels.ts +360 -0
  126. package/src/skill.test.ts +13 -0
@@ -0,0 +1,721 @@
1
+ import {
2
+ CAPABILITIES_V1,
3
+ validateDraft,
4
+ type RoutableDevice,
5
+ type SubmissionReceipt,
6
+ } from '@raidiant/notifai-protocol'
7
+ import { randomBytes } from 'node:crypto'
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import path from 'node:path'
10
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
11
+ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
12
+ import { type CliConfig } from './config.js'
13
+ import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
14
+ import type { SkillScope } from './native-skills.js'
15
+ import { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
16
+ import { buildDraft, formatReceipt } from './send.js'
17
+ import { loginCommand } from './commands-auth.js'
18
+ import {
19
+ EXIT,
20
+ UPDATE_CLI_COMMAND,
21
+ authedClient,
22
+ loadLoggedConfig,
23
+ reportError,
24
+ type CommandDeps,
25
+ } from './commands-core.js'
26
+ import { readyIosDevices, supportPageUrl } from './commands-devices.js'
27
+ import { assessReadiness, remedyLine } from './commands-doctor.js'
28
+ import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
29
+ import { observedCompanionReceipt, readSetupProof, writeSetupProof } from './commands-setup-proof.js'
30
+ import { SKILLS_SOURCE } from './commands-skill.js'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // init
34
+ // ---------------------------------------------------------------------------
35
+
36
+
37
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
38
+ export function projectSlugFrom(name: string): string {
39
+ return inferredProjectSlugFrom(name) ?? 'project'
40
+ }
41
+
42
+ export interface InitFlags {
43
+ projectId?: string
44
+ /**
45
+ * Install the agent guidance skill. Tri-state on purpose:
46
+ * true installs, false skips silently, and undefined means "offer it when a
47
+ * human is present, do nothing when one is not" — an unattended run must
48
+ * never spawn npx against the network by default.
49
+ */
50
+ skills?: boolean
51
+ /** Scope selected by an unattended caller; humans choose inside npx skills. */
52
+ skillsScope?: SkillScope
53
+ /** Same tri-state, for the harness hooks. */
54
+ hooks?: boolean
55
+ }
56
+
57
+
58
+ /** Long enough for a first TestFlight install; keep-waiting extends another budget. */
59
+ const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
60
+ const DEVICE_BRIDGE_POLL_MS = 2_000
61
+ const PROOF_TIMEOUT_MS = 30_000
62
+ const PROOF_POLL_MS = 1_000
63
+
64
+
65
+ function formatWaitBudget(milliseconds: number): string {
66
+ const minutes = Math.round(milliseconds / 60_000)
67
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`
68
+ }
69
+
70
+
71
+ /**
72
+ * The setup coordinator that observes each prerequisite and advances the ones
73
+ * this build can perform.
74
+ *
75
+ * Idempotent by construction: every step first observes, then acts only on the
76
+ * gap, so re-running is how you check the setup as much as how you create it.
77
+ * With a human at a terminal it walks them through the missing pieces; run by
78
+ * an agent it never prompts — each optional step is answered by a flag, and
79
+ * whatever only the user can do (signing in, pairing a companion device) is
80
+ * printed as the next human action. An agent runs every CLI command itself.
81
+ */
82
+ /**
83
+ * Close a gap the CLI is allowed to close on its own, without asking.
84
+ *
85
+ * Only reached for `by: 'cli'` remedies, which by definition need no human, so
86
+ * this stays silent about what it did — the re-assessment that follows reports
87
+ * the new state, and narrating both is how a setup log becomes unreadable.
88
+ *
89
+ * `pending` means the action is real but its evidence has not arrived yet;
90
+ * `failed` means the action itself could not be performed.
91
+ */
92
+ type GapCloseResult = 'closed' | 'pending' | 'failed'
93
+
94
+ async function closeGap(
95
+ deps: CommandDeps,
96
+ state: ReadinessState,
97
+ flags: InitFlags,
98
+ ): Promise<GapCloseResult> {
99
+ if (state.id === 'project') {
100
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
101
+ const existing = existsSync(configPath)
102
+ ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
103
+ : {}
104
+ existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
105
+ mkdirSync(path.dirname(configPath), { recursive: true })
106
+ writeFileSync(configPath, `${stringifyToml(existing)}\n`)
107
+ return 'closed'
108
+ }
109
+
110
+ if (state.id === 'hooks') {
111
+ const harnesses = await pickHarnessesToInstall(deps)
112
+ if (harnesses === null || harnesses.length === 0) return 'failed'
113
+ let ok = true
114
+ for (const harness of harnesses) {
115
+ if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
116
+ }
117
+ return ok ? 'closed' : 'failed'
118
+ }
119
+
120
+ if (state.id === 'skill') {
121
+ if (deps.nativeSkills === undefined) {
122
+ deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
123
+ return 'failed'
124
+ }
125
+ // Refuse rather than reach for a mutable ref: installing the skill from a
126
+ // moving branch is the one outcome the pin exists to prevent.
127
+ if (SKILLS_SOURCE === null) {
128
+ deps.io.err(
129
+ 'Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.',
130
+ )
131
+ return 'failed'
132
+ }
133
+ const scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
134
+ deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
135
+ const addOptions = {
136
+ source: SKILLS_SOURCE,
137
+ skill: 'notifai',
138
+ cwd: deps.cwd,
139
+ env: deps.env,
140
+ ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
141
+ }
142
+ const code = await deps.nativeSkills.add(addOptions)
143
+ if (code !== 0) {
144
+ deps.io.err('Skill installation failed — run it manually with:')
145
+ deps.io.err(
146
+ ` npx skills add ${SKILLS_SOURCE} --skill notifai${
147
+ flags.skillsScope === 'global' ? ' --global' : ''
148
+ }${flags.skillsScope === undefined ? '' : ' --yes'}`,
149
+ )
150
+ }
151
+ return code === 0 ? 'closed' : 'failed'
152
+ }
153
+
154
+ if (state.id === 'proof') return await runSetupProof(deps)
155
+
156
+ return 'failed'
157
+ }
158
+
159
+
160
+ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
161
+ if (devices.length === 0) {
162
+ return 'Waiting for the iPhone app to sign in and register…'
163
+ }
164
+ const denied = devices.find((device) => device.permission_status === 'denied')
165
+ if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
166
+ const undecided = devices.find((device) => device.permission_status === 'not_determined')
167
+ if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
168
+ return 'Waiting for an iPhone to become ready…'
169
+ }
170
+
171
+ /**
172
+ * Observe the supported Device Installation path while the user finishes the
173
+ * app-side work. The live bridge is the dashboard `/support` page (TestFlight
174
+ * steps). Interactive runs offer to open it so the user never types the URL;
175
+ * non-interactive/agent paths only print plain text and never wait on a prompt.
176
+ *
177
+ * The wait budget is stated up front. On expiry we say the *timer* expired —
178
+ * not the setup — and offer another budget when a human is present. Agents
179
+ * never hang: this whole path is gated on `io.interactive`, and keep-waiting
180
+ * uses `confirm` which resolves the safe default when not interactive.
181
+ */
182
+ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
183
+ const remedy = state.remedy
184
+ if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
185
+
186
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
187
+ const authed = authedClient(deps, config)
188
+ if (!authed) {
189
+ deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
190
+ return 'failed'
191
+ }
192
+
193
+ const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
194
+ const supportUrl = supportPageUrl(authed.baseUrl)
195
+ await deps.io.note?.(
196
+ [
197
+ state.detail,
198
+ remedy.summary,
199
+ `Install steps (no typing): ${supportUrl}`,
200
+ `I will wait up to ${budgetLabel} for your iPhone to become ready.`,
201
+ ].join('\n'),
202
+ 'Finish setup on your iPhone',
203
+ )
204
+
205
+ // Open the real support page so the user never has to type the URL. Decline
206
+ // is fine — the URL remains in the note and in the Next: line if they leave.
207
+ if (await deps.io.confirm('Open install instructions in your browser?', true)) {
208
+ deps.io.openUrl(supportUrl)
209
+ }
210
+
211
+ if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
212
+ deps.io.out(
213
+ `OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
214
+ )
215
+ return 'pending'
216
+ }
217
+
218
+ const now = deps.now ?? Date.now
219
+ const sleep =
220
+ deps.sleep ??
221
+ ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
222
+ const spinner = await deps.io.spinner?.(`Waiting up to ${budgetLabel} for your iPhone…`)
223
+ let lastDevices: RoutableDevice[] = []
224
+ let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
225
+
226
+ for (;;) {
227
+ while (now() < deadline) {
228
+ try {
229
+ const response = await authed.client.listDevices()
230
+ const iphoneDevices = response.devices.filter((device) => device.platform === 'ios')
231
+ lastDevices = iphoneDevices
232
+ const ready = readyIosDevices(iphoneDevices)[0]
233
+ if (ready) {
234
+ spinner?.stop(`${ready.display_name} is ready to receive`)
235
+ return 'closed'
236
+ }
237
+ spinner?.message(deviceBridgeMessage(iphoneDevices))
238
+ } catch (err) {
239
+ if (!(err instanceof NetworkError)) {
240
+ spinner?.error('Could not check companion readiness')
241
+ reportError(deps, err)
242
+ return 'failed'
243
+ }
244
+ spinner?.message('Connection lost — still watching…')
245
+ }
246
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
247
+ }
248
+
249
+ // Timer expired — setup did not. Offer another budget only when a human
250
+ // can answer; confirm() already returns the fallback for agents, so this
251
+ // never hangs unattended even if interactive were mis-set.
252
+ spinner?.error(`The ${budgetLabel} wait timer expired`)
253
+ deps.io.err(
254
+ `The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
255
+ )
256
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
257
+ deps.io.err(
258
+ `Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
259
+ )
260
+
261
+ const keepWaiting = await deps.io.confirm(
262
+ `Keep waiting for another ${budgetLabel}?`,
263
+ false,
264
+ )
265
+ if (!keepWaiting) {
266
+ deps.io.out('Stopping the wait. iPhone setup can continue; re-run `notifai init` when ready.')
267
+ return 'pending'
268
+ }
269
+ spinner?.message(`Waiting another ${budgetLabel} for your iPhone…`)
270
+ deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
271
+ }
272
+ }
273
+
274
+ function setupProofDraft(
275
+ config: CliConfig,
276
+ device: RoutableDevice,
277
+ ): ReturnType<typeof buildDraft> {
278
+ const project = config.project.value
279
+ return buildDraft(config, {
280
+ title: 'Notifai is ready',
281
+ body:
282
+ project === null
283
+ ? 'This real notification completed setup verification.'
284
+ : `This real notification completed setup verification for ${project}.`,
285
+ event: 'setup_verified',
286
+ kind: 'update',
287
+ platform: 'ios',
288
+ device: [device.device_id],
289
+ sound: 'none',
290
+ level: 'passive',
291
+ collapseKey: 'notifai-setup-verification',
292
+ })
293
+ }
294
+
295
+ async function submitSetupProof(
296
+ deps: CommandDeps,
297
+ client: ApiClient,
298
+ config: CliConfig,
299
+ device: RoutableDevice,
300
+ ): Promise<SubmissionReceipt | null> {
301
+ const build = setupProofDraft(config, device)
302
+ if (!build.ok) {
303
+ deps.io.err(`Could not build the setup verification notification: ${build.error}`)
304
+ return null
305
+ }
306
+ const capabilities = CAPABILITIES_V1.describe(build.platform)
307
+ if (!capabilities) {
308
+ deps.io.err(`No capability contract is available for ${build.platform}.`)
309
+ return null
310
+ }
311
+ const validation = validateDraft(build.draft, capabilities)
312
+ if (!validation.ok) {
313
+ for (const issue of validation.errors) {
314
+ deps.io.err(`Setup verification ${issue.path}: ${issue.message}`)
315
+ }
316
+ return null
317
+ }
318
+ try {
319
+ return await client.submit(
320
+ {
321
+ idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
322
+ draft: build.draft,
323
+ },
324
+ config.wait_seconds.value,
325
+ )
326
+ } catch (err) {
327
+ reportError(deps, err)
328
+ return null
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Send or resume one real setup probe, then wait for a Companion Receipt.
334
+ * Provider Acceptance is intentionally insufficient: it proves APNs accepted
335
+ * the push, not that a companion process received it.
336
+ */
337
+ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
338
+ const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
339
+ const authed = authedClient(deps, config)
340
+ if (!authed) return 'failed'
341
+
342
+ let devices: RoutableDevice[]
343
+ try {
344
+ devices = (await authed.client.listDevices()).devices
345
+ } catch (err) {
346
+ reportError(deps, err)
347
+ return 'failed'
348
+ }
349
+ const candidates = readyIosDevices(devices)
350
+ const existing = readSetupProof(deps)
351
+ const target =
352
+ candidates.find(
353
+ (device) =>
354
+ device.device_id === existing?.device_id && existing.project === config.project.value,
355
+ ) ?? candidates[0]
356
+ if (!target) {
357
+ deps.io.err('Setup proof needs a receipt-capable iPhone.')
358
+ return 'pending'
359
+ }
360
+
361
+ let proof =
362
+ existing?.device_id === target.device_id && existing.project === config.project.value
363
+ ? existing
364
+ : null
365
+ if (proof === null) {
366
+ const receipt = await submitSetupProof(deps, authed.client, config, target)
367
+ if (receipt === null) return 'failed'
368
+ if (receipt.overall === 'provider_rejected_all') {
369
+ deps.io.err(formatReceipt(receipt))
370
+ return 'failed'
371
+ }
372
+ proof = {
373
+ request_id: receipt.request_id,
374
+ device_id: target.device_id,
375
+ project: config.project.value,
376
+ started_at: new Date((deps.now ?? Date.now)()).toISOString(),
377
+ }
378
+ if (!writeSetupProof(deps, proof)) return 'failed'
379
+ deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`)
380
+ } else {
381
+ deps.io.out(`Checking verification notification ${proof.request_id} again.`)
382
+ }
383
+
384
+ const now = deps.now ?? Date.now
385
+ const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
386
+ const deadline = now() + PROOF_TIMEOUT_MS
387
+ const spinner = deps.io.interactive === true
388
+ ? await deps.io.spinner?.(
389
+ "Waiting for a Companion Receipt (the app's delivery confirmation)…",
390
+ )
391
+ : null
392
+ let lastError: unknown = null
393
+ let replacedMissingProof = false
394
+
395
+ for (;;) {
396
+ try {
397
+ const snapshot = await authed.client.evidence(proof.request_id)
398
+ const observed = observedCompanionReceipt(snapshot, proof.device_id)
399
+ if (observed) {
400
+ spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
401
+ deps.io.out(
402
+ `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`,
403
+ )
404
+ return 'closed'
405
+ }
406
+ lastError = null
407
+ } catch (err) {
408
+ lastError = err
409
+ if (
410
+ err instanceof ApiCallError &&
411
+ err.code === 'not_found' &&
412
+ !replacedMissingProof
413
+ ) {
414
+ const receipt = await submitSetupProof(deps, authed.client, config, target)
415
+ if (receipt === null) return 'failed'
416
+ if (receipt.overall === 'provider_rejected_all') {
417
+ deps.io.err(formatReceipt(receipt))
418
+ return 'failed'
419
+ }
420
+ proof = {
421
+ request_id: receipt.request_id,
422
+ device_id: target.device_id,
423
+ project: config.project.value,
424
+ started_at: new Date(now()).toISOString(),
425
+ }
426
+ if (!writeSetupProof(deps, proof)) return 'failed'
427
+ replacedMissingProof = true
428
+ lastError = null
429
+ deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`)
430
+ continue
431
+ }
432
+ if (!(err instanceof NetworkError)) {
433
+ spinner?.error('Could not read Companion Receipt evidence')
434
+ reportError(deps, err)
435
+ return 'failed'
436
+ }
437
+ spinner?.message('Connection lost — still checking the same request…')
438
+ }
439
+
440
+ if (now() >= deadline) break
441
+ await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
442
+ }
443
+
444
+ spinner?.stop('Delivery confirmation not observed yet')
445
+ if (lastError instanceof NetworkError) deps.io.err(lastError.message)
446
+ deps.io.out(
447
+ `Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
448
+ 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
449
+ )
450
+ return 'pending'
451
+ }
452
+
453
+ /** Closing a local gap cannot have changed the service, the keychain, or devices. */
454
+ function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
455
+ return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
456
+ ? ['local']
457
+ : undefined
458
+ }
459
+
460
+ /** Whether an optional gap should be closed, given flags and who is watching. */
461
+ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
462
+ // Optional CLI updates are surfaced by doctor or when a missing feature is
463
+ // relevant. Init does not turn them into setup work.
464
+ if (state.id === 'contract') return Promise.resolve(false)
465
+ // Naming the project is init's whole reason to touch the filesystem, costs
466
+ // nothing, and is undone by editing one line — so it is done rather than
467
+ // asked about, for a human and an agent alike.
468
+ if (state.id === 'project') return Promise.resolve(true)
469
+ const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined
470
+ if (explicit !== undefined) return Promise.resolve(explicit)
471
+ // An agent is never asked, and never assumed into a change it did not
472
+ // request: silence means no, and the summary says what was skipped.
473
+ if (deps.io.interactive !== true) return Promise.resolve(false)
474
+ const question =
475
+ state.id === 'hooks'
476
+ ? 'Install harness hooks, so questions reach your devices when you are away?'
477
+ : 'Install/update the agent guidance skill through the native npx skills flow?'
478
+ return deps.io.confirm(question, true)
479
+ }
480
+
481
+ /**
482
+ * Setup as one step at a time.
483
+ *
484
+ * The old version ran five steps in a fixed order and ended with a list of
485
+ * everything still outstanding. That is a report, and a report is the wrong
486
+ * output here: someone handed five things to do does none of them, and the
487
+ * order was the script's rather than the dependency graph's — it offered to
488
+ * install hooks after a sign-in that had just failed.
489
+ *
490
+ * So this closes what it can, then surfaces exactly one thing, the first that
491
+ * stands in the way. Re-running advances by one. Idempotence stops being a
492
+ * property to preserve and becomes the mechanism: every decision is derived
493
+ * from observed state, so a partial run, a second project, a fresh worktree
494
+ * and a revoked credential are the same code path arriving at different
495
+ * states rather than four branches to enumerate.
496
+ */
497
+ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
498
+ if (
499
+ flags.skillsScope !== undefined &&
500
+ flags.skillsScope !== 'project' &&
501
+ flags.skillsScope !== 'global'
502
+ ) {
503
+ deps.io.err('Invalid skill scope. Choose `project` or `global`.')
504
+ return EXIT.usage
505
+ }
506
+ if (flags.skillsScope !== undefined && flags.skills !== true) {
507
+ deps.io.err('`--skills-scope` requires `--skills`. Choose project or global in the native installer.')
508
+ return EXIT.usage
509
+ }
510
+ if (
511
+ flags.skills === true &&
512
+ deps.io.interactive !== true &&
513
+ flags.skillsScope === undefined
514
+ ) {
515
+ deps.io.err(
516
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --skills-scope project` or `... global`.',
517
+ )
518
+ return EXIT.usage
519
+ }
520
+ await deps.io.intro?.('Notifai setup')
521
+
522
+ const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
523
+ let readiness = await assessReadiness(deps, skillOpts)
524
+ const reassess = (refresh?: readonly ReadinessRefresh[]) =>
525
+ assessReadiness(deps, {
526
+ ...skillOpts,
527
+ ...(refresh === undefined ? {} : { previous: readiness, refresh }),
528
+ })
529
+ let failed = false
530
+ const attempted = new Set<string>()
531
+
532
+ // Re-assess after every successful action. This is how a browser approval or
533
+ // companion registration can unlock the next state while the user is still
534
+ // here, without copying the dependency graph into a second setup script.
535
+ for (;;) {
536
+ let advanced = false
537
+ let stop = false
538
+
539
+ for (const state of readiness.states) {
540
+ if (state.status === 'ready') continue
541
+ if (state.status === 'unknown') {
542
+ stop = true
543
+ break
544
+ }
545
+
546
+ const remedy = state.remedy
547
+ if (remedy === undefined || attempted.has(state.id)) {
548
+ if (state.status === 'gap') stop = true
549
+ if (stop) break
550
+ continue
551
+ }
552
+
553
+ if (state.status === 'optional-gap') {
554
+ if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags))) continue
555
+ attempted.add(state.id)
556
+ const result = await closeGap(deps, state, flags)
557
+ if (result === 'failed') failed = true
558
+ if (result === 'failed' && state.status === 'optional-gap') {
559
+ readiness = await reassess(refreshAfterClose(state.id))
560
+ advanced = true
561
+ break
562
+ }
563
+ if (result !== 'closed') {
564
+ stop = true
565
+ break
566
+ }
567
+ readiness = await reassess(refreshAfterClose(state.id))
568
+ advanced = true
569
+ break
570
+ }
571
+
572
+ if (remedy.by === 'cli') {
573
+ attempted.add(state.id)
574
+ const result = await closeGap(deps, state, flags)
575
+ if (result === 'failed') failed = true
576
+ if (result !== 'closed') {
577
+ stop = true
578
+ break
579
+ }
580
+ readiness = await reassess(refreshAfterClose(state.id))
581
+ advanced = true
582
+ break
583
+ }
584
+
585
+ // Its to launch, theirs to complete. Running `init` is the consent; announce
586
+ // and open rather than re-asking. An agent never reaches this path.
587
+ if (
588
+ remedy.by === 'user-here' &&
589
+ remedy.interactive === true &&
590
+ deps.io.interactive === true
591
+ ) {
592
+ attempted.add(state.id)
593
+ deps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
594
+ if ((await loginCommand(deps, {})) !== EXIT.ok) {
595
+ failed = true
596
+ stop = true
597
+ break
598
+ }
599
+ readiness = await reassess()
600
+ advanced = true
601
+ break
602
+ }
603
+
604
+ if (
605
+ state.id === 'devices' &&
606
+ remedy.by === 'user-elsewhere' &&
607
+ deps.io.interactive === true
608
+ ) {
609
+ attempted.add(state.id)
610
+ const result = await waitForReadyDevice(deps, state)
611
+ if (result === 'failed') failed = true
612
+ if (result !== 'closed') {
613
+ stop = true
614
+ break
615
+ }
616
+ readiness = await reassess()
617
+ advanced = true
618
+ break
619
+ }
620
+
621
+ // A human-only remedy is the first blocker for an unattended agent.
622
+ stop = true
623
+ break
624
+ }
625
+
626
+ if (stop || !advanced) break
627
+ }
628
+
629
+ await printInitClose(deps, readiness, flags)
630
+ const blocker = firstBlocker(readiness)
631
+ if (blocker === null) return failed ? EXIT.failed : EXIT.ok
632
+ return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
633
+ }
634
+
635
+ function isHookSubstate(id: string): boolean {
636
+ return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
637
+ }
638
+
639
+ function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
640
+ return readiness.states.filter((state) => {
641
+ if (state.status !== 'optional-gap') return false
642
+ if (isHookSubstate(state.id)) return false
643
+ if (state.id === 'skill' && flags.skills === false) return false
644
+ return true
645
+ })
646
+ }
647
+
648
+ function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
649
+ for (const state of leftovers) {
650
+ deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
651
+ }
652
+ }
653
+
654
+ function questionsWillRoute(readiness: Readiness): boolean {
655
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
656
+ const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
657
+ return hooks?.status === 'ready' && settings?.status !== 'gap'
658
+ }
659
+
660
+ async function printInitClose(
661
+ deps: CommandDeps,
662
+ readiness: Readiness,
663
+ flags: InitFlags,
664
+ ): Promise<void> {
665
+ const blocker = firstBlocker(readiness)
666
+ const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
667
+ const questions = questionsWillRoute(readiness)
668
+ const leftovers = leftoverOptionals(readiness, flags).filter(
669
+ (state) => state.id !== 'contract',
670
+ )
671
+
672
+ if (blocker?.id === 'contract') {
673
+ deps.io.out(blocker.detail)
674
+ deps.io.out(UPDATE_CLI_COMMAND)
675
+ await deps.io.outro?.('Update Notifai, then run init again')
676
+ return
677
+ }
678
+
679
+ if (deps.io.interactive === true) {
680
+ if (blocker === null) {
681
+ const lines = [
682
+ canSend ? 'You can send notifications.' : 'You are signed in.',
683
+ questions
684
+ ? 'Questions will reach your devices.'
685
+ : 'Questions stay in the terminal until hooks are installed.',
686
+ ]
687
+ await deps.io.note?.(lines.join('\n'), 'Ready')
688
+ printOptionalLeftovers(deps, leftovers)
689
+ deps.io.out(
690
+ questions
691
+ ? 'All set. Agents in this project can notify you and ask you questions.'
692
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
693
+ )
694
+ await deps.io.outro?.('All set ✨')
695
+ return
696
+ }
697
+ await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
698
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
699
+ deps.io.out(` ${remedyLine(blocker)}`)
700
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
701
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
702
+ }
703
+ await deps.io.outro?.('One step remains (above)')
704
+ return
705
+ }
706
+
707
+ if (blocker === null) {
708
+ printOptionalLeftovers(deps, leftovers)
709
+ deps.io.out(
710
+ questions
711
+ ? 'All set. Agents in this project can notify you and ask you questions.'
712
+ : 'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
713
+ )
714
+ return
715
+ }
716
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
717
+ deps.io.out(` ${remedyLine(blocker)}`)
718
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
719
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
720
+ }
721
+ }