@tiny-fish/cli 0.43.0 → 0.44.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 +11 -7
- package/dist/commands/connect.js +111 -46
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/run.js +5 -2
- package/dist/lib/auth.d.ts +2 -1
- package/dist/lib/auth.js +3 -1
- package/dist/lib/client.d.ts +2 -1
- package/dist/lib/client.js +41 -6
- package/dist/lib/connect-all-auth.js +3 -0
- package/dist/lib/connect-all-uninstall.js +3 -1
- package/dist/lib/connect-all.js +10 -4
- package/dist/lib/connect-clients.d.ts +12 -5
- package/dist/lib/connect-clients.js +48 -23
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/doctor-checks.js +4 -0
- package/dist/lib/doctor-report.d.ts +2 -0
- package/dist/lib/doctor-report.js +1 -1
- package/dist/lib/harness-detect.d.ts +1 -0
- package/dist/lib/harness-detect.js +1 -0
- package/dist/lib/harness-spec.d.ts +4 -0
- package/dist/lib/harness-spec.js +3 -0
- package/dist/lib/hermes-config.d.ts +4 -0
- package/dist/lib/hermes-config.js +12 -3
- package/dist/lib/hermes-plugin.d.ts +1 -1
- package/dist/lib/hermes-plugin.js +6 -6
- package/dist/lib/mcp-json-config.d.ts +4 -2
- package/dist/lib/mcp-json-config.js +20 -10
- package/dist/lib/omp-config.js +5 -3
- package/dist/lib/registration-detect.js +12 -4
- package/dist/lib/setup-telemetry.d.ts +12 -0
- package/dist/lib/setup-telemetry.js +1 -0
- package/dist/lib/verify.d.ts +1 -1
- package/dist/lib/verify.js +29 -9
- package/package.json +2 -2
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,
|
|
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
|
|
99
|
+
npx -y @tiny-fish/cli@latest connect hermes --launch
|
|
93
100
|
```
|
|
94
101
|
|
|
95
|
-
|
|
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
|
|
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
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,10 +397,9 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
|
|
|
350
397
|
telemetry.track('checkpoint', { phase: 'skill_installed' });
|
|
351
398
|
});
|
|
352
399
|
}
|
|
353
|
-
|
|
354
|
-
if (extraPostInstall) {
|
|
400
|
+
if (client.extraPostInstall && state.authMode !== AuthMode.Keyless) {
|
|
355
401
|
await attempt('plugin_install', () => {
|
|
356
|
-
extraPostInstall({
|
|
402
|
+
client.extraPostInstall?.({
|
|
357
403
|
apiKey: validatedApiKey(options.apiKey),
|
|
358
404
|
verbose: options.verbose ?? false,
|
|
359
405
|
seededHome,
|
|
@@ -364,15 +410,14 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
|
|
|
364
410
|
});
|
|
365
411
|
});
|
|
366
412
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
413
|
+
if (state.authMode !== AuthMode.Keyless) {
|
|
414
|
+
await attempt('authentication', () => {
|
|
415
|
+
ensureCliAuthenticated(client.connectClient, options.apiKey, {
|
|
416
|
+
interactiveLogin: options.authTimeoutMs === undefined,
|
|
417
|
+
});
|
|
418
|
+
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
373
419
|
});
|
|
374
|
-
|
|
375
|
-
});
|
|
420
|
+
}
|
|
376
421
|
if (client.postConnectNote)
|
|
377
422
|
errLine(client.postConnectNote);
|
|
378
423
|
if (!options.launch) {
|
|
@@ -389,7 +434,7 @@ async function runPostInstallSteps(client, options, state, telemetry, signInDefe
|
|
|
389
434
|
telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
|
|
390
435
|
// Flushed pre-block; the session-long handover would strand the delivery.
|
|
391
436
|
await telemetry.flush();
|
|
392
|
-
});
|
|
437
|
+
}, state.authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
|
|
393
438
|
});
|
|
394
439
|
}
|
|
395
440
|
}
|
|
@@ -655,18 +700,26 @@ async function connectConfigFileHarness(harness, options) {
|
|
|
655
700
|
await runGuarded(state, telemetry, async () => {
|
|
656
701
|
telemetry.track('started');
|
|
657
702
|
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
703
|
+
const resolveAuth = async () => {
|
|
704
|
+
let resolvedKey = validatedApiKey(options.apiKey);
|
|
705
|
+
const keyless = !resolvedKey && harness === 'omp';
|
|
706
|
+
if (keyless) {
|
|
707
|
+
await requireKeylessMcp(options.mcpUrl);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
state.stage = 'authentication';
|
|
711
|
+
ensureCliAuthenticated(harness, options.apiKey, {
|
|
712
|
+
interactiveLogin: options.authTimeoutMs === undefined,
|
|
713
|
+
});
|
|
714
|
+
resolvedKey = validatedApiKey(options.apiKey);
|
|
715
|
+
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
716
|
+
if (!resolvedKey) {
|
|
717
|
+
errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return { resolvedKey, keyless };
|
|
721
|
+
};
|
|
722
|
+
const { resolvedKey, keyless } = await resolveAuth();
|
|
670
723
|
state.stage = 'registration';
|
|
671
724
|
errLine(`Adding TinyFish to ${displayName}...`);
|
|
672
725
|
const mcpUrl = new URL(options.mcpUrl);
|
|
@@ -686,12 +739,22 @@ async function connectConfigFileHarness(harness, options) {
|
|
|
686
739
|
errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
|
|
687
740
|
}
|
|
688
741
|
telemetry.track('checkpoint', { phase: 'registered' });
|
|
689
|
-
|
|
690
|
-
|
|
742
|
+
let authMode = keyless ? AuthMode.Keyless : 'deferred';
|
|
743
|
+
if (resolvedKey)
|
|
744
|
+
authMode = AuthMode.ApiKey;
|
|
745
|
+
state.authMode = authMode;
|
|
746
|
+
if (keyless)
|
|
747
|
+
saveConnectContext(harness, telemetry.attemptId, AuthMode.Keyless);
|
|
748
|
+
else
|
|
749
|
+
saveConnectContext(harness, telemetry.attemptId);
|
|
750
|
+
settle(state, telemetry, 'completed', { authMode });
|
|
691
751
|
const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
|
|
692
|
-
if (
|
|
752
|
+
if (keyless) {
|
|
753
|
+
errLine(`TinyFish keyless Search is connected in ${displayName}.`);
|
|
754
|
+
}
|
|
755
|
+
else if (resolvedKey) {
|
|
693
756
|
// Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
|
|
694
|
-
const verify = await verifyMcpAuth(resolvedKey);
|
|
757
|
+
const verify = await verifyMcpAuth(resolvedKey, apiBaseFromMcpUrl(options.mcpUrl));
|
|
695
758
|
if (verify.ok) {
|
|
696
759
|
errLine(spec.copy.verified);
|
|
697
760
|
}
|
|
@@ -710,6 +773,7 @@ async function connectConfigFileHarness(harness, options) {
|
|
|
710
773
|
errLine(note);
|
|
711
774
|
reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
|
|
712
775
|
});
|
|
776
|
+
return state.authMode;
|
|
713
777
|
}
|
|
714
778
|
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
715
779
|
export async function connectCursor(options) {
|
|
@@ -751,10 +815,10 @@ const CONNECTOR_OVERRIDES = {
|
|
|
751
815
|
return undefined;
|
|
752
816
|
},
|
|
753
817
|
omp: async (options) => {
|
|
754
|
-
await connectConfigFileHarness('omp', options);
|
|
818
|
+
const authMode = await connectConfigFileHarness('omp', options);
|
|
755
819
|
if (options.launch) {
|
|
756
820
|
try {
|
|
757
|
-
await launchOmpWalkthrough();
|
|
821
|
+
await launchOmpWalkthrough(undefined, authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
|
|
758
822
|
}
|
|
759
823
|
catch (error) {
|
|
760
824
|
// Post-settle, like the native walkthrough step: never fail the connect.
|
|
@@ -762,7 +826,7 @@ const CONNECTOR_OVERRIDES = {
|
|
|
762
826
|
errLine(finishSetupHint('omp'));
|
|
763
827
|
}
|
|
764
828
|
}
|
|
765
|
-
return
|
|
829
|
+
return authMode;
|
|
766
830
|
},
|
|
767
831
|
pi: async (options) => {
|
|
768
832
|
await connectConfigFileHarness('pi', options);
|
|
@@ -839,6 +903,7 @@ async function connectSingleClient(client, options, mcpUrl, attemptId) {
|
|
|
839
903
|
attemptId,
|
|
840
904
|
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
841
905
|
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
906
|
+
authTimeoutMs: detectHumanInitiated() ? undefined : 90_000,
|
|
842
907
|
// Only here: a missing harness rescues via the CLI (PF-3707).
|
|
843
908
|
fallbackWhenMissing: true,
|
|
844
909
|
verbose: options.verbose ?? false,
|
package/dist/commands/doctor.js
CHANGED
|
@@ -22,7 +22,8 @@ function hermesPluginVersions(result) {
|
|
|
22
22
|
export async function runDoctor(options) {
|
|
23
23
|
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
24
24
|
const credential = checkCredential();
|
|
25
|
-
const
|
|
25
|
+
const apiBase = new URL(options.mcpUrl).origin;
|
|
26
|
+
const cliAuth = credential.key ? verifyMcpAuth(credential.key, apiBase) : undefined;
|
|
26
27
|
const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
|
|
27
28
|
checkConnectivity(options.mcpUrl),
|
|
28
29
|
cliAuth,
|
package/dist/commands/run.js
CHANGED
|
@@ -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')
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -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
|
-
|
|
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'));
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -17,12 +17,14 @@ 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
22
|
`${HERMES_KEY_VAR} to Hermes' .env, installs the tinyfish plugin at ` +
|
|
24
23
|
`${HERMES_PLUGIN_SHA.slice(0, 12)}, and points its web backends at tinyfish)`);
|
|
25
24
|
}
|
|
25
|
+
if (harness === 'hermes') {
|
|
26
|
+
return 'would run `tinyfish connect hermes` (writes a keyless MCP header; no credential or plugin write)';
|
|
27
|
+
}
|
|
26
28
|
return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
|
|
27
29
|
}
|
|
28
30
|
export function uninstallPlanText(harness) {
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
}
|