@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
@@ -8,16 +8,19 @@ import { ApiCallError, NetworkError } from './client.js';
8
8
  import { personalProjectConfigPath } from './config.js';
9
9
  import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js';
10
10
  import { SKILLS_INSTALLER_SPEC } from './native-skills.js';
11
- import { firstBlocker, questionRoutingReady, readinessJson, } from './readiness.js';
11
+ import { firstRequiredBlocker, isOptionalAutomation, isOptionalSetup, questionRoutingReady, readinessJson, } from './readiness.js';
12
12
  import { buildDraft, formatReceipt } from './send.js';
13
13
  import { loginCommand } from './commands-auth.js';
14
- import { EXIT, UPDATE_CLI_COMMAND, authedClient, loadLoggedConfig, reportError, } from './commands-core.js';
15
- import { readyCompanionDevices, supportPageUrl } from './commands-devices.js';
14
+ import { EXIT, SETUP_COMMAND, authedClient, loadLoggedConfig, reportError, updateCliCommand, } from './commands-core.js';
15
+ import { readyCompanionDevices } from './commands-devices.js';
16
+ import { companionPlatformLabel, setupCompanionUrl, } from './setup-destinations.js';
16
17
  import { assessReadiness, remedyLine } from './commands-doctor.js';
17
18
  import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js';
18
19
  import { observedCompanionReceipt, readSetupProof, setupProofProject, setupProofApplies, setupProofIsStale, writeSetupProof, } from './commands-setup-proof.js';
19
20
  import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js';
20
21
  import { enableProject, projectBinding } from './project-enablement.js';
22
+ import { inspectCliInstallations } from './cli-bin.js';
23
+ import { installHookAdapter } from './hook-adapter.js';
21
24
  // ---------------------------------------------------------------------------
22
25
  // init
23
26
  // ---------------------------------------------------------------------------
@@ -36,8 +39,10 @@ function formatWaitBudget(milliseconds) {
36
39
  }
37
40
  async function closeGap(deps, state, flags) {
38
41
  if (state.id === 'project') {
39
- if (flags.setupScope === undefined)
40
- return 'failed';
42
+ // Naming this checkout is local bookkeeping, not skill or hook placement,
43
+ // so it does not wait on the scope question. Without an explicit answer the
44
+ // name lands in the checkout it describes, which is the answer a User who
45
+ // never sees the question would have given.
41
46
  const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
42
47
  const configPath = flags.setupScope === 'global'
43
48
  ? personalProjectConfigPath(deps.cwd, deps.env)
@@ -73,6 +78,19 @@ async function closeGap(deps, state, flags) {
73
78
  }
74
79
  return ok ? 'closed' : 'failed';
75
80
  }
81
+ if (state.id === 'hooks-adapter') {
82
+ const effective = inspectCliInstallations(deps.env, deps.hookPlatform ?? process.platform).effective;
83
+ if (effective === null || effective.artifact_path === null)
84
+ return 'failed';
85
+ try {
86
+ installHookAdapter({ execPath: process.execPath, scriptPath: effective.artifact_path }, deps.hookAdapterHome, deps.hookPlatform ?? process.platform);
87
+ return 'closed';
88
+ }
89
+ catch (err) {
90
+ deps.io.err(`Could not retarget Question Routing: ${String(err)}`);
91
+ return 'failed';
92
+ }
93
+ }
76
94
  if (state.id === 'skill') {
77
95
  if (deps.nativeSkills === undefined) {
78
96
  deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
@@ -133,12 +151,42 @@ function deviceBridgeMessage(devices) {
133
151
  return `Waiting for ${undecided.display_name} to allow the notification prompt…`;
134
152
  return 'Waiting for a Companion device to become ready…';
135
153
  }
154
+ /** How many Companion Apps this Account has registered, on any platform. */
155
+ async function registeredCompanions(client) {
156
+ try {
157
+ const response = await client.listDevices();
158
+ return response.devices.filter((device) => device.platform === 'ios' || device.platform === 'android').length;
159
+ }
160
+ catch {
161
+ // Unreachable here means unreachable for the wait that follows; ask, so a
162
+ // User with nothing installed still gets the platform-specific steps.
163
+ return 0;
164
+ }
165
+ }
166
+ /**
167
+ * Which phone the notifications should arrive on.
168
+ *
169
+ * Asked in the User's words, here, so the destination this terminal opens is
170
+ * already about their phone rather than an index they have to choose from
171
+ * again on the other screen. A run with no human gets nothing — it opens no
172
+ * browser and answers no question — so there is no prompt to hang on.
173
+ */
174
+ async function askCompanionPlatform(deps) {
175
+ if (deps.io.interactive !== true || deps.io.select === undefined)
176
+ return null;
177
+ const selected = await deps.io.select('Where do you want to receive notifications?', [
178
+ { value: 'iphone', label: 'iPhone' },
179
+ { value: 'android', label: 'Android' },
180
+ ]);
181
+ return selected === 'iphone' || selected === 'android' ? selected : null;
182
+ }
136
183
  /**
137
184
  * Observe the supported Device Installation path while the user finishes the
138
- * app-side work. The live bridge is the dashboard `/support` page (controlled
139
- * Companion installation steps). Interactive runs offer to open it so the user
140
- * never types the URL; non-interactive/agent paths only print plain text and
141
- * never wait on a prompt.
185
+ * app-side work. The bridge is one focused setup destination for the platform
186
+ * they just named — not the omnibus help page, whose own next step is to go
187
+ * somewhere else. Interactive runs offer to open it so the user never types
188
+ * the URL; non-interactive/agent paths only print plain text and never wait on
189
+ * a prompt.
142
190
  *
143
191
  * The wait budget is stated up front. On expiry we say the *timer* expired —
144
192
  * not the setup — and offer another budget when a human is present. Agents
@@ -156,20 +204,29 @@ async function waitForReadyDevice(deps, state) {
156
204
  return 'failed';
157
205
  }
158
206
  const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
159
- const supportUrl = supportPageUrl(authed.baseUrl);
207
+ // A phone that is registered but silent needs its permission allowed, not
208
+ // install steps — and asking which phone would be asking about one they are
209
+ // already holding. Only a User with nothing registered has a platform still
210
+ // to name, and that is read from the account rather than from the wording of
211
+ // the state that got us here.
212
+ const platform = (await registeredCompanions(authed.client)) === 0
213
+ ? await askCompanionPlatform(deps)
214
+ : null;
215
+ const setupUrl = setupCompanionUrl(authed.baseUrl, platform ?? undefined);
216
+ const stepsLabel = platform === null ? 'Setup steps (no typing)' : `${companionPlatformLabel(platform)} steps (no typing)`;
160
217
  await deps.io.note?.([
161
218
  state.detail,
162
219
  remedy.summary,
163
- `Install steps (no typing): ${supportUrl}`,
164
- `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
165
- ].join('\n'), 'Finish setup on your Companion device');
166
- // Open the real support page so the user never has to type the URL. Decline
167
- // is fine — the URL remains in the note and in the Next: line if they leave.
168
- if (await deps.io.confirm('Open install instructions in your browser?', true)) {
169
- deps.io.openUrl(supportUrl);
170
- }
171
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
172
- deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
220
+ `${stepsLabel}: ${setupUrl}`,
221
+ `I will wait up to ${budgetLabel} for a Companion App to become ready.`,
222
+ ].join('\n'), 'Finish setup on your phone');
223
+ // Open the destination so the user never has to type the URL. Decline is
224
+ // fine — the URL remains in the note and in the Next: line if they leave.
225
+ if (await deps.io.confirm('Open those steps in your browser?', true)) {
226
+ deps.io.openUrl(setupUrl);
227
+ }
228
+ if (!(await deps.io.confirm('Wait here while you finish that on your phone?', true))) {
229
+ deps.io.out(`OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`);
173
230
  return 'pending';
174
231
  }
175
232
  const now = deps.now ?? Date.now;
@@ -207,30 +264,48 @@ async function waitForReadyDevice(deps, state) {
207
264
  spinner?.error(`The ${budgetLabel} wait timer expired`);
208
265
  deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
209
266
  deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
210
- deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
267
+ deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (steps: ${setupUrl}).`);
211
268
  const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
212
269
  if (!keepWaiting) {
213
270
  deps.io.out('Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.');
214
271
  return 'pending';
215
272
  }
216
- spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`);
273
+ spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`);
217
274
  deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
218
275
  }
219
276
  }
277
+ /**
278
+ * The first notification a User ever receives from Notifai.
279
+ *
280
+ * It is the payoff of the whole setup, and it used to be written as an
281
+ * internal receipt: "This real notification completed setup verification" — a
282
+ * sentence about the check rather than about them — delivered silently, at
283
+ * `passive`, with the sound turned off. So the one arrival that proves their
284
+ * agents can reach them landed in Notification Center with no banner and no
285
+ * sound, where a first-time User has no reason to look and every reason to
286
+ * conclude that nothing came.
287
+ *
288
+ * It now says what is true for them and arrives the way their notifications
289
+ * will. `done` is the honest kind — a body of work finished successfully — and
290
+ * it is also what lets the server pick the sound through its own kind, Project
291
+ * and Account layers instead of this command stamping one and skipping them.
292
+ */
220
293
  function setupProofDraft(config, device) {
221
294
  const project = config.project.value;
222
295
  return buildDraft(config, {
223
- title: 'Notifai is ready',
296
+ title: 'Your agents can reach you',
224
297
  body: project === null
225
- ? 'This real notification completed setup verification.'
226
- : `This real notification completed setup verification for ${project}.`,
227
- kind: 'update',
298
+ ? 'Notifai setup is finished. Notifications from your agents will arrive like this one.'
299
+ : `Notifai setup is finished for ${project}. Notifications from your agents will arrive like this one.`,
300
+ kind: 'done',
228
301
  platform: device.platform,
229
302
  device: [device.device_id],
230
- sound: 'none',
303
+ // Noticeable, not intrusive: a normal banner and sound. Deliberately not
304
+ // `time_sensitive` — a setup confirmation has no business breaking a Focus.
305
+ //
231
306
  // Android has no caller-selected interruption level. Explicit null keeps an
232
307
  // Apple preference in config from leaking into this Android-only proof.
233
- level: device.platform === 'ios' ? 'passive' : null,
308
+ level: device.platform === 'ios' ? 'active' : null,
234
309
  collapseKey: 'notifai-setup-verification',
235
310
  });
236
311
  }
@@ -395,7 +470,7 @@ async function runSetupProof(deps) {
395
470
  }
396
471
  /** Closing a local gap cannot have changed the service, the keychain, or devices. */
397
472
  function refreshAfterClose(id) {
398
- return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
473
+ return id === 'project' || id === 'project-enablement' || id === 'hooks' || id.startsWith('hooks-') || id === 'skill' || id === 'question-routing-settings'
399
474
  ? ['local']
400
475
  : undefined;
401
476
  }
@@ -412,7 +487,12 @@ function wantsOptional(deps, state, flags) {
412
487
  return Promise.resolve(true);
413
488
  if (state.id === 'project-enablement')
414
489
  return Promise.resolve(true);
415
- const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
490
+ // Every other hook sub-state is a report line about routing, not an errand
491
+ // with a yes/no question of its own; offering the hooks question for one of
492
+ // them would ask about something the answer does not change.
493
+ if (state.id !== 'hooks' && state.id !== 'skill')
494
+ return Promise.resolve(false);
495
+ const explicit = state.id === 'hooks' ? flags.hooks : flags.skills;
416
496
  if (explicit !== undefined)
417
497
  return Promise.resolve(explicit);
418
498
  // An agent is never asked, and never assumed into a change it did not
@@ -424,26 +504,19 @@ function wantsOptional(deps, state, flags) {
424
504
  : 'Install/update the agent guidance skill through the native npx skills flow?';
425
505
  return deps.io.confirm(question, true);
426
506
  }
427
- /**
428
- * Setup as one step at a time.
429
- *
430
- * The old version ran five steps in a fixed order and ended with a list of
431
- * everything still outstanding. That is a report, and a report is the wrong
432
- * output here: someone handed five things to do does none of them, and the
433
- * order was the script's rather than the dependency graph's — it offered to
434
- * install hooks after a sign-in that had just failed.
435
- *
436
- * So this closes what it can, then surfaces exactly one thing, the first that
437
- * stands in the way. Re-running advances by one. Idempotence stops being a
438
- * property to preserve and becomes the mechanism: every decision is derived
439
- * from observed state, so a partial run, a second project, a fresh worktree
440
- * and a revoked credential are the same code path arriving at different
441
- * states rather than four branches to enumerate.
442
- */
507
+ /** The two states whose remedy actually places files the scope question is about. */
508
+ function needsInstallScope(state) {
509
+ return state.id === 'hooks' || state.id === 'skill';
510
+ }
443
511
  function isSetupScope(value) {
444
512
  return value === 'project' || value === 'global';
445
513
  }
446
- async function resolveSetupScope(deps, flags) {
514
+ /**
515
+ * Scope flags are checked before anything runs, because a contradiction is a
516
+ * usage error and reporting it after half a setup is worse than reporting it
517
+ * now. Answering the question is a separate step (`scopeForLocalInstall`).
518
+ */
519
+ function checkSetupScopeFlags(deps, flags) {
447
520
  if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
448
521
  deps.io.err('Invalid setup scope. Choose `project` or `global`.');
449
522
  return 'usage';
@@ -462,11 +535,35 @@ async function resolveSetupScope(deps, flags) {
462
535
  deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.');
463
536
  return 'usage';
464
537
  }
538
+ const fromFlags = flags.setupScope ?? flags.skillsScope;
539
+ // Asking for an install nobody can be asked about is a usage error, and it
540
+ // is knowable from the flags alone — so it is reported before a setup runs
541
+ // half way rather than after.
542
+ if (fromFlags === undefined &&
543
+ deps.io.interactive !== true &&
544
+ (flags.skills === true || flags.hooks === true)) {
545
+ deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
546
+ return 'usage';
547
+ }
548
+ return fromFlags;
549
+ }
550
+ /**
551
+ * Where a skill or hook install should land, asked at the moment one is about
552
+ * to happen and not before.
553
+ *
554
+ * It used to be the first thing the product ever said, in front of the
555
+ * assessment, sign-in and every other gate — a question about skill, hook and
556
+ * shared-config placement put to someone who had met none of those words and
557
+ * might never reach the step it decides. Worse, project identity and Project
558
+ * Enablement were gated on the answer, so an unattended run that could not
559
+ * produce one advanced nothing at all.
560
+ *
561
+ * Now nothing depends on it except the two installs it actually describes.
562
+ */
563
+ async function scopeForLocalInstall(deps, flags) {
465
564
  const fromFlags = flags.setupScope ?? flags.skillsScope;
466
565
  if (fromFlags !== undefined)
467
566
  return fromFlags;
468
- if (flags.skills === false && flags.hooks === false)
469
- return undefined;
470
567
  if (deps.io.interactive === true && deps.io.select) {
471
568
  const selected = await deps.io.select('Should this Notifai setup apply to this project only, or to every project on this machine?', [
472
569
  {
@@ -483,14 +580,27 @@ async function resolveSetupScope(deps, flags) {
483
580
  if (selected === 'project' || selected === 'global')
484
581
  return selected;
485
582
  deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.');
486
- return 'usage';
487
- }
488
- if (flags.skills === true || flags.hooks === true) {
489
- deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
490
- return 'usage';
583
+ return 'declined';
491
584
  }
492
- return undefined;
585
+ deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
586
+ return 'declined';
493
587
  }
588
+ /**
589
+ * Setup as one step at a time.
590
+ *
591
+ * The old version ran five steps in a fixed order and ended with a list of
592
+ * everything still outstanding. That is a report, and a report is the wrong
593
+ * output here: someone handed five things to do does none of them, and the
594
+ * order was the script's rather than the dependency graph's — it offered to
595
+ * install hooks after a sign-in that had just failed.
596
+ *
597
+ * So this closes what it can, then surfaces exactly one thing, the first that
598
+ * stands in the way. Re-running advances by one. Idempotence stops being a
599
+ * property to preserve and becomes the mechanism: every decision is derived
600
+ * from observed state, so a partial run, a second project, a fresh worktree
601
+ * and a revoked credential are the same code path arriving at different
602
+ * states rather than four branches to enumerate.
603
+ */
494
604
  export async function initCommand(deps, flags) {
495
605
  const workingDeps = flags.json === true
496
606
  ? {
@@ -504,12 +614,14 @@ export async function initCommand(deps, flags) {
504
614
  },
505
615
  }
506
616
  : deps;
507
- const setupScope = await resolveSetupScope(workingDeps, flags);
508
- if (setupScope === 'usage')
617
+ const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags);
618
+ if (scopeFromFlags === 'usage')
509
619
  return EXIT.usage;
510
- const resolved = {
620
+ let resolved = {
511
621
  ...flags,
512
- ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
622
+ ...(scopeFromFlags === undefined
623
+ ? {}
624
+ : { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
513
625
  };
514
626
  await workingDeps.io.intro?.('Notifai setup');
515
627
  const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope };
@@ -520,6 +632,11 @@ export async function initCommand(deps, flags) {
520
632
  ...(refresh === undefined ? {} : { previous: readiness, refresh }),
521
633
  });
522
634
  let failed = false;
635
+ // What actually stopped a sign-in, when something did. Without it the close
636
+ // renders the state as it was before the attempt — "not paired … run
637
+ // `notifai init`" — which contradicts the correct line printed moments
638
+ // earlier and sends the reader back into the command that just failed.
639
+ let loginBlocker = null;
523
640
  const attempted = new Set();
524
641
  // Re-assess after every successful action. This is how a browser approval or
525
642
  // companion registration can unlock the next state while the user is still
@@ -530,24 +647,37 @@ export async function initCommand(deps, flags) {
530
647
  for (const state of readiness.states) {
531
648
  if (state.status === 'ready')
532
649
  continue;
650
+ // Question Routing is optional automation, so it is reported and never
651
+ // waited on. `doctor` still fails on a broken route; `init` walks past
652
+ // it, because a hook diagnostic standing in front of the delivery proof
653
+ // means a setup that can already send never proves that it can.
654
+ const optional = isOptionalSetup(state.id);
655
+ const halt = () => {
656
+ if (!optional)
657
+ stop = true;
658
+ return !optional;
659
+ };
533
660
  if (state.status === 'unknown') {
534
- stop = true;
535
- break;
661
+ if (halt())
662
+ break;
663
+ continue;
536
664
  }
537
665
  const remedy = state.remedy;
538
666
  if (remedy === undefined || attempted.has(state.id)) {
539
- if (state.status === 'gap')
540
- stop = true;
541
- if (stop)
667
+ if (state.status === 'gap' && halt())
542
668
  break;
543
669
  continue;
544
670
  }
545
671
  if (state.status === 'optional-gap') {
546
672
  if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved)))
547
673
  continue;
548
- if (resolved.setupScope === undefined && state.id === 'project') {
549
- stop = true;
550
- break;
674
+ if (needsInstallScope(state)) {
675
+ const scope = await scopeForLocalInstall(workingDeps, resolved);
676
+ if (scope === 'declined') {
677
+ attempted.add(state.id);
678
+ continue;
679
+ }
680
+ resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope };
551
681
  }
552
682
  attempted.add(state.id);
553
683
  const result = await closeGap(workingDeps, state, resolved);
@@ -559,25 +689,33 @@ export async function initCommand(deps, flags) {
559
689
  break;
560
690
  }
561
691
  if (result !== 'closed') {
562
- stop = true;
563
- break;
692
+ if (halt())
693
+ break;
694
+ continue;
564
695
  }
565
696
  readiness = await reassess(refreshAfterClose(state.id));
566
697
  advanced = true;
567
698
  break;
568
699
  }
569
700
  if (remedy.by === 'cli') {
570
- if (resolved.setupScope === undefined && state.id === 'project') {
571
- stop = true;
572
- break;
701
+ if (needsInstallScope(state)) {
702
+ const scope = await scopeForLocalInstall(workingDeps, resolved);
703
+ if (scope === 'declined') {
704
+ attempted.add(state.id);
705
+ if (halt())
706
+ break;
707
+ continue;
708
+ }
709
+ resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope };
573
710
  }
574
711
  attempted.add(state.id);
575
712
  const result = await closeGap(workingDeps, state, resolved);
576
713
  if (result === 'failed')
577
714
  failed = true;
578
715
  if (result !== 'closed') {
579
- stop = true;
580
- break;
716
+ if (halt())
717
+ break;
718
+ continue;
581
719
  }
582
720
  readiness = await reassess(refreshAfterClose(state.id));
583
721
  advanced = true;
@@ -590,7 +728,7 @@ export async function initCommand(deps, flags) {
590
728
  workingDeps.io.interactive === true) {
591
729
  attempted.add(state.id);
592
730
  workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
593
- if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
731
+ if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
594
732
  failed = true;
595
733
  stop = true;
596
734
  break;
@@ -615,50 +753,68 @@ export async function initCommand(deps, flags) {
615
753
  break;
616
754
  }
617
755
  // A human-only remedy is the first blocker for an unattended agent.
618
- stop = true;
619
- break;
756
+ if (halt())
757
+ break;
758
+ continue;
620
759
  }
621
760
  if (stop || !advanced)
622
761
  break;
623
762
  }
763
+ const blocker = loginBlocker ?? firstRequiredBlocker(readiness);
624
764
  if (flags.json === true)
625
765
  deps.io.out(JSON.stringify(readinessJson(readiness), null, 2));
626
766
  else
627
- await printInitClose(deps, readiness, resolved);
628
- const blocker = firstBlocker(readiness);
767
+ await printInitClose(deps, readiness, resolved, loginBlocker);
629
768
  if (blocker === null)
630
769
  return failed ? EXIT.failed : EXIT.ok;
631
770
  return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok;
632
771
  }
633
- function isHookSubstate(id) {
634
- return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
635
- }
636
772
  function leftoverOptionals(readiness, flags) {
637
773
  return readiness.states.filter((state) => {
638
774
  if (state.status !== 'optional-gap')
639
775
  return false;
640
- if (isHookSubstate(state.id))
776
+ if (isOptionalAutomation(state.id))
777
+ return false;
778
+ // Not an install anyone declined, so "Optional, not set up" would misread
779
+ // it; it gets its own line below.
780
+ if (state.id === 'cli-bin')
641
781
  return false;
642
782
  if (state.id === 'skill' && flags.skills === false)
643
783
  return false;
644
784
  return true;
645
785
  });
646
786
  }
787
+ /**
788
+ * A `notifai` the reader cannot type.
789
+ *
790
+ * Said on the way out of every run, successful or not, because every next step
791
+ * this command prints — here, in the skill, in the README — names a command
792
+ * that will not be found until it is fixed.
793
+ */
794
+ function printMissingCliBin(deps, readiness) {
795
+ const state = readiness.states.find((candidate) => candidate.id === 'cli-bin');
796
+ if (state?.status !== 'optional-gap')
797
+ return;
798
+ deps.io.out(`Heads up: ${state.detail}. ${remedyLine(state)}`);
799
+ }
647
800
  function printOptionalLeftovers(deps, leftovers) {
648
801
  for (const state of leftovers) {
649
802
  deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
650
803
  }
651
804
  }
652
- async function printInitClose(deps, readiness, flags) {
653
- const blocker = firstBlocker(readiness);
805
+ async function printInitClose(deps, readiness, flags, stoppedBy = null) {
806
+ const blocker = stoppedBy ?? firstRequiredBlocker(readiness);
654
807
  const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
655
808
  const questions = questionRoutingReady(readiness);
656
809
  const leftovers = leftoverOptionals(readiness, flags).filter((state) => state.id !== 'contract');
657
810
  if (blocker?.id === 'contract') {
658
811
  deps.io.out(blocker.detail);
659
- deps.io.out(UPDATE_CLI_COMMAND);
812
+ deps.io.out(blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.command !== undefined
813
+ ? blocker.remedy.command
814
+ : updateCliCommand(deps));
660
815
  return;
661
816
  }
817
+ printMissingCliBin(deps, readiness);
662
818
  if (deps.io.interactive === true) {
663
819
  if (blocker === null) {
664
820
  const lines = [
@@ -675,11 +831,13 @@ async function printInitClose(deps, readiness, flags) {
675
831
  await deps.io.outro?.('All set ✨');
676
832
  return;
677
833
  }
678
- await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
834
+ // No framed copy of what the next three lines already say. The "Ready"
835
+ // note above adds something the close does not; this one only made a
836
+ // stopped visit state its one step twice, box then text.
679
837
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
680
838
  deps.io.out(` ${remedyLine(blocker)}`);
681
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
682
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
839
+ if (shouldRestateInit(blocker)) {
840
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`);
683
841
  }
684
842
  await deps.io.outro?.('One step remains (above)');
685
843
  return;
@@ -693,8 +851,13 @@ async function printInitClose(deps, readiness, flags) {
693
851
  }
694
852
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
695
853
  deps.io.out(` ${remedyLine(blocker)}`);
696
- if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
697
- deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
854
+ if (shouldRestateInit(blocker)) {
855
+ deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`);
698
856
  }
699
857
  }
858
+ function shouldRestateInit(blocker) {
859
+ if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here')
860
+ return false;
861
+ return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND;
862
+ }
700
863
  //# sourceMappingURL=commands-init.js.map