@tiny-fish/cli 0.30.2-next.267 → 0.30.2-next.268

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.
@@ -63,17 +63,17 @@ const HARNESS_CONNECTORS = {
63
63
  return undefined;
64
64
  },
65
65
  };
66
- async function runHarnessConnect(harness, opts, keyAuthOnly, authTimeoutMs, hoistedCliInstall, onPostInstallFailed, deferOutro) {
66
+ async function runHarnessConnect(harness, opts, extras) {
67
67
  return HARNESS_CONNECTORS[harness]({
68
68
  apiKey: opts.apiKey,
69
69
  mcpUrl: opts.mcpUrl,
70
70
  launch: false,
71
- keyAuthOnly,
72
- authTimeoutMs,
73
- hoistedCliInstall,
74
- onPostInstallFailed,
71
+ keyAuthOnly: extras.keyAuthOnly,
72
+ authTimeoutMs: extras.authTimeoutMs,
73
+ hoistedCliInstall: extras.hoistedCliInstall,
74
+ onPostInstallFailed: extras.onPostInstallFailed,
75
75
  verbose: opts.verbose,
76
- deferOutro,
76
+ deferOutro: extras.deferOutro,
77
77
  });
78
78
  }
79
79
  /** Undefined means Ctrl+C: the caller stops the whole run. */
@@ -168,89 +168,80 @@ function hermesKeyKeptAdvisory() {
168
168
  return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
169
169
  'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
170
170
  }
171
- function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
172
- const result = removeHarnessRegistration(harness, configPath, verbose, binaryOnPath);
173
- if (harness !== 'hermes')
174
- return result;
175
- // Clearing a key the entry still references would break a working install.
176
- const removed = !result.removalFailed &&
177
- (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
178
- const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
179
- if (!advisory)
180
- return result;
181
- // Appended, never replacing: an advisory must not hide the removal's message.
182
- return {
183
- ...result,
184
- errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
185
- };
186
- }
187
- function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
188
- const detection = { harness, detected: true, configPath, installed: false };
189
- if (harness !== 'cursor') {
190
- const removal = uninstallRuns(harness);
191
- if (!removal) {
192
- return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
193
- }
194
- // Stale config: the harness binary is gone, so its removal command cannot run.
195
- // openclaw is exempt — its removal runs through npx, not the harness binary.
196
- if (harness !== 'openclaw' && !binaryOnPath) {
197
- return {
198
- ...detection,
199
- outcome: 'uninstall_pointer',
200
- errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
201
- fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
202
- };
203
- }
204
- // Absent marker = never connected here, so a non-zero removal exit is a no-op.
205
- // Unreadable config cannot prove that, so it keeps the pointer.
206
- const connectedBefore = connectContextState(harness) !== 'absent';
207
- let removed = false;
208
- try {
209
- for (const args of removal.runs) {
210
- const result = spawnRemoval(removal.command, args, removal.timeoutMs);
211
- if (result.status !== 0 && verbose) {
212
- const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
213
- errLine(`${removal.command} ${args.join(' ')}: ${details}`);
214
- }
215
- // Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
216
- if (result.error) {
217
- return {
218
- ...detection,
219
- outcome: 'uninstalled',
220
- removalFailed: true,
221
- errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
222
- fixCommand: uninstallPointer(harness),
223
- };
224
- }
225
- // Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
226
- if (result.status === 0)
227
- removed = true;
171
+ function runRemovalCommands(detection, removal, verbose) {
172
+ const { harness } = detection;
173
+ let removed = false;
174
+ try {
175
+ for (const args of removal.runs) {
176
+ const result = spawnRemoval(removal.command, args, removal.timeoutMs);
177
+ if (result.status !== 0 && verbose) {
178
+ const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
179
+ errLine(`${removal.command} ${args.join(' ')}: ${details}`);
228
180
  }
229
- }
230
- catch (e) {
231
- if (e instanceof ConnectInterruptedError) {
181
+ // Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
182
+ if (result.error) {
232
183
  return {
233
184
  ...detection,
234
- outcome: 'interrupted',
235
- fixCommand: 'tinyfish connect --all --uninstall',
185
+ outcome: 'uninstalled',
186
+ removalFailed: true,
187
+ errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
188
+ fixCommand: uninstallPointer(harness),
236
189
  };
237
190
  }
238
- throw e;
191
+ // Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
192
+ if (result.status === 0)
193
+ removed = true;
239
194
  }
240
- if (removed) {
241
- // Kept marker makes doctor emit a registration-lost repair that undoes this.
242
- clearConnectContext(harness);
243
- return { ...detection, outcome: 'uninstalled' };
195
+ }
196
+ catch (e) {
197
+ if (e instanceof ConnectInterruptedError) {
198
+ return {
199
+ ...detection,
200
+ outcome: 'interrupted',
201
+ fixCommand: 'tinyfish connect --all --uninstall',
202
+ };
244
203
  }
245
- if (!connectedBefore)
246
- return { ...detection, outcome: 'uninstall_noop' };
204
+ throw e;
205
+ }
206
+ return { kind: 'continue', removed };
207
+ }
208
+ function uninstallViaCommands(detection, verbose, binaryOnPath) {
209
+ const { harness } = detection;
210
+ const removal = uninstallRuns(harness);
211
+ if (!removal) {
212
+ return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
213
+ }
214
+ // Stale config: the harness binary is gone, so its removal command cannot run.
215
+ // openclaw is exempt — its removal runs through npx, not the harness binary.
216
+ if (harness !== 'openclaw' && !binaryOnPath) {
247
217
  return {
248
218
  ...detection,
249
219
  outcome: 'uninstall_pointer',
250
- errorMessage: 'could not confirm removal (command exited non-zero; there may have been nothing to remove).',
251
- fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
220
+ errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
221
+ fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
252
222
  };
253
223
  }
224
+ // Absent marker = never connected here, so a non-zero removal exit is a no-op.
225
+ // Unreadable config cannot prove that, so it keeps the pointer.
226
+ const connectedBefore = connectContextState(harness) !== 'absent';
227
+ const ran = runRemovalCommands(detection, removal, verbose);
228
+ if (!('kind' in ran))
229
+ return ran;
230
+ if (ran.removed) {
231
+ // Kept marker makes doctor emit a registration-lost repair that undoes this.
232
+ clearConnectContext(harness);
233
+ return { ...detection, outcome: 'uninstalled' };
234
+ }
235
+ if (!connectedBefore)
236
+ return { ...detection, outcome: 'uninstall_noop' };
237
+ return {
238
+ ...detection,
239
+ outcome: 'uninstall_pointer',
240
+ errorMessage: 'could not confirm removal (command exited non-zero; there may have been nothing to remove).',
241
+ fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
242
+ };
243
+ }
244
+ function uninstallCursor(detection) {
254
245
  let result;
255
246
  try {
256
247
  result = removeCursorMcpServer();
@@ -273,9 +264,117 @@ function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
273
264
  clearConnectContext('cursor');
274
265
  return { ...detection, outcome: 'uninstalled' };
275
266
  }
276
- async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliInstall, deferOutro) {
277
- const { harness, configPath, detected } = detection;
278
- const base = { harness, detected, configPath, installed: false };
267
+ function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
268
+ const detection = { harness, detected: true, configPath, installed: false };
269
+ if (harness === 'cursor')
270
+ return uninstallCursor(detection);
271
+ return uninstallViaCommands(detection, verbose, binaryOnPath);
272
+ }
273
+ function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
274
+ const result = removeHarnessRegistration(harness, configPath, verbose, binaryOnPath);
275
+ if (harness !== 'hermes')
276
+ return result;
277
+ // Clearing a key the entry still references would break a working install.
278
+ const removed = !result.removalFailed &&
279
+ (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
280
+ const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
281
+ if (!advisory)
282
+ return result;
283
+ // Appended, never replacing: an advisory must not hide the removal's message.
284
+ return {
285
+ ...result,
286
+ errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
287
+ };
288
+ }
289
+ function headlessGate(base, oauthBudget, fixCommand) {
290
+ const { harness } = base;
291
+ // A CLI-login sign-in is interactive-only — unboundable headless.
292
+ if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
293
+ return {
294
+ result: { ...base, outcome: 'auth_pending', fixCommand },
295
+ headlessAuth: false,
296
+ spentBrowserSlot: false,
297
+ };
298
+ }
299
+ let spentBrowserSlot = false;
300
+ if (BROWSER_SIGN_IN.has(harness)) {
301
+ // One browser sign-in per run; the rest wait for a re-run.
302
+ if (oauthBudget.spent) {
303
+ return {
304
+ result: { ...base, outcome: 'auth_pending', fixCommand },
305
+ headlessAuth: false,
306
+ spentBrowserSlot: false,
307
+ };
308
+ }
309
+ oauthBudget.spent = true;
310
+ spentBrowserSlot = true;
311
+ errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
312
+ `opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
313
+ }
314
+ return { headlessAuth: true, spentBrowserSlot };
315
+ }
316
+ /** Null: proceed. A result: settle with it. */
317
+ async function awaitSignInPrompt(base, prompt, fixCommand) {
318
+ errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[base.harness]}`);
319
+ try {
320
+ await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[base.harness]}... `);
321
+ return null;
322
+ }
323
+ catch (error) {
324
+ // Ctrl+C stops the run; Ctrl+D only skips this harness.
325
+ if (error instanceof ConnectInterruptedError) {
326
+ errLine('Setup interrupted — run the command again to finish.');
327
+ process.exitCode = 130;
328
+ return { ...base, outcome: 'interrupted', fixCommand };
329
+ }
330
+ return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
331
+ }
332
+ }
333
+ function mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand) {
334
+ if (e instanceof SignInTimeoutError) {
335
+ return {
336
+ ...base,
337
+ outcome: 'auth_pending',
338
+ errorMessage: 'sign-in did not finish in time',
339
+ fixCommand,
340
+ };
341
+ }
342
+ // A pre-browser failure must not burn the run's only browser slot.
343
+ if (spentBrowserSlot &&
344
+ e instanceof ConnectStepError &&
345
+ NO_BROWSER_REASONS.has(e.failureReason)) {
346
+ oauthBudget.spent = false;
347
+ }
348
+ // Detection saw a name on PATH; the probe proved nothing is behind it.
349
+ if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
350
+ return { ...base, detected: false, outcome: 'not_installed' };
351
+ }
352
+ // Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
353
+ if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
354
+ return { ...base, outcome: 'harness_too_old', errorMessage: e.message, fixCommand };
355
+ }
356
+ return {
357
+ ...base,
358
+ outcome: 'failed',
359
+ errorMessage: e instanceof Error ? e.message : String(e),
360
+ fixCommand,
361
+ };
362
+ }
363
+ async function authGate(base, oauthBudget, fixCommand, ctx) {
364
+ if (!ctx.isTTY && !ctx.keyed) {
365
+ return headlessGate(base, oauthBudget, fixCommand);
366
+ }
367
+ // Key-required harnesses refuse below; offering a browser sign-in would mislead.
368
+ if (ctx.isTTY && !ctx.keyed && ctx.prompt && !KEY_REQUIRED.has(base.harness)) {
369
+ const settled = await awaitSignInPrompt(base, ctx.prompt, fixCommand);
370
+ if (settled)
371
+ return { result: settled, headlessAuth: false, spentBrowserSlot: false };
372
+ }
373
+ return { headlessAuth: false, spentBrowserSlot: false };
374
+ }
375
+ /** The non-connect settlements: undetected, stale, dry-run, uninstall. */
376
+ function settleWithoutConnect(detection, opts, base) {
377
+ const { harness, configPath } = detection;
279
378
  const noWork = noWorkOutcome(detection, opts.uninstall);
280
379
  if (noWork === 'not_detected')
281
380
  return { ...base, outcome: noWork };
@@ -288,94 +387,47 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
288
387
  : planText(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
289
388
  return { ...base, outcome: 'dry_run', fixCommand: plan };
290
389
  }
291
- if (opts.uninstall)
390
+ if (opts.uninstall) {
292
391
  return uninstallHarness(harness, configPath, opts.verbose ?? false, detection.binaryOnPath);
392
+ }
393
+ return undefined;
394
+ }
395
+ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliInstall, deferOutro) {
396
+ const { harness, configPath, detected } = detection;
397
+ const base = { harness, detected, configPath, installed: false };
398
+ const settled = settleWithoutConnect(detection, opts, base);
399
+ if (settled)
400
+ return settled;
293
401
  const isTTY = detectHumanInitiated();
294
402
  const apiKey = validatedApiKey(opts.apiKey);
295
403
  // A key only leaves the own-OAuth set where the harness can actually carry one.
296
404
  const keyed = !!apiKey && KEY_AUTH_CAPABLE.has(harness);
297
405
  const fixCommand = `tinyfish connect ${harness}`;
298
- let headlessAuth = false;
299
- let spentBrowserSlot = false;
300
- if (!isTTY && !keyed) {
301
- // A CLI-login sign-in is interactive-only — unboundable headless.
302
- if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
303
- return { ...base, outcome: 'auth_pending', fixCommand };
304
- }
305
- if (BROWSER_SIGN_IN.has(harness)) {
306
- // One browser sign-in per run; the rest wait for a re-run.
307
- if (oauthBudget.spent)
308
- return { ...base, outcome: 'auth_pending', fixCommand };
309
- oauthBudget.spent = true;
310
- spentBrowserSlot = true;
311
- errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
312
- `opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
313
- }
314
- headlessAuth = true;
315
- }
316
- // Key-required harnesses refuse below; offering a browser sign-in would mislead.
317
- if (isTTY && !keyed && prompt && !KEY_REQUIRED.has(harness)) {
318
- errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
319
- try {
320
- await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
321
- }
322
- catch (error) {
323
- // Ctrl+C stops the run; Ctrl+D only skips this harness.
324
- if (error instanceof ConnectInterruptedError) {
325
- errLine('Setup interrupted — run the command again to finish.');
326
- process.exitCode = 130;
327
- return { ...base, outcome: 'interrupted', fixCommand };
328
- }
329
- return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
330
- }
331
- }
406
+ const gate = await authGate(base, oauthBudget, fixCommand, { prompt, isTTY, keyed });
407
+ if (gate.result)
408
+ return gate.result;
409
+ const { headlessAuth, spentBrowserSlot } = gate;
332
410
  // runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
333
411
  const interruptedBefore = process.exitCode === 130;
334
412
  let authMode;
335
413
  let postInstallFailed = false;
336
414
  try {
337
415
  // A keyed install too old for key auth fails, no browser.
338
- authMode = await runHarnessConnect(harness, opts, keyed && !isTTY, headlessAuth ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined, hoistedCliInstall, () => {
339
- postInstallFailed = true;
340
- }, deferOutro);
416
+ authMode = await runHarnessConnect(harness, opts, {
417
+ keyAuthOnly: keyed && !isTTY,
418
+ authTimeoutMs: headlessAuth ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined,
419
+ hoistedCliInstall,
420
+ onPostInstallFailed: () => {
421
+ postInstallFailed = true;
422
+ },
423
+ deferOutro,
424
+ });
341
425
  }
342
426
  catch (e) {
343
- if (e instanceof SignInTimeoutError) {
344
- return {
345
- ...base,
346
- outcome: 'auth_pending',
347
- errorMessage: 'sign-in did not finish in time',
348
- fixCommand,
349
- };
350
- }
351
- // A pre-browser failure must not burn the run's only browser slot.
352
- if (spentBrowserSlot &&
353
- e instanceof ConnectStepError &&
354
- NO_BROWSER_REASONS.has(e.failureReason)) {
355
- oauthBudget.spent = false;
356
- }
357
- // Detection saw a name on PATH; the probe proved nothing is behind it.
358
- if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
359
- return { ...base, detected: false, outcome: 'not_installed' };
360
- }
361
- // Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
362
- if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
363
- return {
364
- ...base,
365
- outcome: 'harness_too_old',
366
- errorMessage: e.message,
367
- fixCommand: `tinyfish connect ${harness}`,
368
- };
369
- }
370
- return {
371
- ...base,
372
- outcome: 'failed',
373
- errorMessage: e instanceof Error ? e.message : String(e),
374
- fixCommand: `tinyfish connect ${harness}`,
375
- };
427
+ return mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand);
376
428
  }
377
429
  if (!interruptedBefore && process.exitCode === 130) {
378
- return { ...base, outcome: 'interrupted', fixCommand: `tinyfish connect ${harness}` };
430
+ return { ...base, outcome: 'interrupted', fixCommand };
379
431
  }
380
432
  // The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
381
433
  const keyAuthed = authMode ? authMode === 'api-key' : keyed;
@@ -391,20 +443,21 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
391
443
  postInstallFailed,
392
444
  };
393
445
  }
394
- function summaryLine(result, uninstall) {
446
+ function absentSummaryLine(result, uninstall) {
395
447
  const name = DISPLAY_NAMES[result.harness];
448
+ const next = uninstall
449
+ ? 'nothing to remove'
450
+ : `Install ${name}, then run: tinyfish connect ${result.harness}`;
396
451
  if (result.outcome === 'not_installed') {
397
- const next = uninstall
398
- ? 'nothing to remove'
399
- : `Install ${name}, then run: tinyfish connect ${result.harness}`;
400
452
  return `- ${name} — not installed (\`${HARNESS_COMMANDS[result.harness]}\` is on PATH but does not run). ${next}`;
401
453
  }
402
454
  if (!result.detected) {
403
- const next = uninstall
404
- ? 'nothing to remove'
405
- : `Install ${name}, then run: tinyfish connect ${result.harness}`;
406
455
  return `- ${name} — not detected (no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, no ${result.configPath}). ${next}`;
407
456
  }
457
+ return undefined;
458
+ }
459
+ function skippedSummaryLine(result) {
460
+ const name = DISPLAY_NAMES[result.harness];
408
461
  if (result.outcome === 'stale_config') {
409
462
  return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
410
463
  }
@@ -422,6 +475,19 @@ function summaryLine(result, uninstall) {
422
475
  ? `… ${name} — ${result.errorMessage}. Finish it in a terminal: ${result.fixCommand}`
423
476
  : `… ${name} — needs a browser sign-in. Run in a terminal: ${result.fixCommand}`;
424
477
  }
478
+ if (result.outcome === 'interrupted') {
479
+ return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
480
+ }
481
+ if (result.outcome === 'harness_too_old') {
482
+ return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
483
+ }
484
+ if (result.outcome === 'failed') {
485
+ return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
486
+ }
487
+ return undefined;
488
+ }
489
+ function uninstallSummaryLine(result) {
490
+ const name = DISPLAY_NAMES[result.harness];
425
491
  if (result.outcome === 'uninstall_pointer') {
426
492
  // errorMessage set = a removal was attempted or impossible; absent = no removal command exists.
427
493
  return result.errorMessage
@@ -439,15 +505,10 @@ function summaryLine(result, uninstall) {
439
505
  return `⚠ ${name} — removed, but ${result.errorMessage}`;
440
506
  return `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`;
441
507
  }
442
- if (result.outcome === 'interrupted') {
443
- return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
444
- }
445
- if (result.outcome === 'harness_too_old') {
446
- return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
447
- }
448
- if (result.outcome === 'failed') {
449
- return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
450
- }
508
+ return undefined;
509
+ }
510
+ function installedSummaryLine(result) {
511
+ const name = DISPLAY_NAMES[result.harness];
451
512
  // The summary is what users read; stderr hints scroll away.
452
513
  if (result.postInstallFailed) {
453
514
  return (`⚠ ${name} — installed, but a finishing step failed. ` +
@@ -464,6 +525,12 @@ function summaryLine(result, uninstall) {
464
525
  }
465
526
  return `✓ ${name} — installed, ${verifyLabel}`;
466
527
  }
528
+ function summaryLine(result, uninstall) {
529
+ return (absentSummaryLine(result, uninstall) ??
530
+ skippedSummaryLine(result) ??
531
+ uninstallSummaryLine(result) ??
532
+ installedSummaryLine(result));
533
+ }
467
534
  /** One line for every harness: seven near-identical paragraphs is what this replaces. */
468
535
  function reloadHint(results) {
469
536
  const byAction = new Map();
@@ -529,16 +596,7 @@ function computeExitCode(results) {
529
596
  return 0;
530
597
  return attempted.some((r) => r.outcome === 'installed') ? 0 : 1;
531
598
  }
532
- export async function runConnectAll(opts) {
533
- const isTTY = detectHumanInitiated();
534
- // Open interface holds stdin raw; hermes' input() then wedges.
535
- const prompt = isTTY ? createStdinPrompt() : undefined;
536
- const detections = detectInstalledHarnesses();
537
- const gateKey = validatedApiKey(opts.apiKey);
538
- // Keyed installs first: working tools land before sign-ins can block.
539
- if (gateKey) {
540
- detections.sort((a, b) => Number(KEY_AUTH_CAPABLE.has(b.harness)) - Number(KEY_AUTH_CAPABLE.has(a.harness)));
541
- }
599
+ async function resolvePick(detections, opts, prompt) {
542
600
  let deselected = new Set();
543
601
  let mode = 'all';
544
602
  // Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
@@ -549,18 +607,15 @@ export async function runConnectAll(opts) {
549
607
  const pick = await pickHarnesses(offerable, connected, { prompt });
550
608
  mode = pick.mode;
551
609
  if (pick.selection === 'cancel') {
552
- // Before summary and telemetry: an explicit nothing is not a setup.
553
- outLine('Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>');
554
- return 0;
610
+ return { deselected, mode, cancelled: true };
555
611
  }
556
612
  const chosen = new Set(pick.selection);
557
613
  deselected = new Set(offerable.filter((d) => !chosen.has(d.harness)).map((d) => d.harness));
558
614
  }
559
- // Mirrors processHarness's gate: headless, only keyless CLI-login harnesses skip.
560
- const willAttempt = (harness) => !deselected.has(harness) &&
561
- (isTTY ||
562
- !HARNESS_CAPABILITIES[harness].signInViaCliLogin ||
563
- (!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
615
+ return { deselected, mode, cancelled: false };
616
+ }
617
+ /** 'interrupted' stops the whole run; undefined means no hoist was needed. */
618
+ function hoistCliIfNeeded(detections, opts, willAttempt) {
564
619
  // Machine-global, so once per invocation; config-writing connects never install it.
565
620
  const cliNeeded = !opts.dryRun &&
566
621
  !opts.uninstall &&
@@ -568,24 +623,21 @@ export async function runConnectAll(opts) {
568
623
  d.binaryOnPath &&
569
624
  !HARNESS_CAPABILITIES[d.harness].cliWritesConfig &&
570
625
  willAttempt(d.harness));
571
- let takeHoistedCli;
572
- if (cliNeeded) {
573
- errLine('First: the TinyFish CLI, shared by every harness.');
574
- const outcome = runHoistedCliInstall(opts.verbose ?? false);
575
- if (!outcome) {
576
- errLine('Setup interrupted — run the command again to finish.');
577
- return 130;
578
- }
579
- let taken = false;
580
- // Every harness gets the outcome; only the first take reports it.
581
- takeHoistedCli = () => {
582
- const firstTake = !taken;
583
- taken = true;
584
- return { outcome, firstTake };
585
- };
586
- }
587
- // A Set dedupes: Codex and Grok defer the same export line.
588
- const outro = new Set();
626
+ if (!cliNeeded)
627
+ return undefined;
628
+ errLine('First: the TinyFish CLI, shared by every harness.');
629
+ const outcome = runHoistedCliInstall(opts.verbose ?? false);
630
+ if (!outcome)
631
+ return 'interrupted';
632
+ let taken = false;
633
+ // Every harness gets the outcome; only the first take reports it.
634
+ return () => {
635
+ const firstTake = !taken;
636
+ taken = true;
637
+ return { outcome, firstTake };
638
+ };
639
+ }
640
+ async function connectLoop(detections, opts, deselected, io) {
589
641
  const results = [];
590
642
  const oauthBudget = { spent: false };
591
643
  // Shares processHarness's no-work states so stepTotal cannot desync.
@@ -611,7 +663,7 @@ export async function runConnectAll(opts) {
611
663
  let result;
612
664
  try {
613
665
  setErrIndent(STEP_INDENT);
614
- result = await processHarness(detection, opts, prompt, oauthBudget, takeHoistedCli, (line) => outro.add(line));
666
+ result = await processHarness(detection, opts, io.prompt, oauthBudget, io.takeHoistedCli, (line) => io.outro.add(line));
615
667
  }
616
668
  catch (e) {
617
669
  // Isolation: one harness throwing never stops the others.
@@ -649,6 +701,86 @@ export async function runConnectAll(opts) {
649
701
  fixCommand: `tinyfish connect ${detection.harness}`,
650
702
  });
651
703
  }
704
+ return results;
705
+ }
706
+ async function noActionableExit(results, opts, prompt) {
707
+ if (!opts.dryRun && !opts.uninstall) {
708
+ try {
709
+ const outcome = await runCliFallback({
710
+ apiKey: opts.apiKey,
711
+ verbose: opts.verbose ?? false,
712
+ prompt,
713
+ retryCommand: 'npx @tiny-fish/cli connect --all',
714
+ });
715
+ return outcome === 'cli_verified' ? 0 : 1;
716
+ }
717
+ catch (error) {
718
+ if (!(error instanceof ConnectInterruptedError))
719
+ throw error;
720
+ errLine('Setup interrupted — run the command again to finish.');
721
+ process.exitCode = 130;
722
+ return 130;
723
+ }
724
+ }
725
+ const stale = results.filter((r) => r.outcome === 'stale_config');
726
+ outLine('');
727
+ outLine(stale.length > 0
728
+ ? `No runnable harness found. Reinstall ${stale.map((r) => DISPLAY_NAMES[r.harness]).join(', ')}, or install one of: ${ALL_HARNESSES.join(', ')}`
729
+ : 'No supported harness found. Install one of: ' + ALL_HARNESSES.join(', '));
730
+ if (!opts.uninstall)
731
+ outLine('Then run: npx @tiny-fish/cli connect --all');
732
+ return 1;
733
+ }
734
+ async function finishRun(results, opts, isTTY) {
735
+ const exitCode = computeExitCode(results);
736
+ const interrupted = results.some((r) => r.outcome === 'interrupted');
737
+ if (exitCode === 0 &&
738
+ !interrupted &&
739
+ !opts.dryRun &&
740
+ !opts.uninstall &&
741
+ results.some((r) => r.installed)) {
742
+ outLine('');
743
+ if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
744
+ return exitCode;
745
+ }
746
+ outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
747
+ }
748
+ return exitCode;
749
+ }
750
+ export async function runConnectAll(opts) {
751
+ const isTTY = detectHumanInitiated();
752
+ // Open interface holds stdin raw; hermes' input() then wedges.
753
+ const prompt = isTTY ? createStdinPrompt() : undefined;
754
+ const detections = detectInstalledHarnesses();
755
+ const gateKey = validatedApiKey(opts.apiKey);
756
+ // Keyed installs first: working tools land before sign-ins can block.
757
+ if (gateKey) {
758
+ detections.sort((a, b) => Number(KEY_AUTH_CAPABLE.has(b.harness)) - Number(KEY_AUTH_CAPABLE.has(a.harness)));
759
+ }
760
+ const pick = await resolvePick(detections, opts, prompt);
761
+ if (pick.cancelled) {
762
+ // Before summary and telemetry: an explicit nothing is not a setup.
763
+ outLine('Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>');
764
+ return 0;
765
+ }
766
+ const { deselected, mode } = pick;
767
+ // Mirrors processHarness's gate: headless, only keyless CLI-login harnesses skip.
768
+ const willAttempt = (harness) => !deselected.has(harness) &&
769
+ (isTTY ||
770
+ !HARNESS_CAPABILITIES[harness].signInViaCliLogin ||
771
+ (!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
772
+ const hoisted = hoistCliIfNeeded(detections, opts, willAttempt);
773
+ if (hoisted === 'interrupted') {
774
+ errLine('Setup interrupted — run the command again to finish.');
775
+ return 130;
776
+ }
777
+ // A Set dedupes: Codex and Grok defer the same export line.
778
+ const outro = new Set();
779
+ const results = await connectLoop(detections, opts, deselected, {
780
+ prompt,
781
+ takeHoistedCli: hoisted,
782
+ outro,
783
+ });
652
784
  outLine('');
653
785
  outLine('TinyFish setup summary:');
654
786
  for (const line of renderSummary(results, opts.uninstall))
@@ -662,32 +794,7 @@ export async function runConnectAll(opts) {
662
794
  for (const line of outro)
663
795
  errLine(line);
664
796
  if (!results.some(actionable)) {
665
- if (!opts.dryRun && !opts.uninstall) {
666
- try {
667
- const outcome = await runCliFallback({
668
- apiKey: opts.apiKey,
669
- verbose: opts.verbose ?? false,
670
- prompt,
671
- retryCommand: 'npx @tiny-fish/cli connect --all',
672
- });
673
- return outcome === 'cli_verified' ? 0 : 1;
674
- }
675
- catch (error) {
676
- if (!(error instanceof ConnectInterruptedError))
677
- throw error;
678
- errLine('Setup interrupted — run the command again to finish.');
679
- process.exitCode = 130;
680
- return 130;
681
- }
682
- }
683
- const stale = results.filter((r) => r.outcome === 'stale_config');
684
- outLine('');
685
- outLine(stale.length > 0
686
- ? `No runnable harness found. Reinstall ${stale.map((r) => DISPLAY_NAMES[r.harness]).join(', ')}, or install one of: ${ALL_HARNESSES.join(', ')}`
687
- : 'No supported harness found. Install one of: ' + ALL_HARNESSES.join(', '));
688
- if (!opts.uninstall)
689
- outLine('Then run: npx @tiny-fish/cli connect --all');
690
- return 1;
797
+ return noActionableExit(results, opts, prompt);
691
798
  }
692
799
  if (!opts.dryRun && !opts.uninstall) {
693
800
  const harnessTelemetry = results.map((r) => ({
@@ -703,20 +810,7 @@ export async function runConnectAll(opts) {
703
810
  // After the summary and before the launch, which is where the user is reading.
704
811
  emitNotice();
705
812
  }
706
- const exitCode = computeExitCode(results);
707
- const interrupted = results.some((r) => r.outcome === 'interrupted');
708
- if (exitCode === 0 &&
709
- !interrupted &&
710
- !opts.dryRun &&
711
- !opts.uninstall &&
712
- results.some((r) => r.installed)) {
713
- outLine('');
714
- if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
715
- return exitCode;
716
- }
717
- outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
718
- }
719
- return exitCode;
813
+ return finishRun(results, opts, isTTY);
720
814
  }
721
815
  export const FIRST_TASK_PROMPT = 'My TinyFish setup just finished — verify it with a real run. Use the MCP server named ' +
722
816
  '`tinyfish` for every TinyFish tool call; if another TinyFish server is also registered, still ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.30.2-next.267",
3
+ "version": "0.30.2-next.268",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {