@tiny-fish/cli 0.43.0 → 0.45.0

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
@@ -23,6 +23,14 @@ omp, OpenClaw, OpenCode) and opens a checklist with everything pre-selected. Pre
23
23
  them all, or use the arrow keys and space to narrow the set first; `q` cancels without touching
24
24
  anything. Terminals without raw-key support get a numbered prompt instead.
25
25
 
26
+ Try Search without an account; run `tinyfish auth login` and reconnect to unlock all tools:
27
+
28
+ ```bash
29
+ npx -y @tiny-fish/cli@latest connect opencode --launch
30
+ npx -y @tiny-fish/cli@latest connect hermes --launch
31
+ npx -y @tiny-fish/cli@latest connect omp --launch
32
+ ```
33
+
26
34
  Without a terminal (agents, CI), bare `connect` behaves exactly like `tinyfish connect --all`
27
35
  and says so on its first line: harnesses that can take your API key (via `TINYFISH_API_KEY` or
28
36
  `--api-key`) connect first, then the first harness that needs a browser sign-in opens your
@@ -85,17 +93,13 @@ the `use-tinyfish` skill this command installs for the other harnesses.
85
93
 
86
94
  ### Connect Hermes
87
95
 
88
- Add TinyFish MCP and the global `use-tinyfish` web skill, authenticate the CLI, and start the
89
- interactive walkthrough with one command:
96
+ Add TinyFish MCP and the global `use-tinyfish` web skill, then start the interactive walkthrough:
90
97
 
91
98
  ```bash
92
- npx -y @tiny-fish/cli@latest connect hermes --launch --api-key sk-tinyfish-...
99
+ npx -y @tiny-fish/cli@latest connect hermes --launch
93
100
  ```
94
101
 
95
- Hermes completes OAuth while adding the MCP server. `hermes mcp add` accepts no header flag, so
96
- `--api-key` cannot replace the Hermes sign-in — its only header path, `--auth header`, prompts
97
- interactively. After setup, the command starts the walkthrough in a Hermes session and leaves that
98
- session open for your replies.
102
+ Without a key, Hermes gets Search. A key enables all tools without browser sign-in.
99
103
 
100
104
  ### Connect Command Code
101
105
 
@@ -1,6 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.js';
3
- import { CURSOR_SKILL_TARGET, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchPiWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
3
+ import { CURSOR_SKILL_TARGET, KEYLESS_ONBOARDING_PROMPT, OPENCLAW, openclawSkillInstallArgs, NATIVE_BY_HARNESS, launchNativeMcpClient, launchOmpWalkthrough, launchPiWalkthrough, launchOpenClawWalkthrough, openExternalUrl, } from '../lib/connect-clients.js';
4
4
  import { captureStdio, capturedOutput, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
5
5
  import { ensureCliAuthenticated } from '../lib/connect-auth.js';
6
6
  import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
@@ -17,7 +17,7 @@ import { emitNotice } from '../lib/notice.js';
17
17
  import { SIGN_IN_PROBES } from '../lib/registration-detect.js';
18
18
  import { errLine, warnLine } from '../lib/output.js';
19
19
  import { z } from 'zod';
20
- import { verifyMcpAuth } from '../lib/verify.js';
20
+ import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
21
21
  import { gateApiKey } from '../lib/connect-preflight.js';
22
22
  import { apiBaseFromMcpUrl, DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
23
23
  function removeExistingRegistration(client, removal, verbose) {
@@ -36,8 +36,8 @@ function exportApiKey(apiKey, displayName, deferOutro) {
36
36
  persistApiKeyToEnvironment(apiKey);
37
37
  }
38
38
  catch (error) {
39
- errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}); ` +
40
- 'signing in instead.');
39
+ // Cause only: headless refuses below, so this must not promise a sign-in (PF-3803).
40
+ errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}).`);
41
41
  return false;
42
42
  }
43
43
  process.env[TINYFISH_API_KEY_VAR] = apiKey;
@@ -51,13 +51,15 @@ function exportApiKey(apiKey, displayName, deferOutro) {
51
51
  }
52
52
  return true;
53
53
  }
54
- /** Undefined leaves the client on its keyless path, if any. */
55
- function resolveKeyedAdd(client, storedKey, keyAuthSupported, deferOutro) {
54
+ function resolveKeyedAdd(client, storedKey, keyAuthSupported, keyAuthOnly, deferOutro) {
56
55
  if (!storedKey || !keyAuthSupported || !client.keyAuth)
57
56
  return undefined;
58
57
  const keyed = { key: storedKey, spec: client.keyAuth };
59
58
  // Before the removals: a failed export must not strand the old registration.
60
59
  if (keyed.spec.envVar && !exportApiKey(keyed.key, client.displayName, deferOutro)) {
60
+ if (keyAuthOnly)
61
+ refuseFailedKeyExport(client);
62
+ errLine(`Signing in to ${client.displayName} instead.`);
61
63
  return undefined;
62
64
  }
63
65
  return keyed;
@@ -79,6 +81,12 @@ function refuseKeylessInstall(client, keyAuthSupported, headless = false) {
79
81
  throw new ConnectStepError(`Connecting ${client.displayName} needs a TinyFish API key, and ${noSignIn}. ` +
80
82
  `Run: tinyfish auth login, then ${rerun}`, 'invalid_config', { failureDetail: 'keyless_install_refused' });
81
83
  }
84
+ /** Headless has no sign-in to degrade to, and the harness version is not the fault (PF-3803). */
85
+ function refuseFailedKeyExport(client) {
86
+ // Never names a file: on Windows the write is `setx`, not a shell profile.
87
+ throw new ConnectStepError(`Could not save ${TINYFISH_API_KEY_VAR} to your environment, so ${client.displayName} has no ` +
88
+ `way to read your key. Fix that, then re-run: tinyfish connect ${client.connectClient}`, 'invalid_config', { failureDetail: 'key_export_failed' });
89
+ }
82
90
  /** Resolved before the removals, so a refusal strands no registration. */
83
91
  function keylessAddArgs(client, keyAuthSupported) {
84
92
  if (!client.addArgs)
@@ -100,6 +108,11 @@ async function verifyKeyBeforeSeeding(apiKey, displayName, mcpUrl) {
100
108
  throw new ConnectStepError(`Your TinyFish API key was rejected (${verified.code ?? 'unknown'}), so it was not ` +
101
109
  `written to ${displayName}. Run: tinyfish auth login`, 'invalid_config', { failureDetail: `key_rejected status=${verified.status}` });
102
110
  }
111
+ async function requireKeylessMcp(mcpUrl) {
112
+ if ((await verifyMcpHealth(mcpUrl, true)).ok)
113
+ return;
114
+ throw new ConnectStepError('TinyFish keyless Search is unavailable', 'invalid_config');
115
+ }
103
116
  /** A bounded spawn's timeout means the sign-in never finished. */
104
117
  function signInStepError(message, result, bounded) {
105
118
  const error = spawnStepError(message, result);
@@ -157,18 +170,31 @@ async function runMissingHarnessFallback(displayName, connectClient, state, tele
157
170
  async function prepareInstallAuth(client, options, keyAuthSupported) {
158
171
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
159
172
  const storedKey = validatedApiKey(options.apiKey);
160
- const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
161
- // Refuses here when key-required; others keep their OAuth argv.
162
- const buildAddArgs = keyedAdd
163
- ? (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key)
164
- : keylessAddArgs(client, keyAuthSupported);
173
+ const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.keyAuthOnly === true, options.deferOutro);
174
+ const keylessArgs = !storedKey ? client.keylessAddArgs : undefined;
175
+ const directInstall = !storedKey ? client.prepareKeylessInstall?.() : undefined;
176
+ let keyless = !!directInstall;
177
+ let buildAddArgs;
178
+ if (keyedAdd) {
179
+ buildAddArgs = (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key);
180
+ }
181
+ else if (keylessArgs && keyAuthSupported) {
182
+ buildAddArgs = keylessArgs;
183
+ keyless = true;
184
+ }
185
+ else if (keylessArgs && options.authTimeoutMs !== undefined) {
186
+ refuseKeylessInstall(client, false, true);
187
+ }
188
+ else if (!directInstall) {
189
+ buildAddArgs = keylessAddArgs(client, keyAuthSupported);
190
+ }
165
191
  if (options.keyAuthOnly && !keyedAdd)
166
192
  refuseKeylessInstall(client, keyAuthSupported, true);
167
193
  // Verify first: a revoked key in a foreign .env is unrecoverable.
168
194
  if (keyedAdd?.spec.seedKey) {
169
195
  await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
170
196
  }
171
- return { keyedAdd, buildAddArgs };
197
+ return { keyedAdd, keyless, buildAddArgs, directInstall };
172
198
  }
173
199
  /** Inherit for an inline-OAuth add; capture otherwise, piping prompts when seeded. */
174
200
  function addSpawnOptions(options, interactiveAdd, seeded) {
@@ -186,14 +212,25 @@ function addSpawnOptions(options, interactiveAdd, seeded) {
186
212
  env: seeded.env,
187
213
  };
188
214
  }
215
+ function performDirectInstall(client, install, mcpUrl) {
216
+ const registered = install.register(mcpUrl.toString());
217
+ if (registered.ok)
218
+ return { seededHome: install.home };
219
+ const detail = registered.detail ? `: ${registered.detail}` : '';
220
+ throw new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there${detail}.`, 'invalid_config', { failureDetail: registered.tag });
221
+ }
189
222
  /** Returns the seeded-credential note when a key was written into a user-owned file. */
190
223
  function performMcpAdd(client, options, plan, attemptId) {
191
- const { keyedAdd, buildAddArgs } = plan;
224
+ const { keyedAdd, buildAddArgs, directInstall } = plan;
192
225
  const mcpUrl = new URL(options.mcpUrl);
193
226
  mcpUrl.searchParams.set('source', CONNECT_SOURCE);
194
227
  mcpUrl.searchParams.set('client', client.connectClient);
195
228
  mcpUrl.searchParams.set('connect_attempt_id', attemptId);
196
229
  errLine(`Adding TinyFish to ${client.displayName}...`);
230
+ if (directInstall)
231
+ return performDirectInstall(client, directInstall, mcpUrl);
232
+ if (!buildAddArgs)
233
+ throw new Error(`No MCP add arguments exist for ${client.displayName}`);
197
234
  // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
198
235
  const addArgs = buildAddArgs(mcpUrl.toString());
199
236
  // Capturing an inline-OAuth add hides the sign-in URL and hangs.
@@ -247,7 +284,11 @@ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
247
284
  }
248
285
  }
249
286
  function completeAuthFlow(client, options, state, telemetry, ctx) {
250
- const { useKeyAuth, loginDegraded, loginArgs, probeAuthenticated } = ctx;
287
+ const { useKeyAuth, useKeyless, loginDegraded, loginArgs, probeAuthenticated } = ctx;
288
+ if (useKeyless) {
289
+ errLine('Using keyless TinyFish Search — no account or browser sign-in needed.');
290
+ return { authMode: AuthMode.Keyless, signInDeferred: false };
291
+ }
251
292
  // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
252
293
  // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
253
294
  const addSignedIn = probeAuthenticated?.();
@@ -287,6 +328,9 @@ async function connectNativeMcpClient(client, options) {
287
328
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
288
329
  const plan = await prepareInstallAuth(client, options, keyAuthSupported);
289
330
  const useKeyAuth = !!plan.keyedAdd;
331
+ const useKeyless = plan.keyless;
332
+ if (useKeyless)
333
+ await requireKeylessMcp(options.mcpUrl);
290
334
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
291
335
  state.stage = 'registration_cleanup';
292
336
  for (const removal of client.removals)
@@ -301,13 +345,14 @@ async function connectNativeMcpClient(client, options) {
301
345
  telemetry.track('checkpoint', { phase: 'registered' });
302
346
  const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
303
347
  useKeyAuth,
348
+ useKeyless,
304
349
  loginDegraded,
305
350
  loginArgs,
306
351
  probeAuthenticated,
307
352
  seededNote,
308
353
  });
309
354
  state.authMode = authMode;
310
- // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
355
+ // Registration/OAuth make MCP work; the steps below must not fail the attempt.
311
356
  settle(state, telemetry, 'completed', { authMode });
312
357
  await runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome);
313
358
  });
@@ -341,6 +386,8 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
341
386
  }
342
387
  };
343
388
  try {
389
+ // Above every step: a Ctrl+C below must not lose a registration already reported (PF-3803).
390
+ saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
344
391
  if (options.cliInstall !== false) {
345
392
  await attempt('cli_install', () => runCliInstallStep(options, telemetry));
346
393
  }
@@ -350,29 +397,24 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
350
397
  telemetry.track('checkpoint', { phase: 'skill_installed' });
351
398
  });
352
399
  }
353
- const extraPostInstall = client.extraPostInstall;
354
- if (extraPostInstall) {
400
+ if (client.extraPostInstall && state.authMode !== AuthMode.Keyless) {
355
401
  await attempt('plugin_install', () => {
356
- extraPostInstall({
402
+ const pluginVersion = client.extraPostInstall?.({
357
403
  apiKey: validatedApiKey(options.apiKey),
358
404
  verbose: options.verbose ?? false,
359
405
  seededHome,
360
406
  });
361
- telemetry.track('checkpoint', {
362
- phase: 'plugin_installed',
363
- pluginVersion: client.pluginVersion,
364
- });
407
+ telemetry.track('checkpoint', { phase: 'plugin_installed', pluginVersion });
365
408
  });
366
409
  }
367
- // Before auth: a failed sign-in must not lose the harness record.
368
- saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
369
- await attempt('authentication', () => {
370
- // A set timeout means headless; never shell an interactive login.
371
- ensureCliAuthenticated(client.connectClient, options.apiKey, {
372
- interactiveLogin: options.authTimeoutMs === undefined,
410
+ if (state.authMode !== AuthMode.Keyless) {
411
+ await attempt('authentication', () => {
412
+ ensureCliAuthenticated(client.connectClient, options.apiKey, {
413
+ interactiveLogin: options.authTimeoutMs === undefined,
414
+ });
415
+ telemetry.track('checkpoint', { phase: 'authenticated' });
373
416
  });
374
- telemetry.track('checkpoint', { phase: 'authenticated' });
375
- });
417
+ }
376
418
  if (client.postConnectNote)
377
419
  errLine(client.postConnectNote);
378
420
  if (!options.launch) {
@@ -389,7 +431,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
389
431
  telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
390
432
  // Flushed pre-block; the session-long handover would strand the delivery.
391
433
  await telemetry.flush();
392
- });
434
+ }, state.authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
393
435
  });
394
436
  }
395
437
  }
@@ -655,18 +697,26 @@ async function connectConfigFileHarness(harness, options) {
655
697
  await runGuarded(state, telemetry, async () => {
656
698
  telemetry.track('started');
657
699
  telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
658
- // Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
659
- state.stage = 'authentication';
660
- // A set timeout means headless; never shell an interactive login.
661
- ensureCliAuthenticated(harness, options.apiKey, {
662
- interactiveLogin: options.authTimeoutMs === undefined,
663
- });
664
- telemetry.track('checkpoint', { phase: 'authenticated' });
665
- const resolvedKey = validatedApiKey(options.apiKey);
666
- // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
667
- if (!resolvedKey) {
668
- errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
669
- }
700
+ const resolveAuth = async () => {
701
+ let resolvedKey = validatedApiKey(options.apiKey);
702
+ const keyless = !resolvedKey && harness === 'omp';
703
+ if (keyless) {
704
+ await requireKeylessMcp(options.mcpUrl);
705
+ }
706
+ else {
707
+ state.stage = 'authentication';
708
+ ensureCliAuthenticated(harness, options.apiKey, {
709
+ interactiveLogin: options.authTimeoutMs === undefined,
710
+ });
711
+ resolvedKey = validatedApiKey(options.apiKey);
712
+ telemetry.track('checkpoint', { phase: 'authenticated' });
713
+ if (!resolvedKey) {
714
+ errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
715
+ }
716
+ }
717
+ return { resolvedKey, keyless };
718
+ };
719
+ const { resolvedKey, keyless } = await resolveAuth();
670
720
  state.stage = 'registration';
671
721
  errLine(`Adding TinyFish to ${displayName}...`);
672
722
  const mcpUrl = new URL(options.mcpUrl);
@@ -686,12 +736,22 @@ async function connectConfigFileHarness(harness, options) {
686
736
  errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
687
737
  }
688
738
  telemetry.track('checkpoint', { phase: 'registered' });
689
- saveConnectContext(harness, telemetry.attemptId);
690
- settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
739
+ let authMode = keyless ? AuthMode.Keyless : 'deferred';
740
+ if (resolvedKey)
741
+ authMode = AuthMode.ApiKey;
742
+ state.authMode = authMode;
743
+ if (keyless)
744
+ saveConnectContext(harness, telemetry.attemptId, AuthMode.Keyless);
745
+ else
746
+ saveConnectContext(harness, telemetry.attemptId);
747
+ settle(state, telemetry, 'completed', { authMode });
691
748
  const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
692
- if (resolvedKey) {
749
+ if (keyless) {
750
+ errLine(`TinyFish keyless Search is connected in ${displayName}.`);
751
+ }
752
+ else if (resolvedKey) {
693
753
  // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
694
- const verify = await verifyMcpAuth(resolvedKey);
754
+ const verify = await verifyMcpAuth(resolvedKey, apiBaseFromMcpUrl(options.mcpUrl));
695
755
  if (verify.ok) {
696
756
  errLine(spec.copy.verified);
697
757
  }
@@ -710,6 +770,7 @@ async function connectConfigFileHarness(harness, options) {
710
770
  errLine(note);
711
771
  reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
712
772
  });
773
+ return state.authMode;
713
774
  }
714
775
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
715
776
  export async function connectCursor(options) {
@@ -751,10 +812,10 @@ const CONNECTOR_OVERRIDES = {
751
812
  return undefined;
752
813
  },
753
814
  omp: async (options) => {
754
- await connectConfigFileHarness('omp', options);
815
+ const authMode = await connectConfigFileHarness('omp', options);
755
816
  if (options.launch) {
756
817
  try {
757
- await launchOmpWalkthrough();
818
+ await launchOmpWalkthrough(undefined, authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
758
819
  }
759
820
  catch (error) {
760
821
  // Post-settle, like the native walkthrough step: never fail the connect.
@@ -762,7 +823,7 @@ const CONNECTOR_OVERRIDES = {
762
823
  errLine(finishSetupHint('omp'));
763
824
  }
764
825
  }
765
- return undefined;
826
+ return authMode;
766
827
  },
767
828
  pi: async (options) => {
768
829
  await connectConfigFileHarness('pi', options);
@@ -839,6 +900,7 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
839
900
  attemptId,
840
901
  // A key that the install cannot use leaves only a browser hop no agent can finish.
841
902
  keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
903
+ authTimeoutMs: detectHumanInitiated() ? undefined : 90_000,
842
904
  // Only here: a missing harness rescues via the CLI (PF-3707).
843
905
  fallbackWhenMissing: true,
844
906
  verbose: options.verbose ?? false,
@@ -8,21 +8,18 @@ import { err, errLine, out, outLine } from '../lib/output.js';
8
8
  import { detectRegistrations } from '../lib/registration-detect.js';
9
9
  import { sendDoctorCompleted } from '../lib/setup-telemetry.js';
10
10
  import { verifyMcpAuth } from '../lib/verify.js';
11
- import { HERMES_PLUGIN_VERSION } from '../lib/hermes-plugin.js';
12
11
  import { DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
13
- /** Skips mean no Hermes; stamping those buries the drift share. */
12
+ /** Skips mean no Hermes; stamping those buries the installed share. */
14
13
  function hermesPluginVersions(result) {
15
14
  if (!result || result.check.status === 'skip')
16
15
  return {};
17
- return {
18
- hermes_plugin_expected_version: HERMES_PLUGIN_VERSION,
19
- ...(result.observedVersion ? { hermes_plugin_version: result.observedVersion } : {}),
20
- };
16
+ return result.observedVersion ? { hermes_plugin_version: result.observedVersion } : {};
21
17
  }
22
18
  export async function runDoctor(options) {
23
19
  const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
24
20
  const credential = checkCredential();
25
- const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
21
+ const apiBase = new URL(options.mcpUrl).origin;
22
+ const cliAuth = credential.key ? verifyMcpAuth(credential.key, apiBase) : undefined;
26
23
  const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
27
24
  checkConnectivity(options.mcpUrl),
28
25
  cliAuth,
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { z } from 'zod';
4
4
  import { getApiKey } from '../lib/auth.js';
5
5
  import { cancelRun, runAsync, runStream, runSync } from '../lib/client.js';
6
- import { err, errLine, handleApiError, out, outLine } from '../lib/output.js';
6
+ import { err, errLine, handleApiError, out, outLine, warnLine } from '../lib/output.js';
7
7
  import { BASE_URL } from '../lib/constants.js';
8
8
  const VALID_BROWSER_PROFILES = ['lite', 'stealth'];
9
9
  const VALID_MODES = ['default', 'strict'];
@@ -257,7 +257,7 @@ async function runStreamPath(req, apiKey, pretty) {
257
257
  process.once('SIGINT', onSigint);
258
258
  let streamFailed = false;
259
259
  try {
260
- for await (const event of runStream(req, apiKey, controller.signal)) {
260
+ for await (const event of runStream(req, apiKey, controller.signal, warnDroppedEvents)) {
261
261
  if (!handleStreamEvent(event, pretty, (id) => {
262
262
  capturedRunId = id;
263
263
  })) {
@@ -292,6 +292,9 @@ async function runStreamPath(req, apiKey, pretty) {
292
292
  if (streamFailed)
293
293
  process.exit(1);
294
294
  }
295
+ function warnDroppedEvents(count) {
296
+ warnLine(`Warning: skipped ${count} malformed stream event${count === 1 ? '' : 's'}; output may be incomplete.`);
297
+ }
295
298
  export function registerRun(agentCmd) {
296
299
  const runCmd = agentCmd
297
300
  .command('run')
@@ -4,8 +4,9 @@ export declare function configFile(): string;
4
4
  /** Install channel baked onto connect context; the connect flow is always CLI-driven. */
5
5
  export declare const CONNECT_SOURCE = "tinyfish_cli";
6
6
  export declare const CONNECT_ATTEMPT_ENV = "TINYFISH_CONNECT_ATTEMPT_ID";
7
+ export declare const RUNTIME_ATTEMPT_ENV = "TINYFISH_RUNTIME_ATTEMPT_ID";
7
8
  /** Mirrors ConnectAuthMode; duplicated to keep auth.ts free of connect-runtime imports. */
8
- export type RecordedAuthMode = 'api-key' | 'oauth' | 'deferred';
9
+ export type RecordedAuthMode = 'api-key' | 'keyless' | 'oauth' | 'deferred';
9
10
  interface ConnectEntry {
10
11
  attempt_id: string;
11
12
  auth_mode?: RecordedAuthMode;
package/dist/lib/auth.js CHANGED
@@ -19,7 +19,9 @@ export const CONNECT_SOURCE = 'tinyfish_cli';
19
19
  // An env var, not a flag: released CLIs ignore an unknown one, and the installer's PATH
20
20
  // prepend means a copied command can still run a CLI far older than the page that wrote it.
21
21
  export const CONNECT_ATTEMPT_ENV = 'TINYFISH_CONNECT_ATTEMPT_ID';
22
- const RECORDED_AUTH_MODES = ['api-key', 'oauth', 'deferred'];
22
+ // Per-request; distinct from CONNECT_ATTEMPT_ENV (install seed, consumed once).
23
+ export const RUNTIME_ATTEMPT_ENV = 'TINYFISH_RUNTIME_ATTEMPT_ID';
24
+ const RECORDED_AUTH_MODES = ['api-key', 'keyless', 'oauth', 'deferred'];
23
25
  export function loadConfig() {
24
26
  try {
25
27
  const raw = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
@@ -4,10 +4,11 @@ type ConnectMap = Record<string, {
4
4
  attempt_id: string;
5
5
  }> | undefined;
6
6
  export declare function buildConnectHeaders(connect: ConnectMap, caller: string | null): Record<string, string>;
7
+ export declare function setupPageConnectHeaders(attemptId: string | undefined): Record<string, string>;
7
8
  export declare function resolveClientName(env?: Record<string, string | undefined>): string;
8
9
  export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
9
10
  export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
10
- export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
11
+ export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal, onDropped?: (count: number) => void): AsyncGenerator<AgentRunWithStreamingResponse>;
11
12
  export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number, baseUrl?: string): Promise<RunListResponse>;
12
13
  export declare function getRun(runId: string, apiKey: string): Promise<Run>;
13
14
  export declare function getRunSteps(runId: string, apiKey: string): Promise<RunStepsResponse>;
@@ -1,5 +1,5 @@
1
1
  import { APIStatusError, agentRunAsyncResponseSchema, agentRunResponseSchema, agentRunWithStreamingResponseSchema, browserSessionSchema, searchQueryResponseSchema, TinyFish, runSchema, runStatusSchema, } from '@tiny-fish/sdk';
2
- import { CONNECT_SOURCE, loadConfig } from './auth.js';
2
+ import { CONNECT_SOURCE, RUNTIME_ATTEMPT_ENV, loadConfig } from './auth.js';
3
3
  import { API_URL_OVERRIDE, CLI_AGENT_IDENTITY, CLI_VERSION } from './constants.js';
4
4
  import { detectHarness, detectHumanInitiated } from './harness.js';
5
5
  import { captureNotice } from './notice.js';
@@ -29,6 +29,15 @@ function connectHeadersFor(client, attemptId) {
29
29
  'X-TF-Connect-Attempt-Id': attemptId,
30
30
  };
31
31
  }
32
+ export function setupPageConnectHeaders(attemptId) {
33
+ if (!attemptId || !z.uuid().safeParse(attemptId).success)
34
+ return {};
35
+ return {
36
+ 'X-TF-Connect-Source': 'setup_page',
37
+ 'X-TF-Connect-Client': 'cli',
38
+ 'X-TF-Connect-Attempt-Id': attemptId,
39
+ };
40
+ }
32
41
  // Forwarded explicitly: the pinned published SDK may predate TF_CLIENT_* support.
33
42
  // Non-printable chars make fetch throw; 128 is the server cap (client_name varchar(128)).
34
43
  const sanitize = (value) => (value ?? '')
@@ -63,7 +72,12 @@ class TinyFishCliClient extends TinyFish {
63
72
  // Reuse the already-resolved client_name so connect_client can never diverge from it
64
73
  // (same sanitize + precedence, not a second raw read of TF_CLIENT_NAME).
65
74
  const connectCaller = clientName || null;
66
- for (const [key, value] of Object.entries(buildConnectHeaders(loadConfig().connect, connectCaller))) {
75
+ // Snippet env (per call) overrides stored install connect context.
76
+ const connectHeaders = {
77
+ ...buildConnectHeaders(loadConfig().connect, connectCaller),
78
+ ...setupPageConnectHeaders(process.env[RUNTIME_ATTEMPT_ENV]),
79
+ };
80
+ for (const [key, value] of Object.entries(connectHeaders)) {
67
81
  const clean = sanitize(value);
68
82
  if (clean)
69
83
  headers[key] = clean;
@@ -186,7 +200,14 @@ function normalizeStreamEvent(event) {
186
200
  error: typeof data['error'] === 'object' && data['error'] !== null ? data['error'] : null,
187
201
  };
188
202
  }
189
- async function* parseSseStream(stream) {
203
+ function isCompleteShaped(event) {
204
+ return (typeof event === 'object' && event !== null && event.type === 'COMPLETE');
205
+ }
206
+ function malformedCompleteMessage(event, lastRunId) {
207
+ const runId = typeof event['run_id'] === 'string' ? event['run_id'] : lastRunId;
208
+ return `Stream sent a COMPLETE event the CLI could not parse; run state unknown. Check it with \`tinyfish agent run get ${runId ?? '<run-id>'}\`.`;
209
+ }
210
+ async function* parseSseStream(stream, onUnparseableLine) {
190
211
  const reader = stream.getReader();
191
212
  let buffer = '';
192
213
  const parseLine = (rawLine) => {
@@ -197,6 +218,7 @@ async function* parseSseStream(stream) {
197
218
  return JSON.parse(line.slice('data:'.length).trim());
198
219
  }
199
220
  catch {
221
+ onUnparseableLine();
200
222
  return undefined;
201
223
  }
202
224
  };
@@ -238,23 +260,36 @@ export function runAsync(req, apiKey) {
238
260
  return parseWithSchema(agentRunAsyncResponseSchema, response, 'Invalid async run response');
239
261
  });
240
262
  }
241
- export async function* runStream(req, apiKey, signal) {
263
+ export async function* runStream(req, apiKey, signal, onDropped) {
242
264
  let stream = null;
265
+ let droppedEvents = 0;
266
+ let lastRunId = null;
243
267
  try {
244
268
  stream = await createSdkClient(apiKey).postStream('/v1/automation/run-sse', {
245
269
  json: req,
246
270
  signal,
247
271
  });
248
- for await (const event of parseSseStream(stream)) {
272
+ for await (const event of parseSseStream(stream, () => (droppedEvents += 1))) {
249
273
  const parsed = agentRunWithStreamingResponseSchema.safeParse(normalizeStreamEvent(event));
250
- if (parsed.success)
274
+ if (parsed.success) {
275
+ if ('run_id' in parsed.data)
276
+ lastRunId = parsed.data.run_id;
251
277
  yield parsed.data;
278
+ continue;
279
+ }
280
+ // A dropped COMPLETE means the run never visibly terminates.
281
+ if (isCompleteShaped(event))
282
+ throw new Error(malformedCompleteMessage(event, lastRunId));
283
+ droppedEvents += 1;
252
284
  }
253
285
  }
254
286
  catch (error) {
255
287
  rethrowSdkError(error);
256
288
  }
257
289
  finally {
290
+ // Runs on caller break too, so the count still surfaces.
291
+ if (droppedEvents > 0)
292
+ onDropped?.(droppedEvents);
258
293
  if (stream) {
259
294
  try {
260
295
  await stream.cancel();
@@ -98,6 +98,9 @@ export function mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixComma
98
98
  };
99
99
  }
100
100
  export async function authGate(base, oauthBudget, fixCommand, ctx) {
101
+ if (!ctx.keyed && harnessSpec(base.harness).keylessFallback) {
102
+ return { headlessAuth: !ctx.isTTY, spentBrowserSlot: false };
103
+ }
101
104
  if (!ctx.isTTY && !ctx.keyed) {
102
105
  return headlessGate(base, oauthBudget, fixCommand);
103
106
  }
@@ -5,7 +5,7 @@ import { removeCursorMcpServer, planCursorWrite } from './cursor-config.js';
5
5
  import { planOmpWrite, removeOmpMcpServer } from './omp-config.js';
6
6
  import { piMcpPath, planPiWrite, removePiMcpServer } from './pi-config.js';
7
7
  import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
8
- import { clearHermesWebBackends, HERMES_PLUGIN_SHA } from './hermes-plugin.js';
8
+ import { clearHermesWebBackends } from './hermes-plugin.js';
9
9
  import { HARNESS_DISPLAY_NAMES as DISPLAY_NAMES } from './harness-detect.js';
10
10
  import { errLine } from './output.js';
11
11
  // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
@@ -17,11 +17,13 @@ export function planText(harness, mcpUrl, apiKey) {
17
17
  return planOmpWrite(mcpUrl, apiKey);
18
18
  if (harness === 'pi')
19
19
  return planPiWrite(mcpUrl, apiKey);
20
- // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
21
20
  if (harness === 'hermes' && apiKey) {
22
21
  return (`would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ` +
23
- `${HERMES_KEY_VAR} to Hermes' .env, installs the tinyfish plugin at ` +
24
- `${HERMES_PLUGIN_SHA.slice(0, 12)}, and points its web backends at tinyfish)`);
22
+ `${HERMES_KEY_VAR} to Hermes' .env, installs the tinyfish plugin from npm @latest, ` +
23
+ `and points its web backends at tinyfish)`);
24
+ }
25
+ if (harness === 'hermes') {
26
+ return 'would run `tinyfish connect hermes` (writes a keyless MCP header; no credential or plugin write)';
25
27
  }
26
28
  return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
27
29
  }
@@ -8,10 +8,11 @@ import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-a
8
8
  import { actionable, computeExitCode, reloadHint, renderSummary } from './connect-all-summary.js';
9
9
  import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
10
10
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
11
+ import { KEYLESS_ONBOARDING_PROMPT } from './connect-clients.js';
11
12
  import { gateApiKey } from './connect-preflight.js';
12
13
  import { ConnectInterruptedError } from './connect-runtime.js';
13
14
  import { pickHarnesses } from './connect-picker.js';
14
- import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
15
+ import { commandOnPath, detectInstalledHarnesses, AuthMode, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
15
16
  import { detectHumanInitiated } from './harness.js';
16
17
  import { emitNotice } from './notice.js';
17
18
  import { errLine, outLine, setErrIndent } from './output.js';
@@ -44,7 +45,7 @@ async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
44
45
  return health;
45
46
  if (!apiKey)
46
47
  return { depth: 'health+auth', ok: false, reason: 'no stored API key' };
47
- return verifyMcpAuth(apiKey);
48
+ return verifyMcpAuth(apiKey, new URL(mcpUrl).origin);
48
49
  }
49
50
  /** The non-connect settlements: undetected, stale, dry-run, uninstall. */
50
51
  function settleWithoutConnect(detection, opts, base) {
@@ -109,7 +110,9 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
109
110
  }
110
111
  // The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
111
112
  const keyAuthed = authMode ? authMode === 'api-key' : keyed;
112
- const verify = await verifyHarness(harness, opts.mcpUrl, keyAuthed, apiKey);
113
+ const verify = authMode === AuthMode.Keyless
114
+ ? { depth: 'health', ok: true }
115
+ : await verifyHarness(harness, opts.mcpUrl, keyAuthed, apiKey);
113
116
  return {
114
117
  ...base,
115
118
  installed: true,
@@ -426,6 +429,9 @@ async function launchFirstTask(results) {
426
429
  if (!chosen)
427
430
  return false;
428
431
  outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
429
- const result = spawn.sync(HARNESS_COMMANDS[chosen.harness], chosen.args, { stdio: 'inherit' });
432
+ const keyless = chosen.harness === 'omp' &&
433
+ results.some((result) => result.harness === 'omp' && !result.keyAuthed);
434
+ const args = keyless ? [KEYLESS_ONBOARDING_PROMPT] : chosen.args;
435
+ const result = spawn.sync(HARNESS_COMMANDS[chosen.harness], args, { stdio: 'inherit' });
430
436
  return !result.error;
431
437
  }