@tiny-fish/cli 0.52.5-next.436 → 0.52.5-next.437

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.
package/README.md CHANGED
@@ -26,11 +26,12 @@ anything. Terminals without raw-key support get a numbered prompt instead.
26
26
  Try Search without an account; run `tinyfish auth login` and reconnect to unlock all tools:
27
27
 
28
28
  ```bash
29
- npx -y @tiny-fish/cli@latest connect opencode --launch
30
29
  npx -y @tiny-fish/cli@latest connect hermes --launch
31
30
  npx -y @tiny-fish/cli@latest connect omp --launch
32
31
  ```
33
32
 
33
+ `connect opencode` signs you in to a TinyFish account instead: it opens your browser, or prints a sign-in link when an agent runs it.
34
+
34
35
  Without a terminal (agents, CI), bare `connect` behaves exactly like `tinyfish connect --all`
35
36
  and says so on its first line: harnesses that can take your API key (via `TINYFISH_API_KEY` or
36
37
  `--api-key`) connect first, then the first harness that needs a browser sign-in opens your
@@ -1,6 +1,6 @@
1
1
  import { NATIVE_BY_HARNESS } from './connect-clients.js';
2
2
  import { harnessSpec } from './harness/harness-spec.js';
3
- import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, } from './connect-runtime.js';
3
+ import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, probeSupportVariant, } from './connect-runtime.js';
4
4
  import { ALL_HARNESSES, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, } from './harness/harness-detect.js';
5
5
  import { errLine } from './output.js';
6
6
  // Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
@@ -23,6 +23,19 @@ const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
23
23
  }));
24
24
  // Derived: these refuse a keyless install, so no sign-in helps.
25
25
  const KEY_REQUIRED = new Set(ALL_HARNESSES.filter((harness) => !!harnessSpec(harness).keyRequired));
26
+ /** OpenCode 2.x prints a sign-in link and exits, so it waits on no browser. */
27
+ function printsSignInLink(harness) {
28
+ const native = NATIVE_BY_HARNESS.get(harness);
29
+ if (!native?.detachedLoginVariant)
30
+ return false;
31
+ try {
32
+ return probeSupportVariant(native) === native.detachedLoginVariant;
33
+ }
34
+ catch {
35
+ // Routing only: connect itself reports the probe failure.
36
+ return false;
37
+ }
38
+ }
26
39
  function headlessGate(base, oauthBudget, fixCommand) {
27
40
  const { harness } = base;
28
41
  // A CLI-login sign-in is interactive-only — unboundable headless.
@@ -33,6 +46,8 @@ function headlessGate(base, oauthBudget, fixCommand) {
33
46
  spentBrowserSlot: false,
34
47
  };
35
48
  }
49
+ if (printsSignInLink(harness))
50
+ return { headlessAuth: true, spentBrowserSlot: false };
36
51
  let spentBrowserSlot = false;
37
52
  if (BROWSER_SIGN_IN.has(harness)) {
38
53
  // One browser sign-in per run; the rest wait for a re-run.
@@ -39,7 +39,7 @@ interface SeededInstall {
39
39
  tag?: string;
40
40
  };
41
41
  }
42
- interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'degradedAuthNotes' | 'postConnectNote' | 'verifyListed'> {
42
+ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent' | 'nonInteractiveAdd' | 'signInHint' | 'detachedLoginVariant' | 'cliKeyOptional' | 'degradedAuthNotes' | 'postConnectNote' | 'verifyListed'> {
43
43
  connectClient: Exclude<AgentClient, 'openclaw'>;
44
44
  /** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
45
45
  keyAuth?: {
@@ -49,7 +49,6 @@ interface BaseMcpClient extends SupportedCommand, Pick<HarnessSpec, 'skillAgent'
49
49
  /** Writes into the harness's store, so connect verifies the key first. */
50
50
  seedKey?: (apiKey: string) => SeededInstall;
51
51
  };
52
- keylessAddArgs?: (mcpUrl: string) => string[];
53
52
  prepareKeylessInstall?: () => DirectInstall;
54
53
  loginArgs?: string[];
55
54
  removals: {
@@ -4,7 +4,7 @@ import { detectHumanInitiated } from './harness.js';
4
4
  import { harnessConfigPath } from './harness/harness-detect.js';
5
5
  import { errLine, sanitizeLine, warnLine } from './output.js';
6
6
  import { ConnectInterruptedError, ConnectStepError, spawnStepError, } from './connect-runtime.js';
7
- import { TINYFISH_ACCESS_MODE_HEADER, TINYFISH_API_KEY_VAR, TINYFISH_KEYLESS_ACCESS_MODE, } from './constants.js';
7
+ import { TINYFISH_API_KEY_VAR } from './constants.js';
8
8
  import { HARNESS_SPECS, NATIVE_HARNESSES, harnessSpec, } from './harness/harness-spec.js';
9
9
  import { HERMES_KEY_VAR, captureHermesKeyRestore, hermesEnvPath, resolveHermesHome, writeHermesKey, } from './hermes-env.js';
10
10
  import { hermesConfigPath, readHermesEntry } from './config/hermes-config.js';
@@ -256,13 +256,6 @@ function specKeyAuth(spec) {
256
256
  addArgs: (mcpUrl, apiKey) => [...specAddArgs(spec, mcpUrl), '--header', value(apiKey)],
257
257
  };
258
258
  }
259
- function specKeylessAddArgs(spec) {
260
- const { header } = spec;
261
- if (!spec.keylessFallback || !header)
262
- return undefined;
263
- const value = `${TINYFISH_ACCESS_MODE_HEADER}${header.sep}${TINYFISH_KEYLESS_ACCESS_MODE}`;
264
- return (mcpUrl) => specAddArgs(spec, mcpUrl).concat('--header', value);
265
- }
266
259
  /** Descriptor behaviour the spec cannot hold; every other field is spec data. */
267
260
  const HARNESS_OVERRIDES = {
268
261
  cline: { launchWalkthrough: launchCline },
@@ -291,9 +284,10 @@ function toNativeClient(harness) {
291
284
  skillAgent: spec.skillAgent,
292
285
  nonInteractiveAdd: spec.nonInteractiveAdd,
293
286
  keyAuth,
294
- keylessAddArgs: specKeylessAddArgs(spec),
295
287
  prepareKeylessInstall: overrides.prepareKeylessInstall,
296
288
  signInHint: spec.signInHint,
289
+ detachedLoginVariant: spec.detachedLoginVariant,
290
+ cliKeyOptional: spec.cliKeyOptional,
297
291
  degradedAuthNotes: spec.degradedAuthNotes,
298
292
  removals: spec.removals ?? [
299
293
  {
@@ -4,6 +4,7 @@ import { KEYLESS_ONBOARDING_PROMPT, launchNativeMcpClient, } from './connect-cli
4
4
  import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
5
5
  import { ensureCliAuthenticated } from './connect-auth.js';
6
6
  import { installWebSkill } from './skill-install.js';
7
+ import { waitForSignInUrl } from './sign-in-url.js';
7
8
  import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, runGuarded, settle, SignInTimeoutError, spawnRemoval, spawnStepError, } from './connect-runtime.js';
8
9
  import { DASHBOARD_URL, TINYFISH_API_KEY_VAR } from './constants.js';
9
10
  import { strayProjectKeyFiles } from './config/opencode-config.js';
@@ -194,20 +195,12 @@ async function prepareInstallAuth(client, options, keyAuthSupported, matchedVari
194
195
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
195
196
  const storedKey = validatedApiKey(options.apiKey);
196
197
  const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.keyAuthOnly === true, options.deferOutro);
197
- const keylessArgs = !storedKey ? client.keylessAddArgs : undefined;
198
198
  const directInstall = !storedKey ? client.prepareKeylessInstall?.() : undefined;
199
- let keyless = !!directInstall;
199
+ const keyless = !!directInstall;
200
200
  let buildAddArgs;
201
201
  if (keyedAdd) {
202
202
  buildAddArgs = (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key);
203
203
  }
204
- else if (keylessArgs && keyAuthSupported) {
205
- buildAddArgs = keylessArgs;
206
- keyless = true;
207
- }
208
- else if (keylessArgs && options.authTimeoutMs !== undefined) {
209
- refuseKeylessInstall(client, false, true);
210
- }
211
204
  else if (!directInstall) {
212
205
  buildAddArgs = keylessAddArgs(client, keyAuthSupported);
213
206
  }
@@ -302,6 +295,24 @@ function performLoginStep(client, pendingLogin, options) {
302
295
  throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
303
296
  }
304
297
  }
298
+ // Upper bound only: the URL normally prints within two seconds.
299
+ const SIGN_IN_URL_WAIT_MS = 15_000;
300
+ // 2.x's service lags `mcp add` by under a second (measured on 2.1.4).
301
+ const STALE_REGISTRATION_RETRIES = 3;
302
+ const STALE_REGISTRATION_DELAY_MS = 1_000;
303
+ /** Relays the sign-in link; the harness service finishes it after we exit. */
304
+ async function printDetachedLoginUrl(client, pendingLogin) {
305
+ errLine('Starting TinyFish sign-in...');
306
+ let run = await waitForSignInUrl(client.command, pendingLogin, SIGN_IN_URL_WAIT_MS);
307
+ for (let retry = 0; retry < STALE_REGISTRATION_RETRIES && run.output.includes('Integration not found'); retry++) {
308
+ await new Promise((resolve) => setTimeout(resolve, STALE_REGISTRATION_DELAY_MS));
309
+ run = await waitForSignInUrl(client.command, pendingLogin, SIGN_IN_URL_WAIT_MS);
310
+ }
311
+ if (!run.url) {
312
+ throw signInStepError(`Could not start TinyFish sign-in in ${client.displayName}`, run.result, true);
313
+ }
314
+ errLine(`Sign in to TinyFish: ${run.url}`);
315
+ }
305
316
  function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
306
317
  if (useKeyAuth) {
307
318
  errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
@@ -314,7 +325,20 @@ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
314
325
  errLine(degradedNotes.deferred);
315
326
  }
316
327
  }
317
- function completeAuthFlow(client, options, state, telemetry, ctx) {
328
+ /** Returns whether sign-in is still deferred afterwards. */
329
+ async function runPendingLogin(client, pendingLogin, run) {
330
+ run.state.stage = 'client_oauth';
331
+ if (run.ctx.detachedLogin) {
332
+ await printDetachedLoginUrl(client, pendingLogin);
333
+ return true;
334
+ }
335
+ performLoginStep(client, pendingLogin, run.options);
336
+ if (run.ctx.probeAuthenticated?.() === false)
337
+ return true;
338
+ run.telemetry.track('checkpoint', { phase: 'oauth_done' });
339
+ return false;
340
+ }
341
+ async function completeAuthFlow(client, options, state, telemetry, ctx) {
318
342
  const { useKeyAuth, useKeyless, loginDegraded, loginArgs, probeAuthenticated } = ctx;
319
343
  if (useKeyless) {
320
344
  errLine('Using keyless TinyFish Search — no account or browser sign-in needed.');
@@ -329,15 +353,9 @@ function completeAuthFlow(client, options, state, telemetry, ctx) {
329
353
  // A probed client signs in again only when its inline OAuth is known to have been abandoned.
330
354
  const retryLogin = probeAuthenticated ? addSignedIn === false : true;
331
355
  const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
332
- let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
333
- if (pendingLogin) {
334
- state.stage = 'client_oauth';
335
- performLoginStep(client, pendingLogin, options);
336
- if (probeAuthenticated?.() === false)
337
- signInDeferred = true;
338
- else
339
- telemetry.track('checkpoint', { phase: 'oauth_done' });
340
- }
356
+ const signInDeferred = pendingLogin
357
+ ? await runPendingLogin(client, pendingLogin, { options, state, telemetry, ctx })
358
+ : !useKeyAuth && !addSignedIn;
341
359
  const authMode = useKeyAuth
342
360
  ? AuthMode.ApiKey
343
361
  : signInDeferred
@@ -380,11 +398,12 @@ export async function connectNativeMcpClient(unpinned, options) {
380
398
  for (const stray of twoPointX ? strayProjectKeyFiles() : []) {
381
399
  warnLine(opencodeStrayKeyNote(stray, DASHBOARD_URL));
382
400
  }
383
- const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
401
+ const { authMode, signInDeferred } = await completeAuthFlow(client, options, state, telemetry, {
384
402
  useKeyAuth,
385
403
  useKeyless,
386
404
  loginDegraded,
387
405
  loginArgs,
406
+ detachedLogin: !!client.detachedLoginVariant && matchedVariant === client.detachedLoginVariant,
388
407
  probeAuthenticated,
389
408
  seededNote,
390
409
  });
@@ -411,6 +430,28 @@ function connectedLine(client, signInDeferred) {
411
430
  return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
412
431
  `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
413
432
  }
433
+ /** Right after OAuth, a key prompt or failure reads as a second sign-in. */
434
+ function offerCliSignIn(client, options, telemetry) {
435
+ try {
436
+ ensureCliAuthenticated(client.connectClient, options.apiKey, { interactiveLogin: false });
437
+ telemetry.track('checkpoint', { phase: 'authenticated' });
438
+ }
439
+ catch (error) {
440
+ if (error instanceof ConnectInterruptedError)
441
+ throw error;
442
+ errLine('To use TinyFish from the terminal too, run: tinyfish auth login');
443
+ }
444
+ }
445
+ function authenticateCli(client, options, state, telemetry) {
446
+ if (client.cliKeyOptional && state.authMode !== AuthMode.ApiKey) {
447
+ offerCliSignIn(client, options, telemetry);
448
+ return;
449
+ }
450
+ ensureCliAuthenticated(client.connectClient, options.apiKey, {
451
+ interactiveLogin: options.authTimeoutMs === undefined,
452
+ });
453
+ telemetry.track('checkpoint', { phase: 'authenticated' });
454
+ }
414
455
  // Runs after `completed` settled: steps fail alone, the rest still run.
415
456
  async function runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome) {
416
457
  let failed = false;
@@ -450,12 +491,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
450
491
  });
451
492
  }
452
493
  if (state.authMode !== AuthMode.Keyless) {
453
- await attempt('authentication', () => {
454
- ensureCliAuthenticated(client.connectClient, options.apiKey, {
455
- interactiveLogin: options.authTimeoutMs === undefined,
456
- });
457
- telemetry.track('checkpoint', { phase: 'authenticated' });
458
- });
494
+ await attempt('authentication', () => authenticateCli(client, options, state, telemetry));
459
495
  }
460
496
  if (client.postConnectNote)
461
497
  errLine(client.postConnectNote);
@@ -56,6 +56,10 @@ export interface HarnessSpec {
56
56
  keyRequired?: true;
57
57
  keylessFallback?: true;
58
58
  loginArgs?: string[];
59
+ /** After this harness's OAuth, connect never prompts for or requires the CLI key. */
60
+ cliKeyOptional?: true;
61
+ /** With this support variant, the sign-in callback outlives `loginArgs`. */
62
+ detachedLoginVariant?: string;
59
63
  /** Default: one `mcp remove tinyfish` labeled from displayName. */
60
64
  removals?: {
61
65
  args: string[];
@@ -295,7 +299,6 @@ export declare const HARNESS_SPECS: {
295
299
  reloadAction: string;
296
300
  skillAgent: "opencode";
297
301
  nonInteractiveAdd: true;
298
- keylessFallback: true;
299
302
  supportCheck: {
300
303
  args: string[];
301
304
  patterns: RegExp[];
@@ -310,6 +313,9 @@ export declare const HARNESS_SPECS: {
310
313
  sep: "=";
311
314
  };
312
315
  loginArgs: string[];
316
+ detachedLoginVariant: string;
317
+ cliKeyOptional: true;
318
+ signInHint: string;
313
319
  removals: never[];
314
320
  postConnectNote: string;
315
321
  verifyListed: true;
@@ -221,7 +221,6 @@ export const HARNESS_SPECS = {
221
221
  reloadAction: 'restart it',
222
222
  skillAgent: 'opencode',
223
223
  nonInteractiveAdd: true,
224
- keylessFallback: true,
225
224
  supportCheck: {
226
225
  args: ['mcp', 'add', '--help'],
227
226
  patterns: [/--url(?:\s|$)/m],
@@ -237,6 +236,10 @@ export const HARNESS_SPECS = {
237
236
  header: { name: 'X-API-Key', sep: '=' },
238
237
  // OpenCode writes its config itself; `mcp auth` is the separate OAuth step.
239
238
  loginArgs: ['mcp', 'auth', 'tinyfish'],
239
+ // 2.x's background service, not `mcp auth`, catches the OAuth callback.
240
+ detachedLoginVariant: '--global',
241
+ cliKeyOptional: true,
242
+ signInHint: 'TinyFish is configured in OpenCode. Open the sign-in link above, then restart OpenCode.',
240
243
  // `opencode mcp add` upserts by name and ships no `mcp remove`.
241
244
  removals: [],
242
245
  postConnectNote: OPENCODE_MODEL_NOTE,
@@ -0,0 +1,8 @@
1
+ import type { SpawnStepResult } from './connect-runtime.js';
2
+ export interface SignInUrlRun {
3
+ url?: string;
4
+ output: string;
5
+ result: SpawnStepResult;
6
+ }
7
+ /** Resolves on the first printed URL; SIGKILL, because SIGTERM cancels OpenCode's sign-in. */
8
+ export declare function waitForSignInUrl(command: string, args: readonly string[], timeoutMs: number): Promise<SignInUrlRun>;
@@ -0,0 +1,35 @@
1
+ import spawn from 'cross-spawn';
2
+ // Trailing whitespace proves the URL arrived whole, not cut mid-chunk.
3
+ const SIGN_IN_URL = /https:\/\/\S+(?=\s)/;
4
+ /** Resolves on the first printed URL; SIGKILL, because SIGTERM cancels OpenCode's sign-in. */
5
+ export function waitForSignInUrl(command, args, timeoutMs) {
6
+ return new Promise((resolve) => {
7
+ const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
8
+ let output = '';
9
+ let settled = false;
10
+ const settle = (result) => {
11
+ if (settled)
12
+ return;
13
+ settled = true;
14
+ clearTimeout(timer);
15
+ child.kill('SIGKILL');
16
+ resolve({
17
+ url: output.match(SIGN_IN_URL)?.[0],
18
+ output,
19
+ result: { ...result, stdout: output },
20
+ });
21
+ };
22
+ const timer = setTimeout(() => {
23
+ settle({ status: null, error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) });
24
+ }, timeoutMs);
25
+ const onData = (chunk) => {
26
+ output += chunk.toString();
27
+ if (SIGN_IN_URL.test(output))
28
+ settle({ status: 0 });
29
+ };
30
+ child.stdout?.on('data', onData);
31
+ child.stderr?.on('data', onData);
32
+ child.on('error', (error) => settle({ status: null, error }));
33
+ child.on('close', (status, signal) => settle({ status, signal }));
34
+ });
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.52.5-next.436",
3
+ "version": "0.52.5-next.437",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {