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