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

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.
@@ -16,7 +16,7 @@ import { SIGN_IN_PROBES } from '../lib/registration-detect.js';
16
16
  import { errLine, warnLine } from '../lib/output.js';
17
17
  import { z } from 'zod';
18
18
  import { verifyMcpAuth } from '../lib/verify.js';
19
- import { DEFAULT_MCP_URL, isDefaultEndpoint } from '../lib/mcp-endpoint.js';
19
+ import { apiBaseFromMcpUrl, DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
20
20
  function removeExistingRegistration(client, removal, verbose) {
21
21
  const result = spawnRemoval(client.command, removal.args);
22
22
  if (result.status === 0)
@@ -48,9 +48,43 @@ function exportApiKey(apiKey, displayName, deferOutro) {
48
48
  }
49
49
  return true;
50
50
  }
51
+ /** Undefined leaves the client on its keyless path, if any. */
52
+ function resolveKeyedAdd(client, storedKey, keyAuthSupported, deferOutro) {
53
+ if (!storedKey || !keyAuthSupported || !client.keyAuth)
54
+ return undefined;
55
+ const keyed = { key: storedKey, spec: client.keyAuth };
56
+ // Before the removals: a failed export must not strand the old registration.
57
+ if (keyed.spec.envVar && !exportApiKey(keyed.key, client.displayName, deferOutro)) {
58
+ return undefined;
59
+ }
60
+ return keyed;
61
+ }
62
+ /** No sign-in to degrade to, so the install stops. */
63
+ function refuseKeylessInstall(client, keyAuthSupported) {
64
+ const noSignIn = `TinyFish does not sign in to ${client.displayName} any other way`;
65
+ const rerun = `re-run: tinyfish connect ${client.connectClient}`;
66
+ if (!client.keyAuth) {
67
+ throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and ${noSignIn}. ` +
68
+ `Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
69
+ }
70
+ if (!keyAuthSupported) {
71
+ throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and ${noSignIn}. ` +
72
+ `Update it, then ${rerun}`, 'harness_too_old');
73
+ }
74
+ throw new ConnectStepError(`Connecting ${client.displayName} needs a TinyFish API key, and ${noSignIn}. ` +
75
+ `Run: tinyfish auth login, then ${rerun}`, 'invalid_config');
76
+ }
77
+ /** Resolved before the removals, so a refusal strands no registration. */
78
+ function keylessAddArgs(client, keyAuthSupported, oauthResource) {
79
+ if (!client.addArgs)
80
+ refuseKeylessInstall(client, keyAuthSupported);
81
+ const addArgs = client.addArgs;
82
+ return (mcpUrl) => addArgs(mcpUrl, oauthResource);
83
+ }
51
84
  /** Runs before any write: a refused key strands nothing. */
52
- async function verifyKeyBeforeSeeding(apiKey, displayName) {
53
- const verified = await verifyMcpAuth(apiKey);
85
+ async function verifyKeyBeforeSeeding(apiKey, displayName, mcpUrl) {
86
+ // One host serves `/mcp` and `/v1`, so `--url` decides where the key is checked.
87
+ const verified = await verifyMcpAuth(apiKey, apiBaseFromMcpUrl(mcpUrl));
54
88
  if (verified.ok)
55
89
  return;
56
90
  // No status means the network failed, not the key.
@@ -114,6 +148,124 @@ async function runMissingHarnessFallback(displayName, connectClient, state, tele
114
148
  errLine('The TinyFish CLI is installed and working.');
115
149
  errLine('Using a different agent? Run: tinyfish connect --all');
116
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
+ }
117
269
  async function connectNativeMcpClient(client, options) {
118
270
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
119
271
  const state = { stage: 'prerequisite_check', settled: false };
@@ -126,123 +278,27 @@ async function connectNativeMcpClient(client, options) {
126
278
  const { optionalSupported, keyAuthSupported, harnessVersion } = support;
127
279
  telemetry.setHarnessVersion(harnessVersion);
128
280
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
129
- // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
130
- const storedKey = validatedApiKey(options.apiKey);
131
- let keyedAdd = storedKey && keyAuthSupported && client.keyAuth
132
- ? { key: storedKey, spec: client.keyAuth }
133
- : undefined;
134
- // Before the removals: a failed export must not strand the old registration.
135
- if (keyedAdd?.spec.envVar &&
136
- !exportApiKey(keyedAdd.key, client.displayName, options.deferOutro)) {
137
- keyedAdd = undefined;
138
- }
139
- const useKeyAuth = !!keyedAdd;
140
- if (options.keyAuthOnly && !useKeyAuth) {
141
- // All native clients have keyAuth; missing flags mean an old install.
142
- if (client.keyAuth) {
143
- throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
144
- `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
145
- }
146
- throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
147
- `terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
148
- }
149
- // Off-default endpoints skip it: the check hits BASE_URL, not `--url`.
150
- if (keyedAdd?.spec.seedKey && isDefaultEndpoint(options.mcpUrl)) {
151
- await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName);
152
- }
281
+ const plan = await prepareInstallAuth(client, options, keyAuthSupported);
282
+ const useKeyAuth = !!plan.keyedAdd;
153
283
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
154
284
  state.stage = 'registration_cleanup';
155
285
  for (const removal of client.removals)
156
286
  removeExistingRegistration(client, removal, options.verbose ?? false);
157
- const mcpUrl = new URL(options.mcpUrl);
158
- mcpUrl.searchParams.set('source', CONNECT_SOURCE);
159
- mcpUrl.searchParams.set('client', client.connectClient);
160
- mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
161
287
  // Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
162
288
  const loginDegraded = state.harnessDegraded === true;
163
289
  const loginArgs = loginDegraded ? undefined : client.loginArgs;
164
290
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
165
291
  state.stage =
166
292
  loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
167
- errLine(`Adding TinyFish to ${client.displayName}...`);
168
- // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
169
- const addArgs = keyedAdd
170
- ? keyedAdd.spec.addArgs(mcpUrl.toString(), keyedAdd.key)
171
- : client.addArgs(mcpUrl.toString(), options.mcpUrl);
172
- // Capturing an inline-OAuth add hides the sign-in URL and hangs.
173
- const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
174
- // After the removals: an interrupt there leaves no key behind.
175
- const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
176
- const addOptions = interactiveAdd
177
- ? { stdio: 'inherit', timeout: options.authTimeoutMs }
178
- : seeded
179
- ? {
180
- encoding: 'utf8',
181
- // Without it, the 1 MiB default overflows into a misread Ctrl+C.
182
- maxBuffer: STEP_MAX_BUFFER,
183
- // The unattended path had no bound at all before; captureStdio's is the floor.
184
- timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
185
- input: seeded.stdinInput,
186
- env: seeded.env,
187
- }
188
- : captureStdio(options.verbose ?? false);
189
- const addResult = spawn.sync(client.command, addArgs, addOptions);
190
- // Rolls back first: signInStepError throws on a Ctrl+C.
191
- const failAdd = (build) => {
192
- seeded?.rollback();
193
- const error = build();
194
- replay(capturedOutput(addResult));
195
- throw error;
196
- };
197
- if (addResult.error || addResult.status !== 0) {
198
- failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
199
- }
200
- const registration = seeded?.confirmRegistered();
201
- if (registration && !registration.ok) {
202
- failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
203
- `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
204
- }
293
+ const seededNote = performMcpAdd(client, options, plan, telemetry.attemptId);
205
294
  telemetry.track('checkpoint', { phase: 'registered' });
206
- // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
207
- // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
208
- const addSignedIn = probeAuthenticated?.();
209
- if (addSignedIn)
210
- telemetry.track('checkpoint', { phase: 'oauth_done' });
211
- const degradedNotes = loginDegraded ? client.degradedAuthNotes : undefined;
212
- if (useKeyAuth) {
213
- errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
214
- // Every other credential the CLI writes into a user-owned file says so too.
215
- if (seeded)
216
- errLine(seeded.note);
217
- }
218
- else if (degradedNotes) {
219
- // Printed here, not with the closing line, so a `--launch` run sees it before the handover.
220
- errLine(degradedNotes.deferred);
221
- }
222
- // A probed client signs in again only when its inline OAuth is known to have been abandoned.
223
- const retryLogin = probeAuthenticated ? addSignedIn === false : true;
224
- const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
225
- let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
226
- if (pendingLogin) {
227
- state.stage = 'client_oauth';
228
- errLine('Signing in to TinyFish...');
229
- const loginResult = spawn.sync(client.command, pendingLogin, {
230
- stdio: 'inherit',
231
- timeout: options.authTimeoutMs,
232
- });
233
- if (loginResult.error || loginResult.status !== 0) {
234
- throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
235
- }
236
- if (probeAuthenticated?.() === false)
237
- signInDeferred = true;
238
- else
239
- telemetry.track('checkpoint', { phase: 'oauth_done' });
240
- }
241
- const authMode = useKeyAuth
242
- ? AuthMode.ApiKey
243
- : signInDeferred
244
- ? 'deferred'
245
- : AuthMode.OAuth;
295
+ const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
296
+ useKeyAuth,
297
+ loginDegraded,
298
+ loginArgs,
299
+ probeAuthenticated,
300
+ seededNote,
301
+ });
246
302
  state.authMode = authMode;
247
303
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
248
304
  settle(state, telemetry, 'completed', { authMode });
@@ -582,6 +638,89 @@ const CLIENT_CONNECTORS = {
582
638
  }
583
639
  },
584
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
+ }
585
724
  export function registerConnect(program) {
586
725
  program
587
726
  .command('connect')
@@ -599,94 +738,22 @@ export function registerConnect(program) {
599
738
  if (options.apiKey && !validateKeyFormat(options.apiKey)) {
600
739
  throw new Error('Invalid --api-key value');
601
740
  }
602
- let mcpUrl = options.url ?? DEFAULT_MCP_URL;
603
- if (mcpUrl.startsWith('-')) {
604
- throw new Error(`Invalid --url value: ${mcpUrl}`);
605
- }
606
- let parsedUrl;
607
- try {
608
- parsedUrl = new URL(mcpUrl);
609
- }
610
- catch {
611
- throw new Error(`Invalid --url value: ${mcpUrl}`);
612
- }
613
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
614
- throw new Error(`Invalid --url value: ${mcpUrl}`);
615
- }
616
- // Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
617
- // base — registration re-stamps it, and nothing else may carry it.
618
- const pageAttemptId = parsedUrl.searchParams.get('connect_attempt_id');
619
- let attemptId;
620
- if (pageAttemptId !== null) {
621
- parsedUrl.searchParams.delete('connect_attempt_id');
622
- mcpUrl = parsedUrl.toString();
623
- if (z.uuid().safeParse(pageAttemptId).success)
624
- attemptId = pageAttemptId;
625
- }
741
+ const { mcpUrl, attemptId } = parseMcpUrlOption(options);
626
742
  if (options.all) {
627
743
  if (client) {
628
744
  throw new Error('Pass either a client or --all, not both.');
629
745
  }
630
- if (options.launch) {
631
- throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
632
- }
633
- const exitCode = await runConnectAll({
634
- apiKey: options.apiKey,
635
- mcpUrl,
636
- dryRun: options.dryRun ?? false,
637
- uninstall: options.uninstall ?? false,
638
- noLaunch: options.skipLaunch === true,
639
- verbose: options.verbose ?? false,
640
- });
641
- // Preserve the 130 runGuarded set on Ctrl+C.
642
- if (!process.exitCode)
643
- process.exitCode = exitCode;
746
+ await connectAllPath(options, mcpUrl);
644
747
  return;
645
748
  }
646
749
  if (options.dryRun || options.uninstall || options.skipLaunch) {
647
750
  throw new Error('--dry-run, --uninstall and --skip-launch are only supported with --all.');
648
751
  }
649
752
  if (!client) {
650
- if (options.launch) {
651
- throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
652
- }
653
- const humanInitiated = detectHumanInitiated();
654
- if (!humanInitiated) {
655
- errLine('No terminal detected: connecting every detected harness (same as tinyfish connect --all).');
656
- }
657
- const exitCode = await runConnectAll({
658
- apiKey: options.apiKey,
659
- mcpUrl,
660
- dryRun: false,
661
- uninstall: false,
662
- pick: humanInitiated,
663
- verbose: options.verbose ?? false,
664
- });
665
- // Preserve the 130 runGuarded set on Ctrl+C.
666
- if (!process.exitCode)
667
- process.exitCode = exitCode;
753
+ await connectPickPath(options, mcpUrl);
668
754
  return;
669
755
  }
670
- const connectOptions = {
671
- apiKey: options.apiKey,
672
- mcpUrl,
673
- launch: options.launch ?? false,
674
- attemptId,
675
- // A key that the install cannot use leaves only a browser hop no agent can finish.
676
- keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
677
- // Only here: a missing harness rescues via the CLI (PF-3707).
678
- fallbackWhenMissing: true,
679
- verbose: options.verbose ?? false,
680
- };
681
- const connector = CLIENT_CONNECTORS[client];
682
- if (connector) {
683
- await connector(connectOptions);
684
- }
685
- else {
686
- throw new Error('Unsupported client: ' +
687
- client +
688
- '. Supported clients: claude-code, codex, cursor, grok, hermes, openclaw, opencode');
689
- }
756
+ await connectSingleClient(client, options, mcpUrl, attemptId);
690
757
  // Deferred to here so the notice lands after install output, not interleaved with it.
691
758
  emitNotice();
692
759
  });
@@ -7,7 +7,7 @@ export declare function buildConnectHeaders(connect: ConnectMap, caller: string
7
7
  export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
8
8
  export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
9
9
  export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
10
- export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number): Promise<RunListResponse>;
10
+ export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number, baseUrl?: string): Promise<RunListResponse>;
11
11
  export declare function getRun(runId: string, apiKey: string): Promise<Run>;
12
12
  export declare function getRunSteps(runId: string, apiKey: string): Promise<RunStepsResponse>;
13
13
  export declare function searchQuery(params: SearchQueryParams, apiKey: string): Promise<SearchQueryResponse>;
@@ -93,10 +93,12 @@ class TinyFishCliClient extends TinyFish {
93
93
  return (await response.json());
94
94
  }
95
95
  }
96
- function createSdkClient(apiKey, timeout) {
96
+ function createSdkClient(apiKey, timeout, baseUrl) {
97
+ // An explicit base outranks the env default.
98
+ const resolvedBase = baseUrl ?? API_URL_OVERRIDE;
97
99
  return new TinyFishCliClient({
98
100
  apiKey,
99
- ...(API_URL_OVERRIDE !== undefined ? { baseURL: API_URL_OVERRIDE } : {}),
101
+ ...(resolvedBase !== undefined ? { baseURL: resolvedBase } : {}),
100
102
  maxRetries: 0,
101
103
  // Fires per HTTP attempt, but maxRetries is 0 and the notice latches once per invocation.
102
104
  onResponse: ({ headers }) => captureNotice(headers),
@@ -263,9 +265,9 @@ export async function* runStream(req, apiKey, signal) {
263
265
  }
264
266
  }
265
267
  }
266
- export async function listRuns(opts, apiKey, timeout) {
268
+ export async function listRuns(opts, apiKey, timeout, baseUrl) {
267
269
  try {
268
- return await createSdkClient(apiKey, timeout).runs.list(opts);
270
+ return await createSdkClient(apiKey, timeout, baseUrl).runs.list(opts);
269
271
  }
270
272
  catch (error) {
271
273
  rethrowSdkError(error);
@@ -40,6 +40,8 @@ const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
40
40
  const native = NATIVE_BY_HARNESS.get(harness);
41
41
  return !!native?.loginArgs || !!native?.oauthInAdd;
42
42
  }));
43
+ // Derived: these refuse a keyless install, so no sign-in helps.
44
+ const KEY_REQUIRED = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyRequired));
43
45
  // Derived: a native add carries the key, or the CLI holds it itself.
44
46
  const KEY_AUTH_CAPABLE = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyAuth || HARNESS_CAPABILITIES[harness].keyHeldByCli));
45
47
  // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
@@ -311,7 +313,8 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
311
313
  }
312
314
  headlessAuth = true;
313
315
  }
314
- if (isTTY && !keyed && prompt) {
316
+ // Key-required harnesses refuse below; offering a browser sign-in would mislead.
317
+ if (isTTY && !keyed && prompt && !KEY_REQUIRED.has(harness)) {
315
318
  errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
316
319
  try {
317
320
  await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
@@ -34,7 +34,7 @@ export interface SeededInstall {
34
34
  detail?: string;
35
35
  };
36
36
  }
37
- export interface NativeMcpClient extends SupportedCommand {
37
+ interface BaseMcpClient extends SupportedCommand {
38
38
  connectClient: Exclude<AgentClient, 'openclaw'>;
39
39
  /** Omitted where the `skills` CLI has no agent for this harness (Grok ships its own plugin). */
40
40
  skillAgent?: SkillAgent;
@@ -62,7 +62,6 @@ export interface NativeMcpClient extends SupportedCommand {
62
62
  args: string[];
63
63
  label: string;
64
64
  }[];
65
- addArgs: (mcpUrl: string, oauthResource: string) => string[];
66
65
  /** Blocking terminal walkthrough; runs only behind the TTY gate. */
67
66
  launchWalkthrough?: () => void;
68
67
  /** Own window, never inherits stdio; reports its own outcome. */
@@ -70,6 +69,21 @@ export interface NativeMcpClient extends SupportedCommand {
70
69
  /** Printed after a successful connect, on both the launch and non-launch paths. */
71
70
  postConnectNote?: string;
72
71
  }
72
+ /** Keyless installs use this argv; the harness signs itself in. */
73
+ export interface OauthCapableMcpClient extends BaseMcpClient {
74
+ keyRequired?: false;
75
+ addArgs: (mcpUrl: string, oauthResource: string) => string[];
76
+ }
77
+ /** No sign-in exists here, so connect refuses a keyless install (Hermes). */
78
+ export interface KeyRequiredMcpClient extends BaseMcpClient {
79
+ keyRequired: true;
80
+ keyAuth: NonNullable<BaseMcpClient['keyAuth']>;
81
+ /** Restoring any of these three would restore the OAuth fallback. */
82
+ addArgs?: never;
83
+ oauthInAdd?: never;
84
+ loginArgs?: never;
85
+ }
86
+ export type NativeMcpClient = OauthCapableMcpClient | KeyRequiredMcpClient;
73
87
  export declare const CLAUDE_CODE: NativeMcpClient;
74
88
  /**
75
89
  * `cmd` re-parses its command line after `/c`, so an unquoted `&` in the query string ends the
@@ -84,7 +98,7 @@ export declare function hermesRegistrationEnabled(home: string): {
84
98
  ok: boolean;
85
99
  detail?: string;
86
100
  };
87
- export declare const HERMES: NativeMcpClient;
101
+ export declare const HERMES: KeyRequiredMcpClient;
88
102
  export declare const OPENCODE: NativeMcpClient;
89
103
  /** Every native MCP client. Derive from this, so a new harness cannot be missed. */
90
104
  export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
@@ -291,6 +291,7 @@ export const HERMES = {
291
291
  connectClient: 'hermes',
292
292
  skillAgent: 'hermes-agent',
293
293
  displayName: 'Hermes',
294
+ keyRequired: true,
294
295
  supportCheck: {
295
296
  args: ['mcp', 'add', '--help'],
296
297
  patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
@@ -303,9 +304,6 @@ export const HERMES = {
303
304
  seedKey: seedHermesKey,
304
305
  },
305
306
  removals: [{ args: ['mcp', 'remove', 'tinyfish'], label: 'TinyFish registration from Hermes' }],
306
- // Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
307
- oauthInAdd: true,
308
- addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), '--auth', 'oauth'],
309
307
  launchWalkthrough: launchHermesWalkthrough,
310
308
  postConnectNote: 'Restart your Hermes session to pick up TinyFish — Hermes discovers MCP servers at startup.',
311
309
  };
@@ -1,4 +1,6 @@
1
1
  export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
2
2
  export declare function endpointOf(url: string): string;
3
- /** `verifyMcpAuth` hits BASE_URL, so a sandbox key would 401 against prod. */
3
+ /** One host serves `/mcp` and `/v1`, so origin is the API base. */
4
+ export declare function apiBaseFromMcpUrl(mcpUrl: string): string | undefined;
5
+ /** doctor reuses the CLI's own BASE_URL check; off-default endpoints cannot. */
4
6
  export declare function isDefaultEndpoint(mcpUrl: string): boolean;
@@ -4,7 +4,16 @@ export function endpointOf(url) {
4
4
  const parsed = new URL(url);
5
5
  return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}`;
6
6
  }
7
- /** `verifyMcpAuth` hits BASE_URL, so a sandbox key would 401 against prod. */
7
+ /** One host serves `/mcp` and `/v1`, so origin is the API base. */
8
+ export function apiBaseFromMcpUrl(mcpUrl) {
9
+ try {
10
+ return new URL(mcpUrl).origin;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ /** doctor reuses the CLI's own BASE_URL check; off-default endpoints cannot. */
8
17
  export function isDefaultEndpoint(mcpUrl) {
9
18
  try {
10
19
  return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
@@ -11,4 +11,4 @@ export interface VerifyResult {
11
11
  /** Reachability check. Verify failure is a warning, never install failure. */
12
12
  export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
13
13
  /** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
14
- export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;
14
+ export declare function verifyMcpAuth(apiKey: string, apiBaseUrl?: string): Promise<VerifyResult>;
@@ -62,10 +62,10 @@ export async function verifyMcpHealth(mcpUrl) {
62
62
  }
63
63
  }
64
64
  /** Authenticated check — where the CLI holds the key (Cursor, OpenClaw, Hermes). */
65
- export async function verifyMcpAuth(apiKey) {
65
+ export async function verifyMcpAuth(apiKey, apiBaseUrl) {
66
66
  try {
67
67
  // Bound it: the SDK default is 600s, and a warning-only check must never hang an install.
68
- await listRuns({ limit: 1 }, apiKey, VERIFY_TIMEOUT_MS);
68
+ await listRuns({ limit: 1 }, apiKey, VERIFY_TIMEOUT_MS, apiBaseUrl);
69
69
  return { depth: 'health+auth', ok: true };
70
70
  }
71
71
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.30.2-next.265",
3
+ "version": "0.30.2-next.267",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {