@raidiant/notifai 10.0.0 → 10.1.1

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 (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. package/src/commands-devices.test.ts +0 -15
@@ -14,7 +14,9 @@ import { personalProjectConfigPath, type CliConfig } from './config.js'
14
14
  import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
15
15
  import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
16
16
  import {
17
- firstBlocker,
17
+ firstRequiredBlocker,
18
+ isOptionalAutomation,
19
+ isOptionalSetup,
18
20
  questionRoutingReady,
19
21
  readinessJson,
20
22
  type Readiness,
@@ -25,13 +27,19 @@ import { buildDraft, formatReceipt } from './send.js'
25
27
  import { loginCommand } from './commands-auth.js'
26
28
  import {
27
29
  EXIT,
28
- UPDATE_CLI_COMMAND,
30
+ SETUP_COMMAND,
29
31
  authedClient,
30
32
  loadLoggedConfig,
31
33
  reportError,
34
+ updateCliCommand,
32
35
  type CommandDeps,
33
36
  } from './commands-core.js'
34
- import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
37
+ import { readyCompanionDevices } from './commands-devices.js'
38
+ import {
39
+ companionPlatformLabel,
40
+ setupCompanionUrl,
41
+ type CompanionPlatform,
42
+ } from './setup-destinations.js'
35
43
  import { assessReadiness, remedyLine } from './commands-doctor.js'
36
44
  import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
37
45
  import {
@@ -44,6 +52,8 @@ import {
44
52
  } from './commands-setup-proof.js'
45
53
  import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
46
54
  import { enableProject, projectBinding } from './project-enablement.js'
55
+ import { inspectCliInstallations } from './cli-bin.js'
56
+ import { installHookAdapter } from './hook-adapter.js'
47
57
 
48
58
  // ---------------------------------------------------------------------------
49
59
  // init
@@ -124,7 +134,10 @@ async function closeGap(
124
134
  flags: InitFlags,
125
135
  ): Promise<GapCloseResult> {
126
136
  if (state.id === 'project') {
127
- if (flags.setupScope === undefined) return 'failed'
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.
128
141
  const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
129
142
  const configPath =
130
143
  flags.setupScope === 'global'
@@ -159,6 +172,25 @@ async function closeGap(
159
172
  return ok ? 'closed' : 'failed'
160
173
  }
161
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
+
162
194
  if (state.id === 'skill') {
163
195
  if (deps.nativeSkills === undefined) {
164
196
  deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
@@ -231,12 +263,44 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
231
263
  return 'Waiting for a Companion device to become ready…'
232
264
  }
233
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
+
234
297
  /**
235
298
  * Observe the supported Device Installation path while the user finishes the
236
- * app-side work. The live bridge is the dashboard `/support` page (controlled
237
- * Companion installation steps). Interactive runs offer to open it so the user
238
- * never types the URL; non-interactive/agent paths only print plain text and
239
- * never wait on a prompt.
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.
240
304
  *
241
305
  * The wait budget is stated up front. On expiry we say the *timer* expired —
242
306
  * not the setup — and offer another budget when a human is present. Agents
@@ -255,26 +319,36 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
255
319
  }
256
320
 
257
321
  const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
258
- const supportUrl = supportPageUrl(authed.baseUrl)
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)`
259
333
  await deps.io.note?.(
260
334
  [
261
335
  state.detail,
262
336
  remedy.summary,
263
- `Install steps (no typing): ${supportUrl}`,
264
- `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
337
+ `${stepsLabel}: ${setupUrl}`,
338
+ `I will wait up to ${budgetLabel} for a Companion App to become ready.`,
265
339
  ].join('\n'),
266
- 'Finish setup on your Companion device',
340
+ 'Finish setup on your phone',
267
341
  )
268
342
 
269
- // Open the real support page so the user never has to type the URL. Decline
270
- // is fine — the URL remains in the note and in the Next: line if they leave.
271
- if (await deps.io.confirm('Open install instructions in your browser?', true)) {
272
- deps.io.openUrl(supportUrl)
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)
273
347
  }
274
348
 
275
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
349
+ if (!(await deps.io.confirm('Wait here while you finish that on your phone?', true))) {
276
350
  deps.io.out(
277
- `OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`,
351
+ `OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`,
278
352
  )
279
353
  return 'pending'
280
354
  }
@@ -323,7 +397,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
323
397
  )
324
398
  deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
325
399
  deps.io.err(
326
- `Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`,
400
+ `Re-run \`notifai init\` later and it will pick up from here (steps: ${setupUrl}).`,
327
401
  )
328
402
 
329
403
  const keepWaiting = await deps.io.confirm(
@@ -336,29 +410,47 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
336
410
  )
337
411
  return 'pending'
338
412
  }
339
- spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`)
413
+ spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`)
340
414
  deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
341
415
  }
342
416
  }
343
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
+ */
344
434
  function setupProofDraft(
345
435
  config: CliConfig,
346
436
  device: RoutableDevice,
347
437
  ): ReturnType<typeof buildDraft> {
348
438
  const project = config.project.value
349
439
  return buildDraft(config, {
350
- title: 'Notifai is ready',
440
+ title: 'Your agents can reach you',
351
441
  body:
352
442
  project === null
353
- ? 'This real notification completed setup verification.'
354
- : `This real notification completed setup verification for ${project}.`,
355
- kind: 'update',
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',
356
446
  platform: device.platform,
357
447
  device: [device.device_id],
358
- sound: 'none',
448
+ // Noticeable, not intrusive: a normal banner and sound. Deliberately not
449
+ // `time_sensitive` — a setup confirmation has no business breaking a Focus.
450
+ //
359
451
  // Android has no caller-selected interruption level. Explicit null keeps an
360
452
  // Apple preference in config from leaking into this Android-only proof.
361
- level: device.platform === 'ios' ? 'passive' : null,
453
+ level: device.platform === 'ios' ? 'active' : null,
362
454
  collapseKey: 'notifai-setup-verification',
363
455
  })
364
456
  }
@@ -543,7 +635,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
543
635
 
544
636
  /** Closing a local gap cannot have changed the service, the keychain, or devices. */
545
637
  function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
546
- return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
638
+ return id === 'project' || id === 'project-enablement' || id === 'hooks' || id.startsWith('hooks-') || id === 'skill' || id === 'question-routing-settings'
547
639
  ? ['local']
548
640
  : undefined
549
641
  }
@@ -558,7 +650,11 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
558
650
  // asked about, for a human and an agent alike.
559
651
  if (state.id === 'project') return Promise.resolve(true)
560
652
  if (state.id === 'project-enablement') return Promise.resolve(true)
561
- const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined
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
562
658
  if (explicit !== undefined) return Promise.resolve(explicit)
563
659
  // An agent is never asked, and never assumed into a change it did not
564
660
  // request: silence means no, and the summary says what was skipped.
@@ -570,30 +666,21 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
570
666
  return deps.io.confirm(question, true)
571
667
  }
572
668
 
573
- /**
574
- * Setup as one step at a time.
575
- *
576
- * The old version ran five steps in a fixed order and ended with a list of
577
- * everything still outstanding. That is a report, and a report is the wrong
578
- * output here: someone handed five things to do does none of them, and the
579
- * order was the script's rather than the dependency graph's — it offered to
580
- * install hooks after a sign-in that had just failed.
581
- *
582
- * So this closes what it can, then surfaces exactly one thing, the first that
583
- * stands in the way. Re-running advances by one. Idempotence stops being a
584
- * property to preserve and becomes the mechanism: every decision is derived
585
- * from observed state, so a partial run, a second project, a fresh worktree
586
- * and a revoked credential are the same code path arriving at different
587
- * states rather than four branches to enumerate.
588
- */
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
+
589
674
  function isSetupScope(value: string | undefined): value is SetupScope {
590
675
  return value === 'project' || value === 'global'
591
676
  }
592
677
 
593
- async function resolveSetupScope(
594
- deps: CommandDeps,
595
- flags: InitFlags,
596
- ): Promise<SetupScope | undefined | 'usage'> {
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' {
597
684
  if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
598
685
  deps.io.err('Invalid setup scope. Choose `project` or `global`.')
599
686
  return 'usage'
@@ -614,9 +701,42 @@ async function resolveSetupScope(
614
701
  deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
615
702
  return 'usage'
616
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'> {
617
738
  const fromFlags = flags.setupScope ?? flags.skillsScope
618
739
  if (fromFlags !== undefined) return fromFlags
619
- if (flags.skills === false && flags.hooks === false) return undefined
620
740
  if (deps.io.interactive === true && deps.io.select) {
621
741
  const selected = await deps.io.select(
622
742
  'Should this Notifai setup apply to this project only, or to every project on this machine?',
@@ -635,17 +755,30 @@ async function resolveSetupScope(
635
755
  )
636
756
  if (selected === 'project' || selected === 'global') return selected
637
757
  deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
638
- return 'usage'
758
+ return 'declined'
639
759
  }
640
- if (flags.skills === true || flags.hooks === true) {
641
- deps.io.err(
642
- 'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
643
- )
644
- return 'usage'
645
- }
646
- return undefined
760
+ deps.io.err(
761
+ 'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
762
+ )
763
+ return 'declined'
647
764
  }
648
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
+ */
649
782
  export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
650
783
  const workingDeps: CommandDeps = flags.json === true
651
784
  ? {
@@ -659,11 +792,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
659
792
  },
660
793
  }
661
794
  : deps
662
- const setupScope = await resolveSetupScope(workingDeps, flags)
663
- if (setupScope === 'usage') return EXIT.usage
664
- const resolved: InitFlags = {
795
+ const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags)
796
+ if (scopeFromFlags === 'usage') return EXIT.usage
797
+ let resolved: InitFlags = {
665
798
  ...flags,
666
- ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
799
+ ...(scopeFromFlags === undefined
800
+ ? {}
801
+ : { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
667
802
  }
668
803
  await workingDeps.io.intro?.('Notifai setup')
669
804
 
@@ -676,6 +811,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
676
811
  ...(refresh === undefined ? {} : { previous: readiness, refresh }),
677
812
  })
678
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
679
819
  const attempted = new Set<string>()
680
820
 
681
821
  // Re-assess after every successful action. This is how a browser approval or
@@ -687,23 +827,35 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
687
827
 
688
828
  for (const state of readiness.states) {
689
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
+ }
690
839
  if (state.status === 'unknown') {
691
- stop = true
692
- break
840
+ if (halt()) break
841
+ continue
693
842
  }
694
843
 
695
844
  const remedy = state.remedy
696
845
  if (remedy === undefined || attempted.has(state.id)) {
697
- if (state.status === 'gap') stop = true
698
- if (stop) break
846
+ if (state.status === 'gap' && halt()) break
699
847
  continue
700
848
  }
701
849
 
702
850
  if (state.status === 'optional-gap') {
703
851
  if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
704
- if (resolved.setupScope === undefined && state.id === 'project') {
705
- stop = true
706
- break
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 }
707
859
  }
708
860
  attempted.add(state.id)
709
861
  const result = await closeGap(workingDeps, state, resolved)
@@ -714,8 +866,8 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
714
866
  break
715
867
  }
716
868
  if (result !== 'closed') {
717
- stop = true
718
- break
869
+ if (halt()) break
870
+ continue
719
871
  }
720
872
  readiness = await reassess(refreshAfterClose(state.id))
721
873
  advanced = true
@@ -723,16 +875,21 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
723
875
  }
724
876
 
725
877
  if (remedy.by === 'cli') {
726
- if (resolved.setupScope === undefined && state.id === 'project') {
727
- stop = true
728
- break
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 }
729
886
  }
730
887
  attempted.add(state.id)
731
888
  const result = await closeGap(workingDeps, state, resolved)
732
889
  if (result === 'failed') failed = true
733
890
  if (result !== 'closed') {
734
- stop = true
735
- break
891
+ if (halt()) break
892
+ continue
736
893
  }
737
894
  readiness = await reassess(refreshAfterClose(state.id))
738
895
  advanced = true
@@ -748,7 +905,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
748
905
  ) {
749
906
  attempted.add(state.id)
750
907
  workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
751
- if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
908
+ if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
752
909
  failed = true
753
910
  stop = true
754
911
  break
@@ -776,33 +933,45 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
776
933
  }
777
934
 
778
935
  // A human-only remedy is the first blocker for an unattended agent.
779
- stop = true
780
- break
936
+ if (halt()) break
937
+ continue
781
938
  }
782
939
 
783
940
  if (stop || !advanced) break
784
941
  }
785
942
 
943
+ const blocker = loginBlocker ?? firstRequiredBlocker(readiness)
786
944
  if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
787
- else await printInitClose(deps, readiness, resolved)
788
- const blocker = firstBlocker(readiness)
945
+ else await printInitClose(deps, readiness, resolved, loginBlocker)
789
946
  if (blocker === null) return failed ? EXIT.failed : EXIT.ok
790
947
  return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
791
948
  }
792
949
 
793
- function isHookSubstate(id: string): boolean {
794
- return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
795
- }
796
-
797
950
  function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
798
951
  return readiness.states.filter((state) => {
799
952
  if (state.status !== 'optional-gap') return false
800
- if (isHookSubstate(state.id)) 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
801
957
  if (state.id === 'skill' && flags.skills === false) return false
802
958
  return true
803
959
  })
804
960
  }
805
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
+
806
975
  function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
807
976
  for (const state of leftovers) {
808
977
  deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
@@ -813,8 +982,9 @@ async function printInitClose(
813
982
  deps: CommandDeps,
814
983
  readiness: Readiness,
815
984
  flags: InitFlags,
985
+ stoppedBy: ReadinessState | null = null,
816
986
  ): Promise<void> {
817
- const blocker = firstBlocker(readiness)
987
+ const blocker = stoppedBy ?? firstRequiredBlocker(readiness)
818
988
  const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
819
989
  const questions = questionRoutingReady(readiness)
820
990
  const leftovers = leftoverOptionals(readiness, flags).filter(
@@ -823,10 +993,16 @@ async function printInitClose(
823
993
 
824
994
  if (blocker?.id === 'contract') {
825
995
  deps.io.out(blocker.detail)
826
- deps.io.out(UPDATE_CLI_COMMAND)
996
+ deps.io.out(
997
+ blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.command !== undefined
998
+ ? blocker.remedy.command
999
+ : updateCliCommand(deps),
1000
+ )
827
1001
  return
828
1002
  }
829
1003
 
1004
+ printMissingCliBin(deps, readiness)
1005
+
830
1006
  if (deps.io.interactive === true) {
831
1007
  if (blocker === null) {
832
1008
  const lines = [
@@ -845,11 +1021,13 @@ async function printInitClose(
845
1021
  await deps.io.outro?.('All set ✨')
846
1022
  return
847
1023
  }
848
- await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
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.
849
1027
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
850
1028
  deps.io.out(` ${remedyLine(blocker)}`)
851
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
852
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
1029
+ if (shouldRestateInit(blocker)) {
1030
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
853
1031
  }
854
1032
  await deps.io.outro?.('One step remains (above)')
855
1033
  return
@@ -866,7 +1044,12 @@ async function printInitClose(
866
1044
  }
867
1045
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
868
1046
  deps.io.out(` ${remedyLine(blocker)}`)
869
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
870
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
1047
+ if (shouldRestateInit(blocker)) {
1048
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
871
1049
  }
872
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
+ }