@raidiant/notifai 10.1.1 → 10.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
@@ -1,1055 +0,0 @@
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 { ensurePrivateDirectory } from './atomic-file.js'
12
- import { ApiCallError, NetworkError, type ApiClient } from './client.js'
13
- import { personalProjectConfigPath, type CliConfig } from './config.js'
14
- import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
15
- import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
16
- import {
17
- firstRequiredBlocker,
18
- isOptionalAutomation,
19
- isOptionalSetup,
20
- questionRoutingReady,
21
- readinessJson,
22
- type Readiness,
23
- type ReadinessRefresh,
24
- type ReadinessState,
25
- } from './readiness.js'
26
- import { buildDraft, formatReceipt } from './send.js'
27
- import { loginCommand } from './commands-auth.js'
28
- import {
29
- EXIT,
30
- SETUP_COMMAND,
31
- authedClient,
32
- loadLoggedConfig,
33
- reportError,
34
- updateCliCommand,
35
- type CommandDeps,
36
- } from './commands-core.js'
37
- import { readyCompanionDevices } from './commands-devices.js'
38
- import {
39
- companionPlatformLabel,
40
- setupCompanionUrl,
41
- type CompanionPlatform,
42
- } from './setup-destinations.js'
43
- import { assessReadiness, remedyLine } from './commands-doctor.js'
44
- import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
45
- import {
46
- observedCompanionReceipt,
47
- readSetupProof,
48
- setupProofProject,
49
- setupProofApplies,
50
- setupProofIsStale,
51
- writeSetupProof,
52
- } from './commands-setup-proof.js'
53
- import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
54
- import { enableProject, projectBinding } from './project-enablement.js'
55
- import { inspectCliInstallations } from './cli-bin.js'
56
- import { installHookAdapter } from './hook-adapter.js'
57
-
58
- // ---------------------------------------------------------------------------
59
- // init
60
- // ---------------------------------------------------------------------------
61
-
62
-
63
- /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
64
- export function projectSlugFrom(name: string): string {
65
- return inferredProjectSlugFrom(name) ?? 'project'
66
- }
67
-
68
- /** Project checkout vs this machine. Drives skill, hooks, and config together. */
69
- export type SetupScope = SkillScope
70
-
71
- export interface InitFlags {
72
- /** Emit the final shared readiness model and never prompt. */
73
- json?: boolean
74
- projectId?: string
75
- /**
76
- * Install the agent guidance skill. Tri-state on purpose:
77
- * true installs, false skips silently, and undefined means "offer it when a
78
- * human is present, do nothing when one is not" — an unattended run must
79
- * never spawn npx against the network by default.
80
- */
81
- skills?: boolean
82
- /**
83
- * One setup-scope for skill, hooks, and config. The unattended answer to
84
- * init's single project-vs-machine question. Pairing, devices, and the CLI
85
- * binary are not this flag.
86
- */
87
- setupScope?: SetupScope
88
- /** Alias of `--setup-scope` when installing the skill unattended. */
89
- skillsScope?: SkillScope
90
- /** Same tri-state, for the harness hooks. */
91
- hooks?: boolean
92
- }
93
-
94
-
95
- /** Long enough for a first controlled Companion install; keep-waiting extends another budget. */
96
- const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
97
- const DEVICE_BRIDGE_POLL_MS = 2_000
98
- const PROOF_TIMEOUT_MS = 30_000
99
- const PROOF_POLL_MS = 1_000
100
-
101
-
102
- function formatWaitBudget(milliseconds: number): string {
103
- const minutes = Math.round(milliseconds / 60_000)
104
- return minutes === 1 ? '1 minute' : `${minutes} minutes`
105
- }
106
-
107
-
108
- /**
109
- * The setup coordinator that observes each prerequisite and advances the ones
110
- * this build can perform.
111
- *
112
- * Idempotent by construction: every step first observes, then acts only on the
113
- * gap, so re-running is how you check the setup as much as how you create it.
114
- * With a human at a terminal it walks them through the missing pieces; run by
115
- * an agent it never prompts — each optional step is answered by a flag, and
116
- * whatever only the user can do (signing in, pairing a companion device) is
117
- * printed as the next human action. An agent runs every CLI command itself.
118
- */
119
- /**
120
- * Close a gap the CLI is allowed to close on its own, without asking.
121
- *
122
- * Only reached for `by: 'cli'` remedies, which by definition need no human, so
123
- * this stays silent about what it did — the re-assessment that follows reports
124
- * the new state, and narrating both is how a setup log becomes unreadable.
125
- *
126
- * `pending` means the action is real but its evidence has not arrived yet;
127
- * `failed` means the action itself could not be performed.
128
- */
129
- type GapCloseResult = 'closed' | 'pending' | 'failed'
130
-
131
- async function closeGap(
132
- deps: CommandDeps,
133
- state: ReadinessState,
134
- flags: InitFlags,
135
- ): Promise<GapCloseResult> {
136
- if (state.id === 'project') {
137
- // Naming this checkout is local bookkeeping, not skill or hook placement,
138
- // so it does not wait on the scope question. Without an explicit answer the
139
- // name lands in the checkout it describes, which is the answer a User who
140
- // never sees the question would have given.
141
- const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
142
- const configPath =
143
- flags.setupScope === 'global'
144
- ? personalProjectConfigPath(deps.cwd, deps.env)
145
- : path.join(deps.cwd, '.notifai', 'config.toml')
146
- const existing = existsSync(configPath)
147
- ? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
148
- : {}
149
- existing['project'] = slug
150
- if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
151
- else mkdirSync(path.dirname(configPath), { recursive: true })
152
- writeFileSync(configPath, `${stringifyToml(existing)}\n`)
153
- return 'closed'
154
- }
155
-
156
- if (state.id === 'project-enablement') {
157
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
158
- const binding = projectBinding(deps.cwd, deps.env, config.project.value)
159
- if (binding === null) return 'failed'
160
- enableProject(binding)
161
- return 'closed'
162
- }
163
-
164
- if (state.id === 'hooks') {
165
- const harnesses = await pickHarnessesToInstall(deps)
166
- if (harnesses === null || harnesses.length === 0) return 'failed'
167
- let ok = true
168
- const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
169
- for (const harness of harnesses) {
170
- if (hooksInstallCommand(deps, { harness, ...hookFlags, narrate: false }) !== EXIT.ok) ok = false
171
- }
172
- return ok ? 'closed' : 'failed'
173
- }
174
-
175
- if (state.id === 'hooks-adapter') {
176
- const effective = inspectCliInstallations(
177
- deps.env,
178
- deps.hookPlatform ?? process.platform,
179
- ).effective
180
- if (effective === null || effective.artifact_path === null) return 'failed'
181
- try {
182
- installHookAdapter(
183
- { execPath: process.execPath, scriptPath: effective.artifact_path },
184
- deps.hookAdapterHome,
185
- deps.hookPlatform ?? process.platform,
186
- )
187
- return 'closed'
188
- } catch (err) {
189
- deps.io.err(`Could not retarget Question Routing: ${String(err)}`)
190
- return 'failed'
191
- }
192
- }
193
-
194
- if (state.id === 'skill') {
195
- if (deps.nativeSkills === undefined) {
196
- deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
197
- return 'failed'
198
- }
199
- // Refuse rather than reach for a mutable ref: installing the skill from a
200
- // moving branch is the one outcome the pin exists to prevent.
201
- if (SKILLS_SOURCE === null) {
202
- deps.io.err(
203
- 'Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.',
204
- )
205
- return 'failed'
206
- }
207
- const installScope = flags.setupScope ?? flags.skillsScope
208
- if (installScope === undefined) {
209
- deps.io.err(
210
- 'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
211
- )
212
- return 'failed'
213
- }
214
- const { installed } = await listScopedNotifaiSkills(deps)
215
- const extras = installed.filter((skill) => skill.scope !== installScope)
216
- for (const extra of extras) {
217
- const code = await deps.nativeSkills.remove({
218
- skill: 'notifai',
219
- scope: extra.scope,
220
- cwd: deps.cwd,
221
- env: deps.env,
222
- })
223
- if (code !== 0) {
224
- deps.io.err(
225
- `Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
226
- )
227
- return 'failed'
228
- }
229
- }
230
- deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
231
- const code = await deps.nativeSkills.add({
232
- source: SKILLS_SOURCE,
233
- skill: 'notifai',
234
- cwd: deps.cwd,
235
- env: deps.env,
236
- scope: installScope,
237
- })
238
- if (code !== 0) {
239
- deps.io.err('Skill installation failed — run it manually with:')
240
- deps.io.err(
241
- ` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
242
- installScope === 'global' ? ' --global' : ''
243
- } --yes`,
244
- )
245
- }
246
- return code === 0 ? 'closed' : 'failed'
247
- }
248
-
249
- if (state.id === 'proof') return await runSetupProof(deps)
250
-
251
- return 'failed'
252
- }
253
-
254
-
255
- function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
256
- if (devices.length === 0) {
257
- return 'Waiting for a Companion App to sign in and register…'
258
- }
259
- const denied = devices.find((device) => device.permission_status === 'denied')
260
- if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
261
- const undecided = devices.find((device) => device.permission_status === 'not_determined')
262
- if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
263
- return 'Waiting for a Companion device to become ready…'
264
- }
265
-
266
- /** How many Companion Apps this Account has registered, on any platform. */
267
- async function registeredCompanions(client: ApiClient): Promise<number> {
268
- try {
269
- const response = await client.listDevices()
270
- return response.devices.filter(
271
- (device) => device.platform === 'ios' || device.platform === 'android',
272
- ).length
273
- } catch {
274
- // Unreachable here means unreachable for the wait that follows; ask, so a
275
- // User with nothing installed still gets the platform-specific steps.
276
- return 0
277
- }
278
- }
279
-
280
- /**
281
- * Which phone the notifications should arrive on.
282
- *
283
- * Asked in the User's words, here, so the destination this terminal opens is
284
- * already about their phone rather than an index they have to choose from
285
- * again on the other screen. A run with no human gets nothing — it opens no
286
- * browser and answers no question — so there is no prompt to hang on.
287
- */
288
- async function askCompanionPlatform(deps: CommandDeps): Promise<CompanionPlatform | null> {
289
- if (deps.io.interactive !== true || deps.io.select === undefined) return null
290
- const selected = await deps.io.select('Where do you want to receive notifications?', [
291
- { value: 'iphone', label: 'iPhone' },
292
- { value: 'android', label: 'Android' },
293
- ])
294
- return selected === 'iphone' || selected === 'android' ? selected : null
295
- }
296
-
297
- /**
298
- * Observe the supported Device Installation path while the user finishes the
299
- * app-side work. The bridge is one focused setup destination for the platform
300
- * they just named — not the omnibus help page, whose own next step is to go
301
- * somewhere else. Interactive runs offer to open it so the user never types
302
- * the URL; non-interactive/agent paths only print plain text and never wait on
303
- * a prompt.
304
- *
305
- * The wait budget is stated up front. On expiry we say the *timer* expired —
306
- * not the setup — and offer another budget when a human is present. Agents
307
- * never hang: this whole path is gated on `io.interactive`, and keep-waiting
308
- * uses `confirm` which resolves the safe default when not interactive.
309
- */
310
- async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
311
- const remedy = state.remedy
312
- if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
313
-
314
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
315
- const authed = authedClient(deps, config)
316
- if (!authed) {
317
- deps.io.err('Could not start the companion-device wait: this machine is not signed in.')
318
- return 'failed'
319
- }
320
-
321
- const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
322
- // A phone that is registered but silent needs its permission allowed, not
323
- // install steps — and asking which phone would be asking about one they are
324
- // already holding. Only a User with nothing registered has a platform still
325
- // to name, and that is read from the account rather than from the wording of
326
- // the state that got us here.
327
- const platform = (await registeredCompanions(authed.client)) === 0
328
- ? await askCompanionPlatform(deps)
329
- : null
330
- const setupUrl = setupCompanionUrl(authed.baseUrl, platform ?? undefined)
331
- const stepsLabel =
332
- platform === null ? 'Setup steps (no typing)' : `${companionPlatformLabel(platform)} steps (no typing)`
333
- await deps.io.note?.(
334
- [
335
- state.detail,
336
- remedy.summary,
337
- `${stepsLabel}: ${setupUrl}`,
338
- `I will wait up to ${budgetLabel} for a Companion App to become ready.`,
339
- ].join('\n'),
340
- 'Finish setup on your phone',
341
- )
342
-
343
- // Open the destination so the user never has to type the URL. Decline is
344
- // fine — the URL remains in the note and in the Next: line if they leave.
345
- if (await deps.io.confirm('Open those steps in your browser?', true)) {
346
- deps.io.openUrl(setupUrl)
347
- }
348
-
349
- if (!(await deps.io.confirm('Wait here while you finish that on your phone?', true))) {
350
- deps.io.out(
351
- `OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`,
352
- )
353
- return 'pending'
354
- }
355
-
356
- const now = deps.now ?? Date.now
357
- const sleep =
358
- deps.sleep ??
359
- ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
360
- const spinner = await deps.io.spinner?.(
361
- `Waiting up to ${budgetLabel} for a Companion device…`,
362
- )
363
- let lastDevices: RoutableDevice[] = []
364
- let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
365
-
366
- for (;;) {
367
- while (now() < deadline) {
368
- try {
369
- const response = await authed.client.listDevices()
370
- const companionDevices = response.devices.filter(
371
- (device) => device.platform === 'ios' || device.platform === 'android',
372
- )
373
- lastDevices = companionDevices
374
- const ready = readyCompanionDevices(companionDevices)[0]
375
- if (ready) {
376
- spinner?.stop(`${ready.display_name} is ready to receive`)
377
- return 'closed'
378
- }
379
- spinner?.message(deviceBridgeMessage(companionDevices))
380
- } catch (err) {
381
- if (!(err instanceof NetworkError)) {
382
- spinner?.error('Could not check companion readiness')
383
- reportError(deps, err)
384
- return 'failed'
385
- }
386
- spinner?.message('Connection lost — still watching…')
387
- }
388
- await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
389
- }
390
-
391
- // Timer expired — setup did not. Offer another budget only when a human
392
- // can answer; confirm() already returns the fallback for agents, so this
393
- // never hangs unattended even if interactive were mis-set.
394
- spinner?.error(`The ${budgetLabel} wait timer expired`)
395
- deps.io.err(
396
- `The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`,
397
- )
398
- deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
399
- deps.io.err(
400
- `Re-run \`notifai init\` later and it will pick up from here (steps: ${setupUrl}).`,
401
- )
402
-
403
- const keepWaiting = await deps.io.confirm(
404
- `Keep waiting for another ${budgetLabel}?`,
405
- false,
406
- )
407
- if (!keepWaiting) {
408
- deps.io.out(
409
- 'Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.',
410
- )
411
- return 'pending'
412
- }
413
- spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`)
414
- deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
415
- }
416
- }
417
-
418
- /**
419
- * The first notification a User ever receives from Notifai.
420
- *
421
- * It is the payoff of the whole setup, and it used to be written as an
422
- * internal receipt: "This real notification completed setup verification" — a
423
- * sentence about the check rather than about them — delivered silently, at
424
- * `passive`, with the sound turned off. So the one arrival that proves their
425
- * agents can reach them landed in Notification Center with no banner and no
426
- * sound, where a first-time User has no reason to look and every reason to
427
- * conclude that nothing came.
428
- *
429
- * It now says what is true for them and arrives the way their notifications
430
- * will. `done` is the honest kind — a body of work finished successfully — and
431
- * it is also what lets the server pick the sound through its own kind, Project
432
- * and Account layers instead of this command stamping one and skipping them.
433
- */
434
- function setupProofDraft(
435
- config: CliConfig,
436
- device: RoutableDevice,
437
- ): ReturnType<typeof buildDraft> {
438
- const project = config.project.value
439
- return buildDraft(config, {
440
- title: 'Your agents can reach you',
441
- body:
442
- project === null
443
- ? 'Notifai setup is finished. Notifications from your agents will arrive like this one.'
444
- : `Notifai setup is finished for ${project}. Notifications from your agents will arrive like this one.`,
445
- kind: 'done',
446
- platform: device.platform,
447
- device: [device.device_id],
448
- // Noticeable, not intrusive: a normal banner and sound. Deliberately not
449
- // `time_sensitive` — a setup confirmation has no business breaking a Focus.
450
- //
451
- // Android has no caller-selected interruption level. Explicit null keeps an
452
- // Apple preference in config from leaking into this Android-only proof.
453
- level: device.platform === 'ios' ? 'active' : null,
454
- collapseKey: 'notifai-setup-verification',
455
- })
456
- }
457
-
458
- async function submitSetupProof(
459
- deps: CommandDeps,
460
- client: ApiClient,
461
- config: CliConfig,
462
- device: RoutableDevice,
463
- ): Promise<SubmissionReceipt | null> {
464
- const build = setupProofDraft(config, device)
465
- if (!build.ok) {
466
- deps.io.err(`Could not build the setup verification notification: ${build.error}`)
467
- return null
468
- }
469
- const capabilities = CAPABILITIES_V1.describe(build.platform)
470
- if (!capabilities) {
471
- deps.io.err(`No capability contract is available for ${build.platform}.`)
472
- return null
473
- }
474
- const validation = validateDraft(build.draft, capabilities)
475
- if (!validation.ok) {
476
- for (const issue of validation.errors) {
477
- deps.io.err(`Setup verification ${issue.path}: ${issue.message}`)
478
- }
479
- return null
480
- }
481
- try {
482
- return await client.submit(
483
- {
484
- idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
485
- draft: build.draft,
486
- },
487
- config.wait_seconds.value,
488
- )
489
- } catch (err) {
490
- reportError(deps, err)
491
- return null
492
- }
493
- }
494
-
495
- /**
496
- * Send or resume one real setup probe, then wait for a Companion Receipt.
497
- * Provider Acceptance is intentionally insufficient: it proves a push provider
498
- * accepted the request, not that a companion process received it.
499
- */
500
- async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
501
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
502
- const authed = authedClient(deps, config)
503
- if (!authed) return 'failed'
504
-
505
- let devices: RoutableDevice[]
506
- try {
507
- devices = (await authed.client.listDevices()).devices
508
- } catch (err) {
509
- reportError(deps, err)
510
- return 'failed'
511
- }
512
- const candidates = readyCompanionDevices(devices)
513
- const project = setupProofProject(deps, config.project.value)
514
- const existing = readSetupProof(deps, project)
515
- const existingApplies = setupProofApplies(
516
- existing,
517
- project,
518
- candidates.map((device) => device.device_id),
519
- )
520
- const target = (existingApplies
521
- ? candidates.find((device) => device.device_id === existing.device_id)
522
- : undefined) ?? candidates[0]
523
- if (!target) {
524
- deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
525
- return 'pending'
526
- }
527
-
528
- let proof =
529
- existingApplies && existing.device_id === target.device_id
530
- ? existing
531
- : null
532
- const nowMs = (deps.now ?? Date.now)()
533
- if (proof !== null && setupProofIsStale(proof, nowMs)) {
534
- let keepStale = false
535
- try {
536
- const snapshot = await authed.client.evidence(proof.request_id)
537
- keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
538
- } catch {
539
- keepStale = false
540
- }
541
- if (!keepStale) {
542
- deps.io.out(
543
- `The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
544
- )
545
- proof = null
546
- }
547
- }
548
- if (proof === null) {
549
- const receipt = await submitSetupProof(deps, authed.client, config, target)
550
- if (receipt === null) return 'failed'
551
- if (receipt.overall === 'provider_rejected_all') {
552
- deps.io.err(formatReceipt(receipt))
553
- return 'failed'
554
- }
555
- proof = {
556
- request_id: receipt.request_id,
557
- device_id: target.device_id,
558
- project,
559
- started_at: new Date((deps.now ?? Date.now)()).toISOString(),
560
- }
561
- if (!writeSetupProof(deps, proof)) return 'failed'
562
- deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`)
563
- } else {
564
- deps.io.out(`Checking verification notification ${proof.request_id} again.`)
565
- }
566
-
567
- const now = deps.now ?? Date.now
568
- const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
569
- const deadline = now() + PROOF_TIMEOUT_MS
570
- const spinner = deps.io.interactive === true
571
- ? await deps.io.spinner?.(
572
- "Waiting for a Companion Receipt (the app's delivery confirmation)…",
573
- )
574
- : null
575
- let lastError: unknown = null
576
- let replacedMissingProof = false
577
-
578
- for (;;) {
579
- try {
580
- const snapshot = await authed.client.evidence(proof.request_id)
581
- const observed = observedCompanionReceipt(snapshot, proof.device_id)
582
- if (observed) {
583
- spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
584
- deps.io.out(
585
- `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`,
586
- )
587
- return 'closed'
588
- }
589
- lastError = null
590
- } catch (err) {
591
- lastError = err
592
- if (
593
- err instanceof ApiCallError &&
594
- err.code === 'not_found' &&
595
- !replacedMissingProof
596
- ) {
597
- const receipt = await submitSetupProof(deps, authed.client, config, target)
598
- if (receipt === null) return 'failed'
599
- if (receipt.overall === 'provider_rejected_all') {
600
- deps.io.err(formatReceipt(receipt))
601
- return 'failed'
602
- }
603
- proof = {
604
- request_id: receipt.request_id,
605
- device_id: target.device_id,
606
- project,
607
- started_at: new Date(now()).toISOString(),
608
- }
609
- if (!writeSetupProof(deps, proof)) return 'failed'
610
- replacedMissingProof = true
611
- lastError = null
612
- deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`)
613
- continue
614
- }
615
- if (!(err instanceof NetworkError)) {
616
- spinner?.error('Could not read Companion Receipt evidence')
617
- reportError(deps, err)
618
- return 'failed'
619
- }
620
- spinner?.message('Connection lost — still checking the same request…')
621
- }
622
-
623
- if (now() >= deadline) break
624
- await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
625
- }
626
-
627
- spinner?.stop('Delivery confirmation not observed yet')
628
- if (lastError instanceof NetworkError) deps.io.err(lastError.message)
629
- deps.io.out(
630
- `Provider accepted the notification; Companion Receipt (the app's delivery confirmation) for ${proof.request_id} was not observed within ${PROOF_TIMEOUT_MS / 1000}s. ` +
631
- 'Proof may still arrive — re-run `notifai init` and it will re-check this same notification.',
632
- )
633
- return 'pending'
634
- }
635
-
636
- /** Closing a local gap cannot have changed the service, the keychain, or devices. */
637
- function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
638
- return id === 'project' || id === 'project-enablement' || id === 'hooks' || id.startsWith('hooks-') || id === 'skill' || id === 'question-routing-settings'
639
- ? ['local']
640
- : undefined
641
- }
642
-
643
- /** Whether an optional gap should be closed, given flags and who is watching. */
644
- function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
645
- // Optional CLI updates are surfaced by doctor or when a missing feature is
646
- // relevant. Init does not turn them into setup work.
647
- if (state.id === 'contract') return Promise.resolve(false)
648
- // Naming the project is init's whole reason to touch the filesystem, costs
649
- // nothing, and is undone by editing one line — so it is done rather than
650
- // asked about, for a human and an agent alike.
651
- if (state.id === 'project') return Promise.resolve(true)
652
- if (state.id === 'project-enablement') return Promise.resolve(true)
653
- // Every other hook sub-state is a report line about routing, not an errand
654
- // with a yes/no question of its own; offering the hooks question for one of
655
- // them would ask about something the answer does not change.
656
- if (state.id !== 'hooks' && state.id !== 'skill') return Promise.resolve(false)
657
- const explicit = state.id === 'hooks' ? flags.hooks : flags.skills
658
- if (explicit !== undefined) return Promise.resolve(explicit)
659
- // An agent is never asked, and never assumed into a change it did not
660
- // request: silence means no, and the summary says what was skipped.
661
- if (deps.io.interactive !== true) return Promise.resolve(false)
662
- const question =
663
- state.id === 'hooks'
664
- ? 'Install harness hooks, so questions reach your devices when you are away?'
665
- : 'Install/update the agent guidance skill through the native npx skills flow?'
666
- return deps.io.confirm(question, true)
667
- }
668
-
669
- /** The two states whose remedy actually places files the scope question is about. */
670
- function needsInstallScope(state: ReadinessState): boolean {
671
- return state.id === 'hooks' || state.id === 'skill'
672
- }
673
-
674
- function isSetupScope(value: string | undefined): value is SetupScope {
675
- return value === 'project' || value === 'global'
676
- }
677
-
678
- /**
679
- * Scope flags are checked before anything runs, because a contradiction is a
680
- * usage error and reporting it after half a setup is worse than reporting it
681
- * now. Answering the question is a separate step (`scopeForLocalInstall`).
682
- */
683
- function checkSetupScopeFlags(deps: CommandDeps, flags: InitFlags): SetupScope | undefined | 'usage' {
684
- if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
685
- deps.io.err('Invalid setup scope. Choose `project` or `global`.')
686
- return 'usage'
687
- }
688
- if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
689
- deps.io.err('Invalid skill scope. Choose `project` or `global`.')
690
- return 'usage'
691
- }
692
- if (
693
- flags.setupScope !== undefined &&
694
- flags.skillsScope !== undefined &&
695
- flags.setupScope !== flags.skillsScope
696
- ) {
697
- deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
698
- return 'usage'
699
- }
700
- if (flags.skillsScope !== undefined && flags.skills !== true) {
701
- deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
702
- return 'usage'
703
- }
704
- const fromFlags = flags.setupScope ?? flags.skillsScope
705
- // Asking for an install nobody can be asked about is a usage error, and it
706
- // is knowable from the flags alone — so it is reported before a setup runs
707
- // half way rather than after.
708
- if (
709
- fromFlags === undefined &&
710
- deps.io.interactive !== true &&
711
- (flags.skills === true || flags.hooks === true)
712
- ) {
713
- deps.io.err(
714
- 'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
715
- )
716
- return 'usage'
717
- }
718
- return fromFlags
719
- }
720
-
721
- /**
722
- * Where a skill or hook install should land, asked at the moment one is about
723
- * to happen and not before.
724
- *
725
- * It used to be the first thing the product ever said, in front of the
726
- * assessment, sign-in and every other gate — a question about skill, hook and
727
- * shared-config placement put to someone who had met none of those words and
728
- * might never reach the step it decides. Worse, project identity and Project
729
- * Enablement were gated on the answer, so an unattended run that could not
730
- * produce one advanced nothing at all.
731
- *
732
- * Now nothing depends on it except the two installs it actually describes.
733
- */
734
- async function scopeForLocalInstall(
735
- deps: CommandDeps,
736
- flags: InitFlags,
737
- ): Promise<SetupScope | 'declined'> {
738
- const fromFlags = flags.setupScope ?? flags.skillsScope
739
- if (fromFlags !== undefined) return fromFlags
740
- if (deps.io.interactive === true && deps.io.select) {
741
- const selected = await deps.io.select(
742
- 'Should this Notifai setup apply to this project only, or to every project on this machine?',
743
- [
744
- {
745
- value: 'project',
746
- label: 'This project',
747
- hint: 'skill, hooks, and shared config stay in this checkout',
748
- },
749
- {
750
- value: 'global',
751
- label: 'This machine',
752
- hint: 'skill and hooks follow you into every repo',
753
- },
754
- ],
755
- )
756
- if (selected === 'project' || selected === 'global') return selected
757
- deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
758
- return 'declined'
759
- }
760
- deps.io.err(
761
- 'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
762
- )
763
- return 'declined'
764
- }
765
-
766
- /**
767
- * Setup as one step at a time.
768
- *
769
- * The old version ran five steps in a fixed order and ended with a list of
770
- * everything still outstanding. That is a report, and a report is the wrong
771
- * output here: someone handed five things to do does none of them, and the
772
- * order was the script's rather than the dependency graph's — it offered to
773
- * install hooks after a sign-in that had just failed.
774
- *
775
- * So this closes what it can, then surfaces exactly one thing, the first that
776
- * stands in the way. Re-running advances by one. Idempotence stops being a
777
- * property to preserve and becomes the mechanism: every decision is derived
778
- * from observed state, so a partial run, a second project, a fresh worktree
779
- * and a revoked credential are the same code path arriving at different
780
- * states rather than four branches to enumerate.
781
- */
782
- export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
783
- const workingDeps: CommandDeps = flags.json === true
784
- ? {
785
- ...deps,
786
- io: {
787
- interactive: false,
788
- out: () => {},
789
- err: (line) => deps.io.err(line),
790
- confirm: async () => false,
791
- openUrl: (url) => deps.io.openUrl(url),
792
- },
793
- }
794
- : deps
795
- const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags)
796
- if (scopeFromFlags === 'usage') return EXIT.usage
797
- let resolved: InitFlags = {
798
- ...flags,
799
- ...(scopeFromFlags === undefined
800
- ? {}
801
- : { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
802
- }
803
- await workingDeps.io.intro?.('Notifai setup')
804
-
805
- const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
806
- let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
807
- const reassess = (refresh?: readonly ReadinessRefresh[]) =>
808
- assessReadiness(workingDeps, {
809
- ...skillOpts,
810
- ...(flags.json === true ? { json: true } : {}),
811
- ...(refresh === undefined ? {} : { previous: readiness, refresh }),
812
- })
813
- let failed = false
814
- // What actually stopped a sign-in, when something did. Without it the close
815
- // renders the state as it was before the attempt — "not paired … run
816
- // `notifai init`" — which contradicts the correct line printed moments
817
- // earlier and sends the reader back into the command that just failed.
818
- let loginBlocker: ReadinessState | null = null
819
- const attempted = new Set<string>()
820
-
821
- // Re-assess after every successful action. This is how a browser approval or
822
- // companion registration can unlock the next state while the user is still
823
- // here, without copying the dependency graph into a second setup script.
824
- for (;;) {
825
- let advanced = false
826
- let stop = false
827
-
828
- for (const state of readiness.states) {
829
- if (state.status === 'ready') continue
830
- // Question Routing is optional automation, so it is reported and never
831
- // waited on. `doctor` still fails on a broken route; `init` walks past
832
- // it, because a hook diagnostic standing in front of the delivery proof
833
- // means a setup that can already send never proves that it can.
834
- const optional = isOptionalSetup(state.id)
835
- const halt = () => {
836
- if (!optional) stop = true
837
- return !optional
838
- }
839
- if (state.status === 'unknown') {
840
- if (halt()) break
841
- continue
842
- }
843
-
844
- const remedy = state.remedy
845
- if (remedy === undefined || attempted.has(state.id)) {
846
- if (state.status === 'gap' && halt()) break
847
- continue
848
- }
849
-
850
- if (state.status === 'optional-gap') {
851
- if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
852
- if (needsInstallScope(state)) {
853
- const scope = await scopeForLocalInstall(workingDeps, resolved)
854
- if (scope === 'declined') {
855
- attempted.add(state.id)
856
- continue
857
- }
858
- resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope }
859
- }
860
- attempted.add(state.id)
861
- const result = await closeGap(workingDeps, state, resolved)
862
- if (result === 'failed') failed = true
863
- if (result === 'failed' && state.status === 'optional-gap') {
864
- readiness = await reassess(refreshAfterClose(state.id))
865
- advanced = true
866
- break
867
- }
868
- if (result !== 'closed') {
869
- if (halt()) break
870
- continue
871
- }
872
- readiness = await reassess(refreshAfterClose(state.id))
873
- advanced = true
874
- break
875
- }
876
-
877
- if (remedy.by === 'cli') {
878
- if (needsInstallScope(state)) {
879
- const scope = await scopeForLocalInstall(workingDeps, resolved)
880
- if (scope === 'declined') {
881
- attempted.add(state.id)
882
- if (halt()) break
883
- continue
884
- }
885
- resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope }
886
- }
887
- attempted.add(state.id)
888
- const result = await closeGap(workingDeps, state, resolved)
889
- if (result === 'failed') failed = true
890
- if (result !== 'closed') {
891
- if (halt()) break
892
- continue
893
- }
894
- readiness = await reassess(refreshAfterClose(state.id))
895
- advanced = true
896
- break
897
- }
898
-
899
- // Its to launch, theirs to complete. Running `init` is the consent; announce
900
- // and open rather than re-asking. An agent never reaches this path.
901
- if (
902
- remedy.by === 'user-here' &&
903
- remedy.interactive === true &&
904
- workingDeps.io.interactive === true
905
- ) {
906
- attempted.add(state.id)
907
- workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
908
- if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
909
- failed = true
910
- stop = true
911
- break
912
- }
913
- readiness = await reassess()
914
- advanced = true
915
- break
916
- }
917
-
918
- if (
919
- state.id === 'devices' &&
920
- remedy.by === 'user-elsewhere' &&
921
- workingDeps.io.interactive === true
922
- ) {
923
- attempted.add(state.id)
924
- const result = await waitForReadyDevice(workingDeps, state)
925
- if (result === 'failed') failed = true
926
- if (result !== 'closed') {
927
- stop = true
928
- break
929
- }
930
- readiness = await reassess()
931
- advanced = true
932
- break
933
- }
934
-
935
- // A human-only remedy is the first blocker for an unattended agent.
936
- if (halt()) break
937
- continue
938
- }
939
-
940
- if (stop || !advanced) break
941
- }
942
-
943
- const blocker = loginBlocker ?? firstRequiredBlocker(readiness)
944
- if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
945
- else await printInitClose(deps, readiness, resolved, loginBlocker)
946
- if (blocker === null) return failed ? EXIT.failed : EXIT.ok
947
- return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
948
- }
949
-
950
- function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
951
- return readiness.states.filter((state) => {
952
- if (state.status !== 'optional-gap') return false
953
- if (isOptionalAutomation(state.id)) return false
954
- // Not an install anyone declined, so "Optional, not set up" would misread
955
- // it; it gets its own line below.
956
- if (state.id === 'cli-bin') return false
957
- if (state.id === 'skill' && flags.skills === false) return false
958
- return true
959
- })
960
- }
961
-
962
- /**
963
- * A `notifai` the reader cannot type.
964
- *
965
- * Said on the way out of every run, successful or not, because every next step
966
- * this command prints — here, in the skill, in the README — names a command
967
- * that will not be found until it is fixed.
968
- */
969
- function printMissingCliBin(deps: CommandDeps, readiness: Readiness): void {
970
- const state = readiness.states.find((candidate) => candidate.id === 'cli-bin')
971
- if (state?.status !== 'optional-gap') return
972
- deps.io.out(`Heads up: ${state.detail}. ${remedyLine(state)}`)
973
- }
974
-
975
- function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
976
- for (const state of leftovers) {
977
- deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
978
- }
979
- }
980
-
981
- async function printInitClose(
982
- deps: CommandDeps,
983
- readiness: Readiness,
984
- flags: InitFlags,
985
- stoppedBy: ReadinessState | null = null,
986
- ): Promise<void> {
987
- const blocker = stoppedBy ?? firstRequiredBlocker(readiness)
988
- const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
989
- const questions = questionRoutingReady(readiness)
990
- const leftovers = leftoverOptionals(readiness, flags).filter(
991
- (state) => state.id !== 'contract',
992
- )
993
-
994
- if (blocker?.id === 'contract') {
995
- deps.io.out(blocker.detail)
996
- deps.io.out(
997
- blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.command !== undefined
998
- ? blocker.remedy.command
999
- : updateCliCommand(deps),
1000
- )
1001
- return
1002
- }
1003
-
1004
- printMissingCliBin(deps, readiness)
1005
-
1006
- if (deps.io.interactive === true) {
1007
- if (blocker === null) {
1008
- const lines = [
1009
- canSend ? 'You can send notifications.' : 'You are signed in.',
1010
- questions
1011
- ? 'Questions will reach your devices.'
1012
- : 'Questions are terminal-only until Question Routing is ready.',
1013
- ]
1014
- await deps.io.note?.(lines.join('\n'), 'Ready')
1015
- printOptionalLeftovers(deps, leftovers)
1016
- deps.io.out(
1017
- questions
1018
- ? 'All set. Agents in this project can notify you and ask you questions.'
1019
- : 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
1020
- )
1021
- await deps.io.outro?.('All set ✨')
1022
- return
1023
- }
1024
- // No framed copy of what the next three lines already say. The "Ready"
1025
- // note above adds something the close does not; this one only made a
1026
- // stopped visit state its one step twice, box then text.
1027
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
1028
- deps.io.out(` ${remedyLine(blocker)}`)
1029
- if (shouldRestateInit(blocker)) {
1030
- deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
1031
- }
1032
- await deps.io.outro?.('One step remains (above)')
1033
- return
1034
- }
1035
-
1036
- if (blocker === null) {
1037
- printOptionalLeftovers(deps, leftovers)
1038
- deps.io.out(
1039
- questions
1040
- ? 'All set. Agents in this project can notify you and ask you questions.'
1041
- : 'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
1042
- )
1043
- return
1044
- }
1045
- deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
1046
- deps.io.out(` ${remedyLine(blocker)}`)
1047
- if (shouldRestateInit(blocker)) {
1048
- deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
1049
- }
1050
- }
1051
-
1052
- function shouldRestateInit(blocker: ReadinessState): boolean {
1053
- if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here') return false
1054
- return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND
1055
- }