@tiny-fish/cli 0.42.1 → 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/auth.js +11 -1
- 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 +50 -9
- 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/output.js +7 -1
- 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/auth.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as readline from 'readline';
|
|
|
3
3
|
import { Option } from 'commander';
|
|
4
4
|
import { CONNECT_ATTEMPT_ENV, clearConfig, getDashboardUrl, loadConfig, maskKey, savePendingConnectAttempt, saveConfig, validateKeyFormat, } from '../lib/auth.js';
|
|
5
5
|
import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
|
|
6
|
-
import { err, errLine, out, outLine } from '../lib/output.js';
|
|
6
|
+
import { err, errLine, out, outLine, warnLine } from '../lib/output.js';
|
|
7
7
|
/**
|
|
8
8
|
* Read a key from stdin without echoing it to the terminal.
|
|
9
9
|
* Uses setRawMode on TTY so characters are never written to the screen.
|
|
@@ -74,6 +74,14 @@ function parkSeedAttempt() {
|
|
|
74
74
|
if (seed)
|
|
75
75
|
savePendingConnectAttempt(seed);
|
|
76
76
|
}
|
|
77
|
+
// Env var wins every later lookup; a silent save misleads.
|
|
78
|
+
function warnIfEnvShadows(savedKey) {
|
|
79
|
+
const envKey = process.env[TINYFISH_API_KEY_VAR];
|
|
80
|
+
if (!envKey || envKey === savedKey)
|
|
81
|
+
return;
|
|
82
|
+
warnLine(`Warning: ${TINYFISH_API_KEY_VAR} is set in your environment and overrides the key just saved. ` +
|
|
83
|
+
`Unset it or update your shell profile, then check with 'tinyfish auth status'.`);
|
|
84
|
+
}
|
|
77
85
|
export function registerAuth(program) {
|
|
78
86
|
const auth = program.command('auth').description('Manage your TinyFish API key');
|
|
79
87
|
auth
|
|
@@ -120,6 +128,7 @@ export function registerAuth(program) {
|
|
|
120
128
|
saveConfig(key);
|
|
121
129
|
parkSeedAttempt();
|
|
122
130
|
out({ status: 'ok', message: 'API key saved', key_preview: maskKey(key) });
|
|
131
|
+
warnIfEnvShadows(key);
|
|
123
132
|
});
|
|
124
133
|
auth
|
|
125
134
|
.command('set')
|
|
@@ -141,6 +150,7 @@ export function registerAuth(program) {
|
|
|
141
150
|
saveConfig(key);
|
|
142
151
|
parkSeedAttempt();
|
|
143
152
|
out({ status: 'ok', message: 'API key saved', key_preview: maskKey(key) });
|
|
153
|
+
warnIfEnvShadows(key);
|
|
144
154
|
});
|
|
145
155
|
auth
|
|
146
156
|
.command('status')
|
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;
|
|
@@ -82,14 +96,20 @@ class TinyFishCliClient extends TinyFish {
|
|
|
82
96
|
captureNotice(response.headers);
|
|
83
97
|
if (!response.ok) {
|
|
84
98
|
let message = response.statusText;
|
|
99
|
+
let code;
|
|
85
100
|
try {
|
|
86
101
|
const body = (await response.json());
|
|
87
|
-
|
|
102
|
+
if (typeof body?.error?.message === 'string')
|
|
103
|
+
message = body.error.message;
|
|
104
|
+
else if (typeof body?.message === 'string')
|
|
105
|
+
message = body.message;
|
|
106
|
+
if (typeof body?.error?.code === 'string')
|
|
107
|
+
code = body.error.code;
|
|
88
108
|
}
|
|
89
109
|
catch {
|
|
90
110
|
// Non-JSON body — keep statusText
|
|
91
111
|
}
|
|
92
|
-
throw new ApiError(response.status, message);
|
|
112
|
+
throw new ApiError(response.status, message, code);
|
|
93
113
|
}
|
|
94
114
|
if (response.status === 204)
|
|
95
115
|
return undefined;
|
|
@@ -113,7 +133,7 @@ function sdk(apiKey, call) {
|
|
|
113
133
|
}
|
|
114
134
|
function rethrowSdkError(error) {
|
|
115
135
|
if (error instanceof APIStatusError) {
|
|
116
|
-
throw new ApiError(error.statusCode, error.message);
|
|
136
|
+
throw new ApiError(error.statusCode, error.message, error.code);
|
|
117
137
|
}
|
|
118
138
|
if (error instanceof Error) {
|
|
119
139
|
throw error;
|
|
@@ -180,7 +200,14 @@ function normalizeStreamEvent(event) {
|
|
|
180
200
|
error: typeof data['error'] === 'object' && data['error'] !== null ? data['error'] : null,
|
|
181
201
|
};
|
|
182
202
|
}
|
|
183
|
-
|
|
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) {
|
|
184
211
|
const reader = stream.getReader();
|
|
185
212
|
let buffer = '';
|
|
186
213
|
const parseLine = (rawLine) => {
|
|
@@ -191,6 +218,7 @@ async function* parseSseStream(stream) {
|
|
|
191
218
|
return JSON.parse(line.slice('data:'.length).trim());
|
|
192
219
|
}
|
|
193
220
|
catch {
|
|
221
|
+
onUnparseableLine();
|
|
194
222
|
return undefined;
|
|
195
223
|
}
|
|
196
224
|
};
|
|
@@ -232,23 +260,36 @@ export function runAsync(req, apiKey) {
|
|
|
232
260
|
return parseWithSchema(agentRunAsyncResponseSchema, response, 'Invalid async run response');
|
|
233
261
|
});
|
|
234
262
|
}
|
|
235
|
-
export async function* runStream(req, apiKey, signal) {
|
|
263
|
+
export async function* runStream(req, apiKey, signal, onDropped) {
|
|
236
264
|
let stream = null;
|
|
265
|
+
let droppedEvents = 0;
|
|
266
|
+
let lastRunId = null;
|
|
237
267
|
try {
|
|
238
268
|
stream = await createSdkClient(apiKey).postStream('/v1/automation/run-sse', {
|
|
239
269
|
json: req,
|
|
240
270
|
signal,
|
|
241
271
|
});
|
|
242
|
-
for await (const event of parseSseStream(stream)) {
|
|
272
|
+
for await (const event of parseSseStream(stream, () => (droppedEvents += 1))) {
|
|
243
273
|
const parsed = agentRunWithStreamingResponseSchema.safeParse(normalizeStreamEvent(event));
|
|
244
|
-
if (parsed.success)
|
|
274
|
+
if (parsed.success) {
|
|
275
|
+
if ('run_id' in parsed.data)
|
|
276
|
+
lastRunId = parsed.data.run_id;
|
|
245
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;
|
|
246
284
|
}
|
|
247
285
|
}
|
|
248
286
|
catch (error) {
|
|
249
287
|
rethrowSdkError(error);
|
|
250
288
|
}
|
|
251
289
|
finally {
|
|
290
|
+
// Runs on caller break too, so the count still surfaces.
|
|
291
|
+
if (droppedEvents > 0)
|
|
292
|
+
onDropped?.(droppedEvents);
|
|
252
293
|
if (stream) {
|
|
253
294
|
try {
|
|
254
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) {
|