@tiny-fish/cli 0.30.2-next.266 → 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.
@@ -148,6 +148,124 @@ async function runMissingHarnessFallback(displayName, connectClient, state, tele
148
148
  errLine('The TinyFish CLI is installed and working.');
149
149
  errLine('Using a different agent? Run: tinyfish connect --all');
150
150
  }
151
+ /** Resolved before the removals, so a refusal strands no registration. */
152
+ async function prepareInstallAuth(client, options, keyAuthSupported) {
153
+ // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
154
+ const storedKey = validatedApiKey(options.apiKey);
155
+ const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
156
+ // Refuses here when key-required; others keep their OAuth argv.
157
+ const buildAddArgs = keyedAdd
158
+ ? (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key)
159
+ : keylessAddArgs(client, keyAuthSupported, options.mcpUrl);
160
+ if (options.keyAuthOnly && !keyedAdd) {
161
+ // All native clients have keyAuth; missing flags mean an old install.
162
+ if (client.keyAuth) {
163
+ throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
164
+ `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
165
+ }
166
+ throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
167
+ `terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
168
+ }
169
+ // Verify first: a revoked key in a foreign .env is unrecoverable.
170
+ if (keyedAdd?.spec.seedKey) {
171
+ await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
172
+ }
173
+ return { keyedAdd, buildAddArgs };
174
+ }
175
+ /** Returns the seeded-credential note when a key was written into a user-owned file. */
176
+ function performMcpAdd(client, options, plan, attemptId) {
177
+ const { keyedAdd, buildAddArgs } = plan;
178
+ const mcpUrl = new URL(options.mcpUrl);
179
+ mcpUrl.searchParams.set('source', CONNECT_SOURCE);
180
+ mcpUrl.searchParams.set('client', client.connectClient);
181
+ mcpUrl.searchParams.set('connect_attempt_id', attemptId);
182
+ errLine(`Adding TinyFish to ${client.displayName}...`);
183
+ // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
184
+ const addArgs = buildAddArgs(mcpUrl.toString());
185
+ // Capturing an inline-OAuth add hides the sign-in URL and hangs.
186
+ const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
187
+ // After the removals: an interrupt there leaves no key behind.
188
+ const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
189
+ const addOptions = interactiveAdd
190
+ ? { stdio: 'inherit', timeout: options.authTimeoutMs }
191
+ : seeded
192
+ ? {
193
+ encoding: 'utf8',
194
+ // Without it, the 1 MiB default overflows into a misread Ctrl+C.
195
+ maxBuffer: STEP_MAX_BUFFER,
196
+ // The unattended path had no bound at all before; captureStdio's is the floor.
197
+ timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
198
+ input: seeded.stdinInput,
199
+ env: seeded.env,
200
+ }
201
+ : captureStdio(options.verbose ?? false);
202
+ const addResult = spawn.sync(client.command, addArgs, addOptions);
203
+ // Rolls back first: signInStepError throws on a Ctrl+C.
204
+ const failAdd = (build) => {
205
+ seeded?.rollback();
206
+ const error = build();
207
+ replay(capturedOutput(addResult));
208
+ throw error;
209
+ };
210
+ if (addResult.error || addResult.status !== 0) {
211
+ failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
212
+ }
213
+ const registration = seeded?.confirmRegistered();
214
+ if (registration && !registration.ok) {
215
+ failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
216
+ `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
217
+ }
218
+ return seeded?.note;
219
+ }
220
+ function performLoginStep(client, pendingLogin, options) {
221
+ errLine('Signing in to TinyFish...');
222
+ const loginResult = spawn.sync(client.command, [...pendingLogin], {
223
+ stdio: 'inherit',
224
+ timeout: options.authTimeoutMs,
225
+ });
226
+ if (loginResult.error || loginResult.status !== 0) {
227
+ throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
228
+ }
229
+ }
230
+ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
231
+ if (useKeyAuth) {
232
+ errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
233
+ // Every other credential the CLI writes into a user-owned file says so too.
234
+ if (seededNote)
235
+ errLine(seededNote);
236
+ }
237
+ else if (degradedNotes) {
238
+ // Printed here, not with the closing line, so a `--launch` run sees it before the handover.
239
+ errLine(degradedNotes.deferred);
240
+ }
241
+ }
242
+ function completeAuthFlow(client, options, state, telemetry, ctx) {
243
+ const { useKeyAuth, loginDegraded, loginArgs, probeAuthenticated } = ctx;
244
+ // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
245
+ // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
246
+ const addSignedIn = probeAuthenticated?.();
247
+ if (addSignedIn)
248
+ telemetry.track('checkpoint', { phase: 'oauth_done' });
249
+ announceAuthNotes(useKeyAuth, loginDegraded ? client.degradedAuthNotes : undefined, ctx.seededNote);
250
+ // A probed client signs in again only when its inline OAuth is known to have been abandoned.
251
+ const retryLogin = probeAuthenticated ? addSignedIn === false : true;
252
+ const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
253
+ let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
254
+ if (pendingLogin) {
255
+ state.stage = 'client_oauth';
256
+ performLoginStep(client, pendingLogin, options);
257
+ if (probeAuthenticated?.() === false)
258
+ signInDeferred = true;
259
+ else
260
+ telemetry.track('checkpoint', { phase: 'oauth_done' });
261
+ }
262
+ const authMode = useKeyAuth
263
+ ? AuthMode.ApiKey
264
+ : signInDeferred
265
+ ? 'deferred'
266
+ : AuthMode.OAuth;
267
+ return { authMode, signInDeferred };
268
+ }
151
269
  async function connectNativeMcpClient(client, options) {
152
270
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
153
271
  const state = { stage: 'prerequisite_check', settled: false };
@@ -160,118 +278,27 @@ async function connectNativeMcpClient(client, options) {
160
278
  const { optionalSupported, keyAuthSupported, harnessVersion } = support;
161
279
  telemetry.setHarnessVersion(harnessVersion);
162
280
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
163
- // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
164
- const storedKey = validatedApiKey(options.apiKey);
165
- const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
166
- const useKeyAuth = !!keyedAdd;
167
- // Refuses here when key-required; others keep their OAuth argv.
168
- const buildAddArgs = keyedAdd
169
- ? (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key)
170
- : keylessAddArgs(client, keyAuthSupported, options.mcpUrl);
171
- if (options.keyAuthOnly && !useKeyAuth) {
172
- // All native clients have keyAuth; missing flags mean an old install.
173
- if (client.keyAuth) {
174
- throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
175
- `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
176
- }
177
- throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
178
- `terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
179
- }
180
- // Verify first: a revoked key in a foreign .env is unrecoverable.
181
- if (keyedAdd?.spec.seedKey) {
182
- await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
183
- }
281
+ const plan = await prepareInstallAuth(client, options, keyAuthSupported);
282
+ const useKeyAuth = !!plan.keyedAdd;
184
283
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
185
284
  state.stage = 'registration_cleanup';
186
285
  for (const removal of client.removals)
187
286
  removeExistingRegistration(client, removal, options.verbose ?? false);
188
- const mcpUrl = new URL(options.mcpUrl);
189
- mcpUrl.searchParams.set('source', CONNECT_SOURCE);
190
- mcpUrl.searchParams.set('client', client.connectClient);
191
- mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
192
287
  // Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
193
288
  const loginDegraded = state.harnessDegraded === true;
194
289
  const loginArgs = loginDegraded ? undefined : client.loginArgs;
195
290
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
196
291
  state.stage =
197
292
  loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
198
- errLine(`Adding TinyFish to ${client.displayName}...`);
199
- // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
200
- const addArgs = buildAddArgs(mcpUrl.toString());
201
- // Capturing an inline-OAuth add hides the sign-in URL and hangs.
202
- const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
203
- // After the removals: an interrupt there leaves no key behind.
204
- const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
205
- const addOptions = interactiveAdd
206
- ? { stdio: 'inherit', timeout: options.authTimeoutMs }
207
- : seeded
208
- ? {
209
- encoding: 'utf8',
210
- // Without it, the 1 MiB default overflows into a misread Ctrl+C.
211
- maxBuffer: STEP_MAX_BUFFER,
212
- // The unattended path had no bound at all before; captureStdio's is the floor.
213
- timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
214
- input: seeded.stdinInput,
215
- env: seeded.env,
216
- }
217
- : captureStdio(options.verbose ?? false);
218
- const addResult = spawn.sync(client.command, addArgs, addOptions);
219
- // Rolls back first: signInStepError throws on a Ctrl+C.
220
- const failAdd = (build) => {
221
- seeded?.rollback();
222
- const error = build();
223
- replay(capturedOutput(addResult));
224
- throw error;
225
- };
226
- if (addResult.error || addResult.status !== 0) {
227
- failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
228
- }
229
- const registration = seeded?.confirmRegistered();
230
- if (registration && !registration.ok) {
231
- failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
232
- `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
233
- }
293
+ const seededNote = performMcpAdd(client, options, plan, telemetry.attemptId);
234
294
  telemetry.track('checkpoint', { phase: 'registered' });
235
- // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
236
- // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
237
- const addSignedIn = probeAuthenticated?.();
238
- if (addSignedIn)
239
- telemetry.track('checkpoint', { phase: 'oauth_done' });
240
- const degradedNotes = loginDegraded ? client.degradedAuthNotes : undefined;
241
- if (useKeyAuth) {
242
- errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
243
- // Every other credential the CLI writes into a user-owned file says so too.
244
- if (seeded)
245
- errLine(seeded.note);
246
- }
247
- else if (degradedNotes) {
248
- // Printed here, not with the closing line, so a `--launch` run sees it before the handover.
249
- errLine(degradedNotes.deferred);
250
- }
251
- // A probed client signs in again only when its inline OAuth is known to have been abandoned.
252
- const retryLogin = probeAuthenticated ? addSignedIn === false : true;
253
- const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
254
- let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
255
- if (pendingLogin) {
256
- state.stage = 'client_oauth';
257
- errLine('Signing in to TinyFish...');
258
- const loginResult = spawn.sync(client.command, pendingLogin, {
259
- stdio: 'inherit',
260
- timeout: options.authTimeoutMs,
261
- });
262
- if (loginResult.error || loginResult.status !== 0) {
263
- throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
264
- }
265
- if (probeAuthenticated?.() === false)
266
- signInDeferred = true;
267
- else
268
- telemetry.track('checkpoint', { phase: 'oauth_done' });
269
- }
270
- const authMode = useKeyAuth
271
- ? AuthMode.ApiKey
272
- : signInDeferred
273
- ? 'deferred'
274
- : AuthMode.OAuth;
295
+ const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
296
+ useKeyAuth,
297
+ loginDegraded,
298
+ loginArgs,
299
+ probeAuthenticated,
300
+ seededNote,
301
+ });
275
302
  state.authMode = authMode;
276
303
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
277
304
  settle(state, telemetry, 'completed', { authMode });
@@ -611,6 +638,89 @@ const CLIENT_CONNECTORS = {
611
638
  }
612
639
  },
613
640
  };
641
+ function parseMcpUrlOption(options) {
642
+ let mcpUrl = options.url ?? DEFAULT_MCP_URL;
643
+ if (mcpUrl.startsWith('-')) {
644
+ throw new Error(`Invalid --url value: ${mcpUrl}`);
645
+ }
646
+ let parsedUrl;
647
+ try {
648
+ parsedUrl = new URL(mcpUrl);
649
+ }
650
+ catch {
651
+ throw new Error(`Invalid --url value: ${mcpUrl}`);
652
+ }
653
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
654
+ throw new Error(`Invalid --url value: ${mcpUrl}`);
655
+ }
656
+ // Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
657
+ // base — registration re-stamps it, and nothing else may carry it.
658
+ const pageAttemptId = parsedUrl.searchParams.get('connect_attempt_id');
659
+ let attemptId;
660
+ if (pageAttemptId !== null) {
661
+ parsedUrl.searchParams.delete('connect_attempt_id');
662
+ mcpUrl = parsedUrl.toString();
663
+ if (z.uuid().safeParse(pageAttemptId).success)
664
+ attemptId = pageAttemptId;
665
+ }
666
+ return { mcpUrl, attemptId };
667
+ }
668
+ async function connectAllPath(options, mcpUrl) {
669
+ if (options.launch) {
670
+ throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
671
+ }
672
+ const exitCode = await runConnectAll({
673
+ apiKey: options.apiKey,
674
+ mcpUrl,
675
+ dryRun: options.dryRun ?? false,
676
+ uninstall: options.uninstall ?? false,
677
+ noLaunch: options.skipLaunch === true,
678
+ verbose: options.verbose ?? false,
679
+ });
680
+ // Preserve the 130 runGuarded set on Ctrl+C.
681
+ if (!process.exitCode)
682
+ process.exitCode = exitCode;
683
+ }
684
+ async function connectPickPath(options, mcpUrl) {
685
+ if (options.launch) {
686
+ throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
687
+ }
688
+ const humanInitiated = detectHumanInitiated();
689
+ if (!humanInitiated) {
690
+ errLine('No terminal detected: connecting every detected harness (same as tinyfish connect --all).');
691
+ }
692
+ const exitCode = await runConnectAll({
693
+ apiKey: options.apiKey,
694
+ mcpUrl,
695
+ dryRun: false,
696
+ uninstall: false,
697
+ pick: humanInitiated,
698
+ verbose: options.verbose ?? false,
699
+ });
700
+ // Preserve the 130 runGuarded set on Ctrl+C.
701
+ if (!process.exitCode)
702
+ process.exitCode = exitCode;
703
+ }
704
+ async function connectSingleClient(client, options, mcpUrl, attemptId) {
705
+ const connectOptions = {
706
+ apiKey: options.apiKey,
707
+ mcpUrl,
708
+ launch: options.launch ?? false,
709
+ attemptId,
710
+ // A key that the install cannot use leaves only a browser hop no agent can finish.
711
+ keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
712
+ // Only here: a missing harness rescues via the CLI (PF-3707).
713
+ fallbackWhenMissing: true,
714
+ verbose: options.verbose ?? false,
715
+ };
716
+ const connector = CLIENT_CONNECTORS[client];
717
+ if (!connector) {
718
+ throw new Error('Unsupported client: ' +
719
+ client +
720
+ '. Supported clients: claude-code, codex, cursor, grok, hermes, openclaw, opencode');
721
+ }
722
+ await connector(connectOptions);
723
+ }
614
724
  export function registerConnect(program) {
615
725
  program
616
726
  .command('connect')
@@ -628,94 +738,22 @@ export function registerConnect(program) {
628
738
  if (options.apiKey && !validateKeyFormat(options.apiKey)) {
629
739
  throw new Error('Invalid --api-key value');
630
740
  }
631
- let mcpUrl = options.url ?? DEFAULT_MCP_URL;
632
- if (mcpUrl.startsWith('-')) {
633
- throw new Error(`Invalid --url value: ${mcpUrl}`);
634
- }
635
- let parsedUrl;
636
- try {
637
- parsedUrl = new URL(mcpUrl);
638
- }
639
- catch {
640
- throw new Error(`Invalid --url value: ${mcpUrl}`);
641
- }
642
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
643
- throw new Error(`Invalid --url value: ${mcpUrl}`);
644
- }
645
- // Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
646
- // base — registration re-stamps it, and nothing else may carry it.
647
- const pageAttemptId = parsedUrl.searchParams.get('connect_attempt_id');
648
- let attemptId;
649
- if (pageAttemptId !== null) {
650
- parsedUrl.searchParams.delete('connect_attempt_id');
651
- mcpUrl = parsedUrl.toString();
652
- if (z.uuid().safeParse(pageAttemptId).success)
653
- attemptId = pageAttemptId;
654
- }
741
+ const { mcpUrl, attemptId } = parseMcpUrlOption(options);
655
742
  if (options.all) {
656
743
  if (client) {
657
744
  throw new Error('Pass either a client or --all, not both.');
658
745
  }
659
- if (options.launch) {
660
- throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
661
- }
662
- const exitCode = await runConnectAll({
663
- apiKey: options.apiKey,
664
- mcpUrl,
665
- dryRun: options.dryRun ?? false,
666
- uninstall: options.uninstall ?? false,
667
- noLaunch: options.skipLaunch === true,
668
- verbose: options.verbose ?? false,
669
- });
670
- // Preserve the 130 runGuarded set on Ctrl+C.
671
- if (!process.exitCode)
672
- process.exitCode = exitCode;
746
+ await connectAllPath(options, mcpUrl);
673
747
  return;
674
748
  }
675
749
  if (options.dryRun || options.uninstall || options.skipLaunch) {
676
750
  throw new Error('--dry-run, --uninstall and --skip-launch are only supported with --all.');
677
751
  }
678
752
  if (!client) {
679
- if (options.launch) {
680
- throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
681
- }
682
- const humanInitiated = detectHumanInitiated();
683
- if (!humanInitiated) {
684
- errLine('No terminal detected: connecting every detected harness (same as tinyfish connect --all).');
685
- }
686
- const exitCode = await runConnectAll({
687
- apiKey: options.apiKey,
688
- mcpUrl,
689
- dryRun: false,
690
- uninstall: false,
691
- pick: humanInitiated,
692
- verbose: options.verbose ?? false,
693
- });
694
- // Preserve the 130 runGuarded set on Ctrl+C.
695
- if (!process.exitCode)
696
- process.exitCode = exitCode;
753
+ await connectPickPath(options, mcpUrl);
697
754
  return;
698
755
  }
699
- const connectOptions = {
700
- apiKey: options.apiKey,
701
- mcpUrl,
702
- launch: options.launch ?? false,
703
- attemptId,
704
- // A key that the install cannot use leaves only a browser hop no agent can finish.
705
- keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
706
- // Only here: a missing harness rescues via the CLI (PF-3707).
707
- fallbackWhenMissing: true,
708
- verbose: options.verbose ?? false,
709
- };
710
- const connector = CLIENT_CONNECTORS[client];
711
- if (connector) {
712
- await connector(connectOptions);
713
- }
714
- else {
715
- throw new Error('Unsupported client: ' +
716
- client +
717
- '. Supported clients: claude-code, codex, cursor, grok, hermes, openclaw, opencode');
718
- }
756
+ await connectSingleClient(client, options, mcpUrl, attemptId);
719
757
  // Deferred to here so the notice lands after install output, not interleaved with it.
720
758
  emitNotice();
721
759
  });
@@ -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.266",
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": {