@springbrand/cli 0.1.4 → 0.1.5-alpha.2

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 (78) hide show
  1. package/README.md +13 -22
  2. package/dist/commands/connect.d.ts +4 -2
  3. package/dist/commands/connect.d.ts.map +1 -1
  4. package/dist/commands/connect.js +184 -64
  5. package/dist/commands/connect.js.map +1 -1
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +24 -4
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/index.js +0 -0
  10. package/dist/lib/auth.d.ts +1 -1
  11. package/dist/lib/auth.d.ts.map +1 -1
  12. package/dist/lib/auth.js +2 -2
  13. package/dist/lib/auth.js.map +1 -1
  14. package/dist/lib/command-code-config.d.ts +1 -1
  15. package/dist/lib/command-code-config.d.ts.map +1 -1
  16. package/dist/lib/command-code-config.js +3 -3
  17. package/dist/lib/command-code-config.js.map +1 -1
  18. package/dist/lib/connect-all-summary.d.ts.map +1 -1
  19. package/dist/lib/connect-all-summary.js +8 -2
  20. package/dist/lib/connect-all-summary.js.map +1 -1
  21. package/dist/lib/connect-all.d.ts +3 -1
  22. package/dist/lib/connect-all.d.ts.map +1 -1
  23. package/dist/lib/connect-all.js +14 -6
  24. package/dist/lib/connect-all.js.map +1 -1
  25. package/dist/lib/connect-auth.d.ts +4 -3
  26. package/dist/lib/connect-auth.d.ts.map +1 -1
  27. package/dist/lib/connect-auth.js +14 -48
  28. package/dist/lib/connect-auth.js.map +1 -1
  29. package/dist/lib/connect-preflight.d.ts +1 -0
  30. package/dist/lib/connect-preflight.d.ts.map +1 -1
  31. package/dist/lib/connect-preflight.js +12 -7
  32. package/dist/lib/connect-preflight.js.map +1 -1
  33. package/dist/lib/connect-result.d.ts +135 -0
  34. package/dist/lib/connect-result.d.ts.map +1 -0
  35. package/dist/lib/connect-result.js +146 -0
  36. package/dist/lib/connect-result.js.map +1 -0
  37. package/dist/lib/connect-runtime.d.ts +1 -1
  38. package/dist/lib/connect-runtime.d.ts.map +1 -1
  39. package/dist/lib/connect-runtime.js +4 -2
  40. package/dist/lib/connect-runtime.js.map +1 -1
  41. package/dist/lib/cursor-config.d.ts +1 -1
  42. package/dist/lib/cursor-config.d.ts.map +1 -1
  43. package/dist/lib/cursor-config.js +3 -3
  44. package/dist/lib/cursor-config.js.map +1 -1
  45. package/dist/lib/doctor-checks.d.ts.map +1 -1
  46. package/dist/lib/doctor-checks.js +2 -4
  47. package/dist/lib/doctor-checks.js.map +1 -1
  48. package/dist/lib/doctor-machine.d.ts.map +1 -1
  49. package/dist/lib/doctor-machine.js +10 -2
  50. package/dist/lib/doctor-machine.js.map +1 -1
  51. package/dist/lib/doctor-repairs.d.ts.map +1 -1
  52. package/dist/lib/doctor-repairs.js +7 -8
  53. package/dist/lib/doctor-repairs.js.map +1 -1
  54. package/dist/lib/doctor-report.d.ts +152 -0
  55. package/dist/lib/doctor-report.d.ts.map +1 -1
  56. package/dist/lib/doctor-report.js +4 -1
  57. package/dist/lib/doctor-report.js.map +1 -1
  58. package/dist/lib/harness-spec.d.ts +2 -0
  59. package/dist/lib/harness-spec.d.ts.map +1 -1
  60. package/dist/lib/harness-spec.js +23 -7
  61. package/dist/lib/harness-spec.js.map +1 -1
  62. package/dist/lib/omp-config.d.ts +1 -1
  63. package/dist/lib/omp-config.d.ts.map +1 -1
  64. package/dist/lib/omp-config.js +3 -3
  65. package/dist/lib/omp-config.js.map +1 -1
  66. package/dist/lib/pi-config.d.ts +1 -1
  67. package/dist/lib/pi-config.d.ts.map +1 -1
  68. package/dist/lib/pi-config.js +3 -3
  69. package/dist/lib/pi-config.js.map +1 -1
  70. package/dist/lib/registration-detect.d.ts +2 -2
  71. package/dist/lib/registration-detect.d.ts.map +1 -1
  72. package/dist/lib/registration-detect.js +60 -43
  73. package/dist/lib/registration-detect.js.map +1 -1
  74. package/dist/lib/workbuddy-config.d.ts +1 -1
  75. package/dist/lib/workbuddy-config.d.ts.map +1 -1
  76. package/dist/lib/workbuddy-config.js +3 -3
  77. package/dist/lib/workbuddy-config.js.map +1 -1
  78. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  /** 复刻自 tiny-fish CLI 编译产物:connect.d.ts + connect.impl.js */
2
2
  import spawn from 'cross-spawn';
3
- import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
3
+ import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, } from '../lib/auth.js';
4
4
  import { CURSOR_SKILL_TARGET, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchPiWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
5
5
  import { captureStdio, capturedOutput, installSpringBrandCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
6
- import { ensureCliAuthenticated } from '../lib/connect-auth.js';
6
+ import { connectApiKey, ensureCliAuthenticated } from '../lib/connect-auth.js';
7
7
  import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
8
8
  import { installWebSkill, removeWebSkill } from '../lib/skill-install.js';
9
9
  import { ConnectInterruptedError, ConnectStepError, HoistedFailureReportedError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
@@ -16,12 +16,14 @@ import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '
16
16
  import { detectHumanInitiated } from '../lib/harness.js';
17
17
  import { SPRINGBRAND_API_KEY_VAR } from '../lib/constants.js';
18
18
  import { emitNotice } from '../lib/notice.js';
19
- import { detectLegacyPlugin, legacyPluginHint, SIGN_IN_PROBES } from '../lib/registration-detect.js';
20
- import { errLine, warnLine } from '../lib/output.js';
19
+ import { detectLegacyPlugin, detectRegistrations, legacyPluginHint, SIGN_IN_PROBES } from '../lib/registration-detect.js';
20
+ import { errLine, out, warnLine } from '../lib/output.js';
21
21
  import { z } from 'zod';
22
- import { verifyMcpAuth } from '../lib/verify.js';
22
+ import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
23
23
  import { gateApiKey } from '../lib/connect-preflight.js';
24
- import { apiBaseFromMcpUrl, DEFAULT_MCP_URL, mcpServerName } from '../lib/mcp-endpoint.js';
24
+ import { apiBaseFromMcpUrl, DEFAULT_MCP_URL, endpointOf, mcpServerName } from '../lib/mcp-endpoint.js';
25
+ import { harnessOauthAction } from '../lib/harness-spec.js';
26
+ import { buildConnectResult } from '../lib/connect-result.js';
25
27
  function removeExistingRegistration(client, removal, verbose) {
26
28
  const result = spawnRemoval(client.command, removal.args);
27
29
  if (result.status === 0)
@@ -42,9 +44,7 @@ function exportApiKey(apiKey, displayName, deferOutro) {
42
44
  persistApiKeyToEnvironment(apiKey);
43
45
  }
44
46
  catch (error) {
45
- errLine(`Could not export SPRINGBRAND_API_KEY (${error instanceof Error ? error.message : String(error)}); ` +
46
- 'signing in instead.');
47
- return false;
47
+ throw new ConnectStepError('Could not persist the API-key environment variable. OAuth was not started.', 'invalid_config');
48
48
  }
49
49
  process.env[SPRINGBRAND_API_KEY_VAR] = apiKey;
50
50
  if (deferOutro) {
@@ -165,7 +165,9 @@ async function runMissingHarnessFallback(displayName, connectClient, state, tele
165
165
  /** Resolved before the removals, so a refusal strands no registration. */
166
166
  async function prepareInstallAuth(client, options, keyAuthSupported) {
167
167
  // `/mcp` accepts the key as `Authorization: Bearer`, so a stored key replaces the browser OAuth hop.
168
- const storedKey = validatedApiKey(options.apiKey);
168
+ const storedKey = connectApiKey(options.apiKey);
169
+ if (storedKey && (!keyAuthSupported || !client.keyAuth))
170
+ throw new ConnectStepError('This Host version cannot install API-key authentication. Update the Host and retry with the same key.', 'key_auth_unsupported');
169
171
  const candidate = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
170
172
  // Direct seeded clients need their own pre-write probe. Codex and other env-backed clients
171
173
  // have already passed the shared connect preflight before this function is reached.
@@ -183,7 +185,7 @@ async function prepareInstallAuth(client, options, keyAuthSupported) {
183
185
  /** Inherit for an inline-OAuth add; capture otherwise, piping prompts when seeded. */
184
186
  function addSpawnOptions(options, interactiveAdd, seeded) {
185
187
  if (interactiveAdd)
186
- return { stdio: 'inherit', timeout: options.authTimeoutMs };
188
+ return { stdio: ['inherit', 2, 2], timeout: options.authTimeoutMs ?? 60_000 };
187
189
  if (!seeded)
188
190
  return captureStdio(options.verbose ?? false);
189
191
  return {
@@ -207,7 +209,7 @@ function performMcpAdd(client, options, plan, attemptId) {
207
209
  // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
208
210
  const addArgs = buildAddArgs(mcpUrl.toString());
209
211
  // Capturing an inline-OAuth add hides the sign-in URL and hangs.
210
- const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
212
+ const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd || options.deferOAuth);
211
213
  // After the removals: an interrupt there leaves no key behind.
212
214
  const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
213
215
  // Writing the entry ourselves skips the harness probe that saves it disabled.
@@ -237,8 +239,8 @@ function performMcpAdd(client, options, plan, attemptId) {
237
239
  function performLoginStep(client, pendingLogin, options) {
238
240
  errLine('Signing in to SpringBrand...');
239
241
  const loginResult = spawn.sync(client.command, [...pendingLogin], {
240
- stdio: 'inherit',
241
- timeout: options.authTimeoutMs,
242
+ stdio: ['inherit', 2, 2],
243
+ timeout: options.authTimeoutMs ?? 60_000,
242
244
  });
243
245
  if (loginResult.error || loginResult.status !== 0) {
244
246
  throw signInStepError(`Could not authenticate SpringBrand in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
@@ -256,17 +258,20 @@ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
256
258
  errLine(degradedNotes.deferred);
257
259
  }
258
260
  }
261
+ function loginArgsFor(client, mcpUrl) {
262
+ return client.loginArgs?.map((arg) => arg === 'springbrand' ? mcpServerName(mcpUrl) : arg);
263
+ }
259
264
  function completeAuthFlow(client, options, state, telemetry, ctx) {
260
265
  const { useKeyAuth, loginDegraded, loginArgs, probeAuthenticated } = ctx;
261
266
  // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
262
267
  // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
263
- const addSignedIn = probeAuthenticated?.();
268
+ const addSignedIn = useKeyAuth ? undefined : probeAuthenticated?.();
264
269
  if (addSignedIn)
265
270
  telemetry.track('checkpoint', { phase: 'oauth_done' });
266
271
  announceAuthNotes(useKeyAuth, loginDegraded ? client.degradedAuthNotes : undefined, ctx.seededNote);
267
272
  // A probed client signs in again only when its inline OAuth is known to have been abandoned.
268
- const retryLogin = probeAuthenticated ? addSignedIn === false : true;
269
- const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
273
+ const retryLogin = addSignedIn !== true;
274
+ const pendingLogin = !useKeyAuth && !options.deferOAuth && retryLogin ? loginArgs : undefined;
270
275
  let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
271
276
  if (pendingLogin) {
272
277
  state.stage = 'client_oauth';
@@ -286,7 +291,8 @@ function completeAuthFlow(client, options, state, telemetry, ctx) {
286
291
  async function connectNativeMcpClient(client, options) {
287
292
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
288
293
  const state = { stage: 'prerequisite_check', settled: false };
289
- const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
294
+ const nativeProbe = SIGN_IN_PROBES[client.connectClient];
295
+ const probeAuthenticated = nativeProbe ? () => nativeProbe(options.mcpUrl) : undefined;
290
296
  await runGuarded(state, telemetry, async () => {
291
297
  telemetry.track('started');
292
298
  const support = await requireSupportOrRescue(client, client.connectClient, state, telemetry, options);
@@ -318,7 +324,7 @@ async function connectNativeMcpClient(client, options) {
318
324
  try {
319
325
  client.pluginInstall({
320
326
  mcpUrl: pluginUrl.toString(),
321
- apiKey: useKeyAuth ? validatedApiKey(options.apiKey) : undefined,
327
+ apiKey: useKeyAuth ? connectApiKey(options.apiKey) : undefined,
322
328
  });
323
329
  }
324
330
  catch (error) {
@@ -345,7 +351,7 @@ async function connectNativeMcpClient(client, options) {
345
351
  useKeyAuth,
346
352
  loginDegraded,
347
353
  // The Plugin names its server after the endpoint's environment.
348
- loginArgs: loginDegraded ? undefined : ['mcp', 'login', mcpServerName(options.mcpUrl)],
354
+ loginArgs: loginDegraded ? undefined : loginArgsFor(client, options.mcpUrl),
349
355
  probeAuthenticated,
350
356
  seededNote: undefined,
351
357
  });
@@ -360,7 +366,7 @@ async function connectNativeMcpClient(client, options) {
360
366
  removeExistingRegistration(client, scopedRemoval(removal, options.mcpUrl), options.verbose ?? false);
361
367
  // Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
362
368
  const loginDegraded = state.harnessDegraded === true;
363
- const loginArgs = loginDegraded ? undefined : client.loginArgs;
369
+ const loginArgs = loginDegraded ? undefined : loginArgsFor(client, options.mcpUrl);
364
370
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
365
371
  state.stage =
366
372
  loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
@@ -380,15 +386,18 @@ async function connectNativeMcpClient(client, options) {
380
386
  });
381
387
  return state.authMode;
382
388
  }
383
- function connectedLine(client, signInDeferred) {
389
+ function connectedLine(client, signInDeferred, mcpUrl) {
384
390
  // The CLI never observes deferred auth, so don't claim "connected".
385
391
  if (signInDeferred) {
392
+ const action = harnessOauthAction(client.connectClient, mcpUrl);
393
+ if (action)
394
+ return `SpringBrand is configured in ${client.displayName}, but authentication is pending. ${action}`;
386
395
  if (client.signInHint)
387
396
  return client.signInHint;
388
397
  return (`SpringBrand is configured in ${client.displayName}. ${client.displayName} will ask you ` +
389
398
  'to sign in to SpringBrand the first time you use it.');
390
399
  }
391
- return (`SpringBrand is connected. Open ${client.displayName}; if it is already running, ` +
400
+ return (`SpringBrand configuration is installed. Open ${client.displayName}; if it is already running, ` +
392
401
  `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
393
402
  }
394
403
  // Runs after `completed` settled: steps fail alone, the rest still run.
@@ -421,7 +430,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
421
430
  if (extraPostInstall) {
422
431
  await attempt('plugin_install', () => {
423
432
  extraPostInstall({
424
- apiKey: validatedApiKey(options.apiKey),
433
+ apiKey: connectApiKey(options.apiKey),
425
434
  verbose: options.verbose ?? false,
426
435
  seededHome,
427
436
  });
@@ -432,7 +441,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
432
441
  });
433
442
  }
434
443
  // Before auth: a failed sign-in must not lose the harness record.
435
- saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
444
+ saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode, options.mcpUrl);
436
445
  if (cliAuthRequired) {
437
446
  await attempt('authentication', () => {
438
447
  // A set timeout means headless; never shell an interactive login.
@@ -448,13 +457,18 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
448
457
  if (!options.launch) {
449
458
  // Under --all the summary replaces this; sign-in hints still print.
450
459
  if (signInDeferred || !options.deferOutro)
451
- errLine(connectedLine(client, signInDeferred));
460
+ errLine(connectedLine(client, signInDeferred, options.mcpUrl));
452
461
  }
453
462
  else {
454
463
  await attempt('walkthrough_launch', async () => {
455
464
  // Before the handover, or the only sign-in instructions scroll past under the agent.
456
- if (signInDeferred && client.signInHint)
457
- errLine(client.signInHint);
465
+ if (signInDeferred) {
466
+ const action = harnessOauthAction(client.connectClient, options.mcpUrl);
467
+ if (action)
468
+ errLine(action);
469
+ else if (client.signInHint)
470
+ errLine(client.signInHint);
471
+ }
458
472
  await launchNativeMcpClient(client, async (outcome) => {
459
473
  telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
460
474
  // Flushed pre-block; the session-long handover would strand the delivery.
@@ -470,7 +484,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
470
484
  failed = true;
471
485
  }
472
486
  if (failed) {
473
- errLine(finishSetupHint(client.connectClient));
487
+ errLine(finishSetupHint(client.connectClient, options.mcpUrl));
474
488
  options.onPostInstallFailed?.();
475
489
  }
476
490
  }
@@ -502,8 +516,8 @@ function runCliInstallStep(options, telemetry) {
502
516
  function walkthroughPhase(outcome) {
503
517
  return outcome === 'printed' ? 'walkthrough_prompt_printed' : 'walkthrough_launched';
504
518
  }
505
- function finishSetupHint(connectClient) {
506
- return `Finish setup with: ${finishSetupCommand(connectClient)}`;
519
+ function finishSetupHint(connectClient, mcpUrl) {
520
+ return `Finish setup with: ${finishSetupCommand(connectClient, mcpUrl)}`;
507
521
  }
508
522
  /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
509
523
  function trackPostInstallFailure(displayName, state, telemetry, error) {
@@ -530,11 +544,15 @@ export async function connectOpenClaw(options) {
530
544
  settled: false,
531
545
  harnessDegraded: false,
532
546
  };
547
+ let authMode;
533
548
  await runGuarded(state, telemetry, async () => {
534
549
  telemetry.track('started');
535
550
  const support = await requireSupportOrRescue(OPENCLAW, 'openclaw', state, telemetry, options);
536
551
  if (!support)
537
552
  return;
553
+ if (!connectApiKey(options.apiKey)) {
554
+ throw new ConnectStepError('OpenClaw requires a SpringBrand API key; keyless OAuth is not supported by this Host.', 'key_auth_unsupported');
555
+ }
538
556
  telemetry.setHarnessVersion(support.harnessVersion);
539
557
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
540
558
  if (options.cliInstall !== false) {
@@ -558,6 +576,7 @@ export async function connectOpenClaw(options) {
558
576
  interactiveLogin: options.authTimeoutMs === undefined,
559
577
  mcpUrl: options.mcpUrl,
560
578
  });
579
+ authMode = AuthMode.ApiKey;
561
580
  telemetry.track('checkpoint', { phase: 'authenticated' });
562
581
  saveConnectContext('openclaw', telemetry.attemptId);
563
582
  // For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
@@ -580,9 +599,10 @@ export async function connectOpenClaw(options) {
580
599
  }
581
600
  catch (error) {
582
601
  trackPostInstallFailure('OpenClaw', state, telemetry, error);
583
- errLine(finishSetupHint('openclaw'));
602
+ errLine(finishSetupHint('openclaw', options.mcpUrl));
584
603
  }
585
604
  });
605
+ return authMode;
586
606
  }
587
607
  const PI_RELOAD_COPY = 'SpringBrand is configured in pi. Restart pi, then run `/mcp-auth springbrand` there if it asks you ' +
588
608
  'to sign in.';
@@ -620,11 +640,11 @@ const CONFIG_FILE_HARNESSES = {
620
640
  configPath: cursorMcpPath,
621
641
  tryLaunch: launchCursorDeeplink,
622
642
  copy: {
623
- verified: "SpringBrand is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.",
643
+ verified: "The API key was accepted; Host loading is still unverified. Reload the Cursor window (or restart cursor-agent) and it's live.",
624
644
  authFailed: (reason) => `SpringBrand is configured, but the authenticated check failed (${reason}). ` +
625
645
  'Fix: rotate/re-enter your key with `sp auth login`, then re-run: sp connect cursor',
626
646
  launched: 'Cursor should pop a SpringBrand install confirmation — approve it, then sign in when prompted.',
627
- reload: 'SpringBrand is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
647
+ reload: 'SpringBrand is configured. Reload the Cursor window, then approve/sign in under Settings → MCP.',
628
648
  },
629
649
  },
630
650
  omp: {
@@ -636,7 +656,7 @@ const CONFIG_FILE_HARNESSES = {
636
656
  // No install deeplink exists; the keyless copy carries the sign-in.
637
657
  tryLaunch: () => false,
638
658
  copy: {
639
- verified: "SpringBrand is connected and verified (health+auth). Restart omp and it's live.",
659
+ verified: "The API key was accepted; Host loading is still unverified. Restart omp and it's live.",
640
660
  authFailed: (reason) => `SpringBrand is configured, but the authenticated check failed (${reason}). ` +
641
661
  'Fix: rotate/re-enter your key with `sp auth login`, then re-run: sp connect omp',
642
662
  launched: OMP_KEYLESS_COPY,
@@ -652,7 +672,7 @@ const CONFIG_FILE_HARNESSES = {
652
672
  tryLaunch: () => false,
653
673
  postConnectNote: piPostConnectNotes,
654
674
  copy: {
655
- verified: "SpringBrand is connected and verified (health+auth). Restart pi and it's live.",
675
+ verified: "The API key was accepted; Host loading is still unverified. Restart pi and it's live.",
656
676
  authFailed: (reason) => `SpringBrand is configured, but the authenticated check failed (${reason}). ` +
657
677
  'Fix: rotate/re-enter your key with `sp auth login`, then re-run: sp connect pi',
658
678
  launched: PI_RELOAD_COPY,
@@ -666,7 +686,7 @@ const CONFIG_FILE_HARNESSES = {
666
686
  configPath: workbuddyMcpPath,
667
687
  tryLaunch: () => false,
668
688
  copy: {
669
- verified: "SpringBrand is connected and verified (health+auth). Restart WorkBuddy and it's live.",
689
+ verified: "The API key was accepted; Host loading is still unverified. Restart WorkBuddy and it's live.",
670
690
  authFailed: (reason) => `SpringBrand is configured, but the authenticated check failed (${reason}). ` +
671
691
  'Fix: rotate/re-enter your key with `sp auth login`, then re-run: sp connect workbuddy-ai',
672
692
  launched: 'SpringBrand is configured. Open WorkBuddy to load the MCP server.',
@@ -690,10 +710,10 @@ function installSkillPostSettle(spec, displayName, state, telemetry, options) {
690
710
  }
691
711
  }
692
712
  /** Prints last: the outro must own the final line. */
693
- function reportPostInstallFailure(harness, failed, onPostInstallFailed) {
713
+ function reportPostInstallFailure(harness, failed, mcpUrl, onPostInstallFailed) {
694
714
  if (!failed)
695
715
  return;
696
- errLine(finishSetupHint(harness));
716
+ errLine(finishSetupHint(harness, mcpUrl));
697
717
  onPostInstallFailed?.();
698
718
  }
699
719
  /** Writes the harness's MCP config file itself; no `mcp add` exists. */
@@ -737,12 +757,19 @@ async function connectConfigFileHarness(harness, options) {
737
757
  settled: false,
738
758
  harnessDegraded: false,
739
759
  };
760
+ let resolvedAuthMode;
740
761
  await runGuarded(state, telemetry, async () => {
741
762
  telemetry.track('started');
742
763
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
743
764
  // A key lets the config carry an Authorization header. With no key, leave the
744
765
  // client on its own native MCP OAuth path; never open the CLI API-key page.
745
- const resolvedKey = validatedApiKey(options.apiKey);
766
+ const rawKey = connectApiKey(options.apiKey);
767
+ if (rawKey && !validateKeyFormat(rawKey)) {
768
+ throw new ConnectStepError('The supplied SpringBrand API key is invalid; OAuth was not started.', 'invalid_config', {
769
+ failureDetail: 'key_rejected_before_oauth',
770
+ });
771
+ }
772
+ const resolvedKey = connectApiKey(options.apiKey);
746
773
  if (resolvedKey) {
747
774
  state.stage = 'authentication';
748
775
  ensureCliAuthenticated(harness, options.apiKey, {
@@ -751,10 +778,6 @@ async function connectConfigFileHarness(harness, options) {
751
778
  });
752
779
  telemetry.track('checkpoint', { phase: 'authenticated' });
753
780
  }
754
- // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
755
- if (!resolvedKey) {
756
- errLine('Ignoring the stored API key: invalid format. Run: sp auth login');
757
- }
758
781
  state.stage = 'registration';
759
782
  errLine(`Adding SpringBrand to ${displayName}...`);
760
783
  const mcpUrl = new URL(options.mcpUrl);
@@ -774,8 +797,9 @@ async function connectConfigFileHarness(harness, options) {
774
797
  errLine('Repaired the existing SpringBrand entry (updated auth/config to current).');
775
798
  }
776
799
  telemetry.track('checkpoint', { phase: 'registered' });
777
- saveConnectContext(harness, telemetry.attemptId);
778
- settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
800
+ saveConnectContext(harness, telemetry.attemptId, resolvedKey ? AuthMode.ApiKey : 'oauth', options.mcpUrl);
801
+ resolvedAuthMode = resolvedKey ? AuthMode.ApiKey : 'deferred';
802
+ settle(state, telemetry, 'completed', { authMode: resolvedAuthMode });
779
803
  const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
780
804
  if (resolvedKey) {
781
805
  // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
@@ -797,12 +821,13 @@ async function connectConfigFileHarness(harness, options) {
797
821
  const note = spec.postConnectNote?.();
798
822
  if (note)
799
823
  errLine(note);
800
- reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
824
+ reportPostInstallFailure(harness, postInstallFailed, options.mcpUrl, options.onPostInstallFailed);
801
825
  });
826
+ return resolvedAuthMode;
802
827
  }
803
828
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
804
829
  export async function connectCursor(options) {
805
- await connectConfigFileHarness('cursor', options);
830
+ return connectConfigFileHarness('cursor', options);
806
831
  }
807
832
  /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
808
833
  function launchCursorDeeplink(mcpUrl) {
@@ -831,19 +856,18 @@ export async function launchAgent(client) {
831
856
  /** Total over the non-native harnesses; native harnesses use the descriptor engine. */
832
857
  const CONNECTOR_OVERRIDES = {
833
858
  openclaw: async (options) => {
834
- await connectOpenClaw(options);
835
- return undefined;
859
+ return connectOpenClaw(options);
836
860
  },
837
861
  cursor: async (options) => {
838
- await connectCursor(options);
862
+ const authMode = await connectCursor(options);
839
863
  // `connect cursor --launch` still connects; it just cannot launch after.
840
864
  if (options.launch) {
841
865
  errLine('Cursor has no launchable walkthrough. Open Cursor and start chatting.');
842
866
  }
843
- return undefined;
867
+ return authMode;
844
868
  },
845
869
  omp: async (options) => {
846
- await connectConfigFileHarness('omp', options);
870
+ const authMode = await connectConfigFileHarness('omp', options);
847
871
  if (options.launch) {
848
872
  try {
849
873
  await launchOmpWalkthrough();
@@ -851,30 +875,30 @@ const CONNECTOR_OVERRIDES = {
851
875
  catch (error) {
852
876
  // Post-settle, like the native walkthrough step: never fail the connect.
853
877
  errLine(error instanceof Error ? error.message : String(error));
854
- errLine(finishSetupHint('omp'));
878
+ errLine(finishSetupHint('omp', options.mcpUrl));
855
879
  }
856
880
  }
857
- return undefined;
881
+ return authMode;
858
882
  },
859
883
  pi: async (options) => {
860
- await connectConfigFileHarness('pi', options);
884
+ const authMode = await connectConfigFileHarness('pi', options);
861
885
  if (options.launch) {
862
886
  try {
863
887
  await launchPiWalkthrough();
864
888
  }
865
889
  catch (error) {
866
890
  errLine(error instanceof Error ? error.message : String(error));
867
- errLine(finishSetupHint('pi'));
891
+ errLine(finishSetupHint('pi', options.mcpUrl));
868
892
  }
869
893
  }
870
- return undefined;
894
+ return authMode;
871
895
  },
872
896
  'workbuddy-ai': async (options) => {
873
- await connectConfigFileHarness('workbuddy-ai', options);
897
+ const authMode = await connectConfigFileHarness('workbuddy-ai', options);
874
898
  if (options.launch) {
875
899
  errLine('WorkBuddy has no launchable CLI walkthrough. Open WorkBuddy and start chatting.');
876
900
  }
877
- return undefined;
901
+ return authMode;
878
902
  },
879
903
  };
880
904
  function requireNative(harness) {
@@ -930,6 +954,62 @@ async function connectManyPath(options, mcpUrl, attemptId) {
930
954
  if (!process.exitCode)
931
955
  process.exitCode = exitCode;
932
956
  }
957
+ function safeConnectError(error) {
958
+ if (error instanceof ConnectStepError) {
959
+ if (error.message === 'unsupported')
960
+ return { code: 'unsupported', message: 'This Host is not supported by the CLI.', phase: 'input' };
961
+ const phase = error.failureReason === 'key_auth_unsupported' || error.failureReason === 'invalid_config'
962
+ ? 'authentication'
963
+ : error.failureReason === 'harness_command_unsupported' || error.failureReason === 'harness_too_old'
964
+ ? 'registration'
965
+ : 'registration';
966
+ return {
967
+ code: error.failureReason,
968
+ message: error.failureReason === 'key_auth_unsupported'
969
+ ? 'This Host does not support the requested authentication mode.'
970
+ : error.failureReason === 'invalid_config'
971
+ ? 'The Host configuration could not be updated. Follow the CLI recovery message and retry.'
972
+ : 'The Host could not be configured. Follow the CLI recovery message and retry.',
973
+ phase,
974
+ };
975
+ }
976
+ return { code: 'unexpected_error', message: 'The Host installation did not complete. Retry the connect command.', phase: 'registration' };
977
+ }
978
+ async function singleConnectResult(client, mcpUrl, authMode, installed, apiKey, error) {
979
+ const health = await verifyMcpHealth(mcpUrl);
980
+ const key = connectApiKey(apiKey);
981
+ // A preflight failure must never send the rejected or cross-environment key a
982
+ // second time merely to build a machine result.
983
+ const auth = !error && authMode === 'api-key' && key && validateKeyFormat(key)
984
+ ? await verifyMcpAuth(key, apiBaseFromMcpUrl(mcpUrl))
985
+ : undefined;
986
+ let registrationConnected;
987
+ let registrationMatches = false;
988
+ let keyMatchesRegistration = false;
989
+ try {
990
+ const status = ALL_HARNESSES.includes(client)
991
+ ? detectRegistrations([client], mcpUrl)[0] : undefined;
992
+ registrationConnected = status?.connected;
993
+ registrationMatches = status?.registered === 'yes' && !!status.registeredUrl &&
994
+ endpointOf(status.registeredUrl) === endpointOf(mcpUrl);
995
+ keyMatchesRegistration = status?.keyMatchesCliKey === true;
996
+ }
997
+ catch {
998
+ registrationConnected = undefined;
999
+ }
1000
+ return buildConnectResult({
1001
+ host: client,
1002
+ mcpUrl,
1003
+ authMode,
1004
+ installed,
1005
+ health,
1006
+ auth,
1007
+ registrationConnected,
1008
+ registrationMatches,
1009
+ keyMatchesRegistration,
1010
+ ...(error ? { error: safeConnectError(error) } : {}),
1011
+ });
1012
+ }
933
1013
  async function connectSingleClient(client, options, mcpUrl, attemptId) {
934
1014
  const connectOptions = {
935
1015
  apiKey: options.apiKey,
@@ -937,12 +1017,21 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
937
1017
  launch: options.launch ?? false,
938
1018
  attemptId,
939
1019
  // A key that the install cannot use leaves only a browser hop no agent can finish.
940
- keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
941
- // Only here: a missing harness rescues via the CLI (PF-3707).
942
- fallbackWhenMissing: true,
1020
+ keyAuthOnly: !detectHumanInitiated() && !!connectApiKey(options.apiKey),
1021
+ authTimeoutMs: 60_000,
1022
+ // Machine mode is consumed by the landing agent: install the Host entry and
1023
+ // return its native OAuth action instead of trying to drive an interactive login.
1024
+ deferOAuth: options.json === true,
1025
+ // A selected Host failure never switches clients or auth modes.
1026
+ fallbackWhenMissing: false,
943
1027
  verbose: options.verbose ?? false,
944
1028
  };
945
1029
  if (!ALL_HARNESSES.includes(client)) {
1030
+ if (options.json) {
1031
+ out(await singleConnectResult(String(client), mcpUrl, undefined, false, undefined, new ConnectStepError('unsupported', 'harness_command_unsupported')));
1032
+ process.exitCode = 1;
1033
+ return;
1034
+ }
946
1035
  throw new Error('Unsupported client: ' + client + '. Supported clients: ' + ALL_HARNESSES.join(', '));
947
1036
  }
948
1037
  const gate = await gateApiKey({
@@ -951,8 +1040,12 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
951
1040
  retryCommand: `sp connect ${client}`,
952
1041
  mode: 'single',
953
1042
  attemptId,
1043
+ strict: options.json === true,
954
1044
  });
955
1045
  if (gate === 'blocked') {
1046
+ if (options.json) {
1047
+ out(await singleConnectResult(client, mcpUrl, connectApiKey(options.apiKey) !== undefined ? AuthMode.ApiKey : undefined, false, options.apiKey, new ConnectStepError('invalid key', 'invalid_config')));
1048
+ }
956
1049
  process.exitCode = 1;
957
1050
  return;
958
1051
  }
@@ -961,7 +1054,21 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
961
1054
  const hint = legacyPluginHint(client, legacy);
962
1055
  if (hint)
963
1056
  errLine(hint);
964
- await connectHarness(client, connectOptions);
1057
+ let authMode = connectApiKey(options.apiKey) !== undefined ? AuthMode.ApiKey : AuthMode.OAuth;
1058
+ let error;
1059
+ try {
1060
+ authMode = await connectHarness(client, connectOptions) ?? authMode;
1061
+ }
1062
+ catch (caught) {
1063
+ error = caught;
1064
+ if (!options.json)
1065
+ throw caught;
1066
+ }
1067
+ const result = await singleConnectResult(client, mcpUrl, authMode, !error, options.apiKey, error);
1068
+ if (options.json)
1069
+ out(result);
1070
+ if (result.outcome !== 'verified' && process.exitCode !== 130)
1071
+ process.exitCode = 1;
965
1072
  }
966
1073
  export function registerConnect(program) {
967
1074
  program
@@ -975,9 +1082,16 @@ export function registerConnect(program) {
975
1082
  .option('--api-key <apiKey>', 'SpringBrand API key to persist for the installed skill')
976
1083
  .option('--launch', 'Launch the agent and start the SpringBrand walkthrough')
977
1084
  .option('--url <mcpUrl>', 'MCP endpoint override')
1085
+ .option('--json', 'Print one machine-readable bootstrap result')
978
1086
  .option('--verbose', 'Print the full npm and skills output instead of only on failure')
979
1087
  .action(async (client, options) => {
980
1088
  if (options.apiKey && !validateKeyFormat(options.apiKey)) {
1089
+ if (options.json && client && ALL_HARNESSES.includes(client)) {
1090
+ const { mcpUrl } = parseMcpUrlOption(options);
1091
+ out(await singleConnectResult(client, mcpUrl, AuthMode.ApiKey, false, options.apiKey, new ConnectStepError('invalid key', 'invalid_config')));
1092
+ process.exitCode = 1;
1093
+ return;
1094
+ }
981
1095
  throw new Error('Invalid --api-key value');
982
1096
  }
983
1097
  const { mcpUrl, attemptId } = parseMcpUrlOption(options);
@@ -985,9 +1099,15 @@ export function registerConnect(program) {
985
1099
  if (client) {
986
1100
  throw new Error('Pass either a client or --all, not both.');
987
1101
  }
1102
+ if (options.json) {
1103
+ throw new Error('--json requires a single client; --all has per-Host text output');
1104
+ }
988
1105
  await connectManyPath(options, mcpUrl, attemptId);
989
1106
  return;
990
1107
  }
1108
+ if (options.json && !client) {
1109
+ throw new Error('--json requires a single client; pass a client name');
1110
+ }
991
1111
  if (options.dryRun || options.uninstall || options.skipLaunch) {
992
1112
  throw new Error('--dry-run, --uninstall and --skip-launch are only supported with --all.');
993
1113
  }