@tiny-fish/cli 0.45.1 → 0.45.2-next.356
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/dist/commands/connect.d.ts +0 -37
- package/dist/commands/connect.js +5 -847
- package/dist/commands/onboard.js +1 -1
- package/dist/lib/connect-all.js +1 -1
- package/dist/lib/connect-config-file.d.ts +76 -0
- package/dist/lib/connect-config-file.js +241 -0
- package/dist/lib/connect-harness.d.ts +4 -0
- package/dist/lib/connect-harness.js +76 -0
- package/dist/lib/connect-native.d.ts +4 -0
- package/dist/lib/connect-native.js +389 -0
- package/dist/lib/connect-openclaw.d.ts +3 -0
- package/dist/lib/connect-openclaw.js +71 -0
- package/dist/lib/connect-steps.d.ts +38 -0
- package/dist/lib/connect-steps.js +104 -0
- package/dist/lib/doctor-repairs.js +1 -1
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -1,855 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from '../lib/auth.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
|
-
import { captureStdio, capturedOutput, installTinyFishCli, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from '../lib/cli-install.js';
|
|
5
|
-
import { ensureCliAuthenticated } from '../lib/connect-auth.js';
|
|
6
|
-
import { createStdinPrompt, runCliFallback } from '../lib/connect-fallback.js';
|
|
7
|
-
import { installWebSkill } from '../lib/skill-install.js';
|
|
8
|
-
import { ConnectInterruptedError, ConnectStepError, HoistedFailureReportedError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
|
|
9
|
-
import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
|
|
1
|
+
import { validateKeyFormat, validatedApiKey } from '../lib/auth.js';
|
|
10
2
|
import { runConnectAll } from '../lib/connect-all.js';
|
|
11
|
-
import {
|
|
12
|
-
import { PI_ADAPTER_INSTALL_COMMAND, piMcpAdapterState, piSkillDirMismatch, piMcpPath, readPiTinyfishEntry, writePiMcpConfig, } from '../lib/pi-config.js';
|
|
13
|
-
import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '../lib/harness-detect.js';
|
|
3
|
+
import { ALL_HARNESSES } from '../lib/harness-detect.js';
|
|
14
4
|
import { detectHumanInitiated } from '../lib/harness.js';
|
|
15
|
-
import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
|
|
16
5
|
import { emitNotice } from '../lib/notice.js';
|
|
17
|
-
import {
|
|
18
|
-
import { errLine, warnLine } from '../lib/output.js';
|
|
6
|
+
import { errLine } from '../lib/output.js';
|
|
19
7
|
import { z } from 'zod';
|
|
20
|
-
import { verifyMcpAuth, verifyMcpHealth } from '../lib/verify.js';
|
|
21
8
|
import { gateApiKey } from '../lib/connect-preflight.js';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
const result = spawnRemoval(client.command, removal.args);
|
|
25
|
-
if (result.status === 0)
|
|
26
|
-
return;
|
|
27
|
-
// Best-effort: a stale entry makes `mcp add` error or upsert, with a truer message.
|
|
28
|
-
if (!verbose)
|
|
29
|
-
return;
|
|
30
|
-
const details = result.stderr?.trim() || result.error?.message || 'unknown error';
|
|
31
|
-
warnLine(`Could not remove existing ${removal.label}: ${details}`);
|
|
32
|
-
}
|
|
33
|
-
/** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
|
|
34
|
-
function exportApiKey(apiKey, displayName, deferOutro) {
|
|
35
|
-
try {
|
|
36
|
-
persistApiKeyToEnvironment(apiKey);
|
|
37
|
-
}
|
|
38
|
-
catch (error) {
|
|
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
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
process.env[TINYFISH_API_KEY_VAR] = apiKey;
|
|
44
|
-
if (deferOutro) {
|
|
45
|
-
deferOutro('Exported TINYFISH_API_KEY to your shell profile — restart your agents (or open a new ' +
|
|
46
|
-
'terminal) before use.');
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
errLine(`Exported TINYFISH_API_KEY to your shell profile — ${displayName} reads the key from there, ` +
|
|
50
|
-
'so restart it (or open a new terminal) before use.');
|
|
51
|
-
}
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
function resolveKeyedAdd(client, storedKey, keyAuthSupported, keyAuthOnly, deferOutro) {
|
|
55
|
-
if (!storedKey || !keyAuthSupported || !client.keyAuth)
|
|
56
|
-
return undefined;
|
|
57
|
-
const keyed = { key: storedKey, spec: client.keyAuth };
|
|
58
|
-
// Before the removals: a failed export must not strand the old registration.
|
|
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.`);
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
return keyed;
|
|
66
|
-
}
|
|
67
|
-
/** No sign-in to degrade to, so the install stops. */
|
|
68
|
-
function refuseKeylessInstall(client, keyAuthSupported, headless = false) {
|
|
69
|
-
const noSignIn = headless
|
|
70
|
-
? 'signing in needs a terminal'
|
|
71
|
-
: `TinyFish does not sign in to ${client.displayName} any other way`;
|
|
72
|
-
const rerun = `re-run: tinyfish connect ${client.connectClient}`;
|
|
73
|
-
if (!client.keyAuth) {
|
|
74
|
-
throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and ${noSignIn}. ` +
|
|
75
|
-
`Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
|
|
76
|
-
}
|
|
77
|
-
if (!keyAuthSupported || headless) {
|
|
78
|
-
throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and ${noSignIn}. ` +
|
|
79
|
-
`Update it, then ${rerun}`, 'harness_too_old');
|
|
80
|
-
}
|
|
81
|
-
throw new ConnectStepError(`Connecting ${client.displayName} needs a TinyFish API key, and ${noSignIn}. ` +
|
|
82
|
-
`Run: tinyfish auth login, then ${rerun}`, 'invalid_config', { failureDetail: 'keyless_install_refused' });
|
|
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
|
-
}
|
|
90
|
-
/** Resolved before the removals, so a refusal strands no registration. */
|
|
91
|
-
function keylessAddArgs(client, keyAuthSupported) {
|
|
92
|
-
if (!client.addArgs)
|
|
93
|
-
refuseKeylessInstall(client, keyAuthSupported);
|
|
94
|
-
return client.addArgs;
|
|
95
|
-
}
|
|
96
|
-
/** Runs before any write: a refused key strands nothing. */
|
|
97
|
-
async function verifyKeyBeforeSeeding(apiKey, displayName, mcpUrl) {
|
|
98
|
-
// One host serves `/mcp` and `/v1`, so `--url` decides where the key is checked.
|
|
99
|
-
const verified = await verifyMcpAuth(apiKey, apiBaseFromMcpUrl(mcpUrl));
|
|
100
|
-
if (verified.ok)
|
|
101
|
-
return;
|
|
102
|
-
// No status means the network failed, not the key.
|
|
103
|
-
if (verified.status === undefined) {
|
|
104
|
-
throw new ConnectStepError(`Could not reach TinyFish to check your API key (${verified.code ?? 'unknown'}), so ` +
|
|
105
|
-
`nothing was written to ${displayName}. Check your connection and retry.`, 'timeout');
|
|
106
|
-
}
|
|
107
|
-
// The tag carries the HTTP status; `verified.code` is authored prose.
|
|
108
|
-
throw new ConnectStepError(`Your TinyFish API key was rejected (${verified.code ?? 'unknown'}), so it was not ` +
|
|
109
|
-
`written to ${displayName}. Run: tinyfish auth login`, 'invalid_config', { failureDetail: `key_rejected status=${verified.status}` });
|
|
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
|
-
}
|
|
116
|
-
/** A bounded spawn's timeout means the sign-in never finished. */
|
|
117
|
-
function signInStepError(message, result, bounded) {
|
|
118
|
-
const error = spawnStepError(message, result);
|
|
119
|
-
return bounded && error.failureReason === 'timeout'
|
|
120
|
-
? new SignInTimeoutError(error.message, error.cause)
|
|
121
|
-
: error;
|
|
122
|
-
}
|
|
123
|
-
function isMissingHarness(error) {
|
|
124
|
-
return error instanceof ConnectStepError && error.failureReason === 'harness_not_installed';
|
|
125
|
-
}
|
|
126
|
-
/** Undefined means the rescue ran; the caller returns immediately. */
|
|
127
|
-
async function requireSupportOrRescue(client, connectClient, state, telemetry, options) {
|
|
128
|
-
try {
|
|
129
|
-
return requireCommandSupport(client);
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
if (!options.fallbackWhenMissing || !isMissingHarness(error))
|
|
133
|
-
throw error;
|
|
134
|
-
await runMissingHarnessFallback(client.displayName, connectClient, state, telemetry, options);
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// The harness attempt stays failed; the rescue rides fallback_outcome (PF-3707).
|
|
139
|
-
async function runMissingHarnessFallback(displayName, connectClient, state, telemetry, options) {
|
|
140
|
-
// Stamped before any step: Ctrl+C settles before an outcome exists.
|
|
141
|
-
telemetry.setFallbackOutcome('abandoned');
|
|
142
|
-
errLine(`${displayName} was not found on PATH. Setting up the TinyFish CLI instead.`);
|
|
143
|
-
const outcome = await runCliFallback({
|
|
144
|
-
apiKey: options.apiKey,
|
|
145
|
-
verbose: options.verbose ?? false,
|
|
146
|
-
prompt: detectHumanInitiated() ? createStdinPrompt() : undefined,
|
|
147
|
-
retryCommand: `npx @tiny-fish/cli connect ${connectClient}`,
|
|
148
|
-
hooks: {
|
|
149
|
-
// Terminal detail is explicit below; abort rows still name the step.
|
|
150
|
-
onStepStart: (stage) => {
|
|
151
|
-
state.stage = stage;
|
|
152
|
-
},
|
|
153
|
-
onStepDone: (phase) => telemetry.track('checkpoint', { phase }),
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
telemetry.setFallbackOutcome(outcome);
|
|
157
|
-
// Never `completed`: dashboards read that as "the harness install worked".
|
|
158
|
-
settle(state, telemetry, 'failed', {
|
|
159
|
-
failedStage: 'prerequisite_check',
|
|
160
|
-
failureReason: 'harness_not_installed',
|
|
161
|
-
});
|
|
162
|
-
if (outcome !== 'cli_verified') {
|
|
163
|
-
process.exitCode = 1;
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
errLine('The TinyFish CLI is installed and working.');
|
|
167
|
-
errLine('Using a different agent? Run: tinyfish connect --all');
|
|
168
|
-
}
|
|
169
|
-
/** Resolved before the removals, so a refusal strands no registration. */
|
|
170
|
-
async function prepareInstallAuth(client, options, keyAuthSupported) {
|
|
171
|
-
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
172
|
-
const storedKey = validatedApiKey(options.apiKey);
|
|
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
|
-
}
|
|
191
|
-
if (options.keyAuthOnly && !keyedAdd)
|
|
192
|
-
refuseKeylessInstall(client, keyAuthSupported, true);
|
|
193
|
-
// Verify first: a revoked key in a foreign .env is unrecoverable.
|
|
194
|
-
if (keyedAdd?.spec.seedKey) {
|
|
195
|
-
await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
|
|
196
|
-
}
|
|
197
|
-
return { keyedAdd, keyless, buildAddArgs, directInstall };
|
|
198
|
-
}
|
|
199
|
-
/** Inherit for an inline-OAuth add; capture otherwise, piping prompts when seeded. */
|
|
200
|
-
function addSpawnOptions(options, interactiveAdd, seeded) {
|
|
201
|
-
if (interactiveAdd)
|
|
202
|
-
return { stdio: 'inherit', timeout: options.authTimeoutMs };
|
|
203
|
-
if (!seeded)
|
|
204
|
-
return captureStdio(options.verbose ?? false);
|
|
205
|
-
return {
|
|
206
|
-
encoding: 'utf8',
|
|
207
|
-
// Without it, the 1 MiB default overflows into a misread Ctrl+C.
|
|
208
|
-
maxBuffer: STEP_MAX_BUFFER,
|
|
209
|
-
// The unattended path had no bound at all before; captureStdio's is the floor.
|
|
210
|
-
timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
|
|
211
|
-
input: seeded.stdinInput,
|
|
212
|
-
env: seeded.env,
|
|
213
|
-
};
|
|
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
|
-
}
|
|
222
|
-
/** Returns the seeded-credential note when a key was written into a user-owned file. */
|
|
223
|
-
function performMcpAdd(client, options, plan, attemptId) {
|
|
224
|
-
const { keyedAdd, buildAddArgs, directInstall } = plan;
|
|
225
|
-
const mcpUrl = new URL(options.mcpUrl);
|
|
226
|
-
mcpUrl.searchParams.set('source', CONNECT_SOURCE);
|
|
227
|
-
mcpUrl.searchParams.set('client', client.connectClient);
|
|
228
|
-
mcpUrl.searchParams.set('connect_attempt_id', attemptId);
|
|
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}`);
|
|
234
|
-
// Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
|
|
235
|
-
const addArgs = buildAddArgs(mcpUrl.toString());
|
|
236
|
-
// Capturing an inline-OAuth add hides the sign-in URL and hangs.
|
|
237
|
-
const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
|
|
238
|
-
// After the removals: an interrupt there leaves no key behind.
|
|
239
|
-
const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
|
|
240
|
-
// Writing the entry ourselves skips the harness probe that saves it disabled.
|
|
241
|
-
if (seeded?.register(mcpUrl.toString())) {
|
|
242
|
-
return { note: seeded.note, seededHome: seeded.home };
|
|
243
|
-
}
|
|
244
|
-
const addResult = spawn.sync(client.command, addArgs, addSpawnOptions(options, interactiveAdd, seeded));
|
|
245
|
-
// Rolls back first: signInStepError throws on a Ctrl+C.
|
|
246
|
-
const failAdd = (build) => {
|
|
247
|
-
seeded?.rollback();
|
|
248
|
-
const error = build();
|
|
249
|
-
replay(capturedOutput(addResult));
|
|
250
|
-
throw error;
|
|
251
|
-
};
|
|
252
|
-
if (addResult.error || addResult.status !== 0) {
|
|
253
|
-
failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
|
|
254
|
-
}
|
|
255
|
-
const registration = seeded?.confirmRegistered();
|
|
256
|
-
if (registration && !registration.ok) {
|
|
257
|
-
failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
|
|
258
|
-
`${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config',
|
|
259
|
-
// The tag keeps the path-bearing prose out of telemetry.
|
|
260
|
-
{ failureDetail: registration.tag }));
|
|
261
|
-
}
|
|
262
|
-
return { note: seeded?.note, seededHome: seeded?.home };
|
|
263
|
-
}
|
|
264
|
-
function performLoginStep(client, pendingLogin, options) {
|
|
265
|
-
errLine('Signing in to TinyFish...');
|
|
266
|
-
const loginResult = spawn.sync(client.command, [...pendingLogin], {
|
|
267
|
-
stdio: 'inherit',
|
|
268
|
-
timeout: options.authTimeoutMs,
|
|
269
|
-
});
|
|
270
|
-
if (loginResult.error || loginResult.status !== 0) {
|
|
271
|
-
throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
|
|
275
|
-
if (useKeyAuth) {
|
|
276
|
-
errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
|
|
277
|
-
// Every other credential the CLI writes into a user-owned file says so too.
|
|
278
|
-
if (seededNote)
|
|
279
|
-
errLine(seededNote);
|
|
280
|
-
}
|
|
281
|
-
else if (degradedNotes) {
|
|
282
|
-
// Printed here, not with the closing line, so a `--launch` run sees it before the handover.
|
|
283
|
-
errLine(degradedNotes.deferred);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
function completeAuthFlow(client, options, state, telemetry, 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
|
-
}
|
|
292
|
-
// Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
|
|
293
|
-
// whether the login step below still runs. Unknown keeps the pre-probe behaviour.
|
|
294
|
-
const addSignedIn = probeAuthenticated?.();
|
|
295
|
-
if (addSignedIn)
|
|
296
|
-
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
297
|
-
announceAuthNotes(useKeyAuth, loginDegraded ? client.degradedAuthNotes : undefined, ctx.seededNote);
|
|
298
|
-
// A probed client signs in again only when its inline OAuth is known to have been abandoned.
|
|
299
|
-
const retryLogin = probeAuthenticated ? addSignedIn === false : true;
|
|
300
|
-
const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
|
|
301
|
-
let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
302
|
-
if (pendingLogin) {
|
|
303
|
-
state.stage = 'client_oauth';
|
|
304
|
-
performLoginStep(client, pendingLogin, options);
|
|
305
|
-
if (probeAuthenticated?.() === false)
|
|
306
|
-
signInDeferred = true;
|
|
307
|
-
else
|
|
308
|
-
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
309
|
-
}
|
|
310
|
-
const authMode = useKeyAuth
|
|
311
|
-
? AuthMode.ApiKey
|
|
312
|
-
: signInDeferred
|
|
313
|
-
? 'deferred'
|
|
314
|
-
: AuthMode.OAuth;
|
|
315
|
-
return { authMode, signInDeferred };
|
|
316
|
-
}
|
|
317
|
-
async function connectNativeMcpClient(client, options) {
|
|
318
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
319
|
-
const state = { stage: 'prerequisite_check', settled: false };
|
|
320
|
-
const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
|
|
321
|
-
await runGuarded(state, telemetry, async () => {
|
|
322
|
-
telemetry.track('started');
|
|
323
|
-
const support = await requireSupportOrRescue(client, client.connectClient, state, telemetry, options);
|
|
324
|
-
if (!support)
|
|
325
|
-
return;
|
|
326
|
-
const { optionalSupported, keyAuthSupported, harnessVersion } = support;
|
|
327
|
-
telemetry.setHarnessVersion(harnessVersion);
|
|
328
|
-
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
329
|
-
const plan = await prepareInstallAuth(client, options, keyAuthSupported);
|
|
330
|
-
const useKeyAuth = !!plan.keyedAdd;
|
|
331
|
-
const useKeyless = plan.keyless;
|
|
332
|
-
if (useKeyless)
|
|
333
|
-
await requireKeylessMcp(options.mcpUrl);
|
|
334
|
-
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
335
|
-
state.stage = 'registration_cleanup';
|
|
336
|
-
for (const removal of client.removals)
|
|
337
|
-
removeExistingRegistration(client, removal, options.verbose ?? false);
|
|
338
|
-
// Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
|
|
339
|
-
const loginDegraded = state.harnessDegraded === true;
|
|
340
|
-
const loginArgs = loginDegraded ? undefined : client.loginArgs;
|
|
341
|
-
// A probed client authenticates inside `mcp add` too, so a failure there is either.
|
|
342
|
-
state.stage =
|
|
343
|
-
loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
|
|
344
|
-
const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
|
|
345
|
-
telemetry.track('checkpoint', { phase: 'registered' });
|
|
346
|
-
const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
|
|
347
|
-
useKeyAuth,
|
|
348
|
-
useKeyless,
|
|
349
|
-
loginDegraded,
|
|
350
|
-
loginArgs,
|
|
351
|
-
probeAuthenticated,
|
|
352
|
-
seededNote,
|
|
353
|
-
});
|
|
354
|
-
state.authMode = authMode;
|
|
355
|
-
// Registration/OAuth make MCP work; the steps below must not fail the attempt.
|
|
356
|
-
settle(state, telemetry, 'completed', { authMode });
|
|
357
|
-
await runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome);
|
|
358
|
-
});
|
|
359
|
-
return state.authMode;
|
|
360
|
-
}
|
|
361
|
-
function connectedLine(client, signInDeferred) {
|
|
362
|
-
// The CLI never observes deferred auth, so don't claim "connected".
|
|
363
|
-
if (signInDeferred) {
|
|
364
|
-
if (client.signInHint)
|
|
365
|
-
return client.signInHint;
|
|
366
|
-
return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
|
|
367
|
-
'to sign in to TinyFish the first time you use it.');
|
|
368
|
-
}
|
|
369
|
-
return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
|
|
370
|
-
`${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
|
|
371
|
-
}
|
|
372
|
-
// Runs after `completed` settled: steps fail alone, the rest still run.
|
|
373
|
-
async function runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome) {
|
|
374
|
-
let failed = false;
|
|
375
|
-
// Modelled on upgrade's attempt(); interrupts rethrow and abandon remaining steps.
|
|
376
|
-
const attempt = async (stage, run) => {
|
|
377
|
-
state.stage = stage;
|
|
378
|
-
try {
|
|
379
|
-
await run();
|
|
380
|
-
}
|
|
381
|
-
catch (error) {
|
|
382
|
-
if (error instanceof ConnectInterruptedError)
|
|
383
|
-
throw error;
|
|
384
|
-
failed = true;
|
|
385
|
-
trackPostInstallFailure(client.displayName, state, telemetry, error);
|
|
386
|
-
}
|
|
387
|
-
};
|
|
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);
|
|
391
|
-
if (options.cliInstall !== false) {
|
|
392
|
-
await attempt('cli_install', () => runCliInstallStep(options, telemetry));
|
|
393
|
-
}
|
|
394
|
-
if (client.skillAgent) {
|
|
395
|
-
await attempt('skill_install', () => {
|
|
396
|
-
installWebSkill(client, { verbose: options.verbose ?? false });
|
|
397
|
-
telemetry.track('checkpoint', { phase: 'skill_installed' });
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
if (client.extraPostInstall && state.authMode !== AuthMode.Keyless) {
|
|
401
|
-
await attempt('plugin_install', () => {
|
|
402
|
-
const pluginVersion = client.extraPostInstall?.({
|
|
403
|
-
apiKey: validatedApiKey(options.apiKey),
|
|
404
|
-
verbose: options.verbose ?? false,
|
|
405
|
-
seededHome,
|
|
406
|
-
});
|
|
407
|
-
telemetry.track('checkpoint', { phase: 'plugin_installed', pluginVersion });
|
|
408
|
-
});
|
|
409
|
-
}
|
|
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' });
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
if (client.postConnectNote)
|
|
419
|
-
errLine(client.postConnectNote);
|
|
420
|
-
if (!options.launch) {
|
|
421
|
-
// Under --all the summary replaces this; sign-in hints still print.
|
|
422
|
-
if (signInDeferred || !options.deferOutro)
|
|
423
|
-
errLine(connectedLine(client, signInDeferred));
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
await attempt('walkthrough_launch', async () => {
|
|
427
|
-
// Before the handover, or the only sign-in instructions scroll past under the agent.
|
|
428
|
-
if (signInDeferred && client.signInHint)
|
|
429
|
-
errLine(client.signInHint);
|
|
430
|
-
await launchNativeMcpClient(client, async (outcome) => {
|
|
431
|
-
telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
|
|
432
|
-
// Flushed pre-block; the session-long handover would strand the delivery.
|
|
433
|
-
await telemetry.flush();
|
|
434
|
-
}, state.authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
catch (error) {
|
|
439
|
-
// Interrupts stop quietly; anything else escapes to runGuarded.
|
|
440
|
-
if (!(error instanceof ConnectInterruptedError))
|
|
441
|
-
throw error;
|
|
442
|
-
failed = true;
|
|
443
|
-
}
|
|
444
|
-
if (failed) {
|
|
445
|
-
errLine(finishSetupHint(client.connectClient));
|
|
446
|
-
options.onPostInstallFailed?.();
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
/** Fails on a carried failure; only the first take reports anything. */
|
|
450
|
-
function takeHoistedCliInstall(take, telemetry) {
|
|
451
|
-
const { outcome, firstTake } = take();
|
|
452
|
-
if (!outcome.ok) {
|
|
453
|
-
// Late takers fail too; only the first reports the install.
|
|
454
|
-
if (!firstTake)
|
|
455
|
-
throw new HoistedFailureReportedError(outcome.error.message);
|
|
456
|
-
throw withStageDuration(outcome.error, outcome.durationMs);
|
|
457
|
-
}
|
|
458
|
-
if (firstTake) {
|
|
459
|
-
telemetry.track('checkpoint', {
|
|
460
|
-
phase: 'cli_installed',
|
|
461
|
-
stageDurationMs: outcome.durationMs,
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
/** Consumes the `--all` hoist when present; installs inline otherwise. */
|
|
466
|
-
function runCliInstallStep(options, telemetry) {
|
|
467
|
-
if (options.hoistedCliInstall) {
|
|
468
|
-
takeHoistedCliInstall(options.hoistedCliInstall, telemetry);
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
installTinyFishCli({ verbose: options.verbose ?? false, announce: true });
|
|
472
|
-
telemetry.track('checkpoint', { phase: 'cli_installed' });
|
|
473
|
-
}
|
|
474
|
-
function walkthroughPhase(outcome) {
|
|
475
|
-
return outcome === 'printed' ? 'walkthrough_prompt_printed' : 'walkthrough_launched';
|
|
476
|
-
}
|
|
477
|
-
function finishSetupHint(connectClient) {
|
|
478
|
-
return `Finish setup with: ${finishSetupCommand(connectClient)}`;
|
|
479
|
-
}
|
|
480
|
-
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
481
|
-
function trackPostInstallFailure(displayName, state, telemetry, error) {
|
|
482
|
-
if (error instanceof ConnectInterruptedError)
|
|
483
|
-
return;
|
|
484
|
-
// An earlier harness already owns the shared install's failure record.
|
|
485
|
-
if (!(error instanceof HoistedFailureReportedError)) {
|
|
486
|
-
telemetry.track('post_install_failed', {
|
|
487
|
-
failedStage: state.stage,
|
|
488
|
-
failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
|
|
489
|
-
failureDetail: error instanceof ConnectStepError ? (error.failureDetail ?? error.message) : undefined,
|
|
490
|
-
stageDurationMs: stageDurationOf(error),
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
494
|
-
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
495
|
-
}
|
|
496
|
-
/** Skill install only: no key handoff or post-install callback to honour. */
|
|
497
|
-
export async function connectOpenClaw(options) {
|
|
498
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, 'openclaw', options);
|
|
499
|
-
// Skill install, no MCP server — never degraded, and false keeps it inside a `= false` filter.
|
|
500
|
-
const state = {
|
|
501
|
-
stage: 'prerequisite_check',
|
|
502
|
-
settled: false,
|
|
503
|
-
harnessDegraded: false,
|
|
504
|
-
};
|
|
505
|
-
await runGuarded(state, telemetry, async () => {
|
|
506
|
-
telemetry.track('started');
|
|
507
|
-
const support = await requireSupportOrRescue(OPENCLAW, 'openclaw', state, telemetry, options);
|
|
508
|
-
if (!support)
|
|
509
|
-
return;
|
|
510
|
-
telemetry.setHarnessVersion(support.harnessVersion);
|
|
511
|
-
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
512
|
-
if (options.cliInstall !== false) {
|
|
513
|
-
state.stage = 'cli_install';
|
|
514
|
-
runCliInstallStep(options, telemetry);
|
|
515
|
-
}
|
|
516
|
-
state.stage = 'skill_install';
|
|
517
|
-
errLine('Installing the TinyFish skill in OpenClaw...');
|
|
518
|
-
const skillInstallResult = spawn.sync('openclaw', openclawSkillInstallArgs(support.matchedVariant), captureStdio(options.verbose ?? false));
|
|
519
|
-
if (skillInstallResult.error || skillInstallResult.status !== 0) {
|
|
520
|
-
// Built first: its interrupt check must run before any replay.
|
|
521
|
-
const error = spawnStepError('Could not install the TinyFish skill in OpenClaw', skillInstallResult);
|
|
522
|
-
replay(capturedOutput(skillInstallResult));
|
|
523
|
-
throw error;
|
|
524
|
-
}
|
|
525
|
-
telemetry.track('checkpoint', { phase: 'skill_installed' });
|
|
526
|
-
// Stores the key before probing, so a passed --api-key never reaches an interactive login.
|
|
527
|
-
state.stage = 'authentication';
|
|
528
|
-
// A set timeout means headless; never shell an interactive login.
|
|
529
|
-
ensureCliAuthenticated('openclaw', options.apiKey, {
|
|
530
|
-
interactiveLogin: options.authTimeoutMs === undefined,
|
|
531
|
-
});
|
|
532
|
-
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
533
|
-
saveConnectContext('openclaw', telemetry.attemptId);
|
|
534
|
-
// For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
|
|
535
|
-
settle(state, telemetry, 'completed');
|
|
536
|
-
if (!options.launch) {
|
|
537
|
-
// Under --all the summary already lists OpenClaw.
|
|
538
|
-
if (!options.deferOutro) {
|
|
539
|
-
errLine('TinyFish is connected. Open OpenClaw; if it is already running, ' +
|
|
540
|
-
`${RELOAD_ACTION.openclaw} to pick up the skill.`);
|
|
541
|
-
}
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
state.stage = 'walkthrough_launch';
|
|
545
|
-
try {
|
|
546
|
-
await launchOpenClawWalkthrough(async (outcome) => {
|
|
547
|
-
telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
|
|
548
|
-
// Flushed pre-block; the session-long handover would strand the delivery.
|
|
549
|
-
await telemetry.flush();
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
catch (error) {
|
|
553
|
-
trackPostInstallFailure('OpenClaw', state, telemetry, error);
|
|
554
|
-
errLine(finishSetupHint('openclaw'));
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
const PI_RELOAD_COPY = 'TinyFish is configured in pi. Restart pi, then run `/mcp-auth tinyfish` there if it asks you ' +
|
|
559
|
-
'to sign in.';
|
|
560
|
-
// The skill is the working path either way, so the note must not read as a failure.
|
|
561
|
-
const PI_ADAPTER_DORMANT_NOTE = 'Note: pi has no built-in MCP support, so the entry in its mcp.json stays dormant until you ' +
|
|
562
|
-
`run \`${PI_ADAPTER_INSTALL_COMMAND}\`. The TinyFish skill works without it.`;
|
|
563
|
-
// Only the dormant case was spoken for, leaving a working MCP install silent.
|
|
564
|
-
const PI_ADAPTER_LIVE_NOTE = 'pi-mcp-adapter is installed, so pi loads the TinyFish MCP tools as well as the skill once ' +
|
|
565
|
-
'you restart it.';
|
|
566
|
-
function piAdapterNote() {
|
|
567
|
-
const state = piMcpAdapterState();
|
|
568
|
-
if (state === 'installed')
|
|
569
|
-
return PI_ADAPTER_LIVE_NOTE;
|
|
570
|
-
return state === 'absent' ? PI_ADAPTER_DORMANT_NOTE : undefined;
|
|
571
|
-
}
|
|
572
|
-
// The `skills` CLI writes to the default dir even when pi reads another one.
|
|
573
|
-
function piPostConnectNotes() {
|
|
574
|
-
const notes = [piAdapterNote()];
|
|
575
|
-
const skills = piSkillDirMismatch();
|
|
576
|
-
if (skills) {
|
|
577
|
-
notes.push(`Note: PI_CODING_AGENT_DIR points pi at ${skills.readFrom}, but the TinyFish skill ` +
|
|
578
|
-
`installed to ${skills.installedTo}. Copy or symlink it across for pi to load it.`);
|
|
579
|
-
}
|
|
580
|
-
const printable = notes.filter(Boolean);
|
|
581
|
-
return printable.length > 0 ? printable.join('\n') : undefined;
|
|
582
|
-
}
|
|
583
|
-
// Only interactive omp wires the OAuth handler; headless cannot sign in.
|
|
584
|
-
const OMP_KEYLESS_COPY = 'TinyFish is configured in omp. Open omp and sign in to TinyFish when prompted, or run ' +
|
|
585
|
-
'`/mcp reauth tinyfish` there.';
|
|
586
|
-
const CONFIG_FILE_HARNESSES = {
|
|
587
|
-
cursor: {
|
|
588
|
-
skillTarget: CURSOR_SKILL_TARGET,
|
|
589
|
-
registeredUrl: () => readCursorTinyfishEntry().url,
|
|
590
|
-
write: writeCursorMcpConfig,
|
|
591
|
-
configPath: cursorMcpPath,
|
|
592
|
-
tryLaunch: launchCursorDeeplink,
|
|
593
|
-
copy: {
|
|
594
|
-
verified: "TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.",
|
|
595
|
-
authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
|
|
596
|
-
'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor',
|
|
597
|
-
launched: 'Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.',
|
|
598
|
-
reload: 'TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
|
|
599
|
-
},
|
|
600
|
-
},
|
|
601
|
-
omp: {
|
|
602
|
-
// No skill exists for omp; installWebSkill returns without one.
|
|
603
|
-
skillTarget: { displayName: HARNESS_DISPLAY_NAMES.omp },
|
|
604
|
-
registeredUrl: () => readOmpTinyfishEntry()?.url,
|
|
605
|
-
write: writeOmpMcpConfig,
|
|
606
|
-
configPath: ompMcpPath,
|
|
607
|
-
// No install deeplink exists; the keyless copy carries the sign-in.
|
|
608
|
-
tryLaunch: () => false,
|
|
609
|
-
copy: {
|
|
610
|
-
verified: "TinyFish is connected and verified (health+auth). Restart omp and it's live.",
|
|
611
|
-
authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
|
|
612
|
-
'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect omp',
|
|
613
|
-
launched: OMP_KEYLESS_COPY,
|
|
614
|
-
reload: OMP_KEYLESS_COPY,
|
|
615
|
-
},
|
|
616
|
-
},
|
|
617
|
-
pi: {
|
|
618
|
-
skillTarget: { skillAgent: 'pi', displayName: HARNESS_DISPLAY_NAMES.pi },
|
|
619
|
-
registeredUrl: () => readPiTinyfishEntry().url,
|
|
620
|
-
write: writePiMcpConfig,
|
|
621
|
-
configPath: piMcpPath,
|
|
622
|
-
// No install deeplink exists; the copy carries the restart instead.
|
|
623
|
-
tryLaunch: () => false,
|
|
624
|
-
postConnectNote: piPostConnectNotes,
|
|
625
|
-
copy: {
|
|
626
|
-
verified: "TinyFish is connected and verified (health+auth). Restart pi and it's live.",
|
|
627
|
-
authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
|
|
628
|
-
'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect pi',
|
|
629
|
-
launched: PI_RELOAD_COPY,
|
|
630
|
-
reload: PI_RELOAD_COPY,
|
|
631
|
-
},
|
|
632
|
-
},
|
|
633
|
-
};
|
|
634
|
-
/** Cosmetic and post-settle: a skill failure must not fail the connection. */
|
|
635
|
-
function installSkillPostSettle(spec, displayName, state, telemetry, options) {
|
|
636
|
-
if (!spec.skillTarget.skillAgent)
|
|
637
|
-
return false;
|
|
638
|
-
state.stage = 'skill_install';
|
|
639
|
-
try {
|
|
640
|
-
installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
|
|
641
|
-
telemetry.track('checkpoint', { phase: 'skill_installed' });
|
|
642
|
-
return false;
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
trackPostInstallFailure(displayName, state, telemetry, error);
|
|
646
|
-
return true;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
/** Prints last: the outro must own the final line. */
|
|
650
|
-
function reportPostInstallFailure(harness, failed, onPostInstallFailed) {
|
|
651
|
-
if (!failed)
|
|
652
|
-
return;
|
|
653
|
-
errLine(finishSetupHint(harness));
|
|
654
|
-
onPostInstallFailed?.();
|
|
655
|
-
}
|
|
656
|
-
/** Writes the harness's MCP config file itself; no `mcp add` exists. */
|
|
657
|
-
async function connectConfigFileHarness(harness, options) {
|
|
658
|
-
const spec = CONFIG_FILE_HARNESSES[harness];
|
|
659
|
-
const displayName = HARNESS_DISPLAY_NAMES[harness];
|
|
660
|
-
let fallbackAttemptId;
|
|
661
|
-
const registeredUrl = spec.registeredUrl();
|
|
662
|
-
if (registeredUrl) {
|
|
663
|
-
try {
|
|
664
|
-
const registered = new URL(registeredUrl);
|
|
665
|
-
const requested = new URL(options.mcpUrl);
|
|
666
|
-
const registeredId = registered.searchParams.get('connect_attempt_id');
|
|
667
|
-
const registeredSource = registered.searchParams.get('source');
|
|
668
|
-
const registeredClient = registered.searchParams.get('client');
|
|
669
|
-
for (const key of ['source', 'client', 'connect_attempt_id']) {
|
|
670
|
-
registered.searchParams.delete(key);
|
|
671
|
-
requested.searchParams.delete(key);
|
|
672
|
-
}
|
|
673
|
-
registered.searchParams.sort();
|
|
674
|
-
requested.searchParams.sort();
|
|
675
|
-
if (registered.toString() === requested.toString() &&
|
|
676
|
-
registeredSource === CONNECT_SOURCE &&
|
|
677
|
-
registeredClient === harness &&
|
|
678
|
-
z.uuid().safeParse(registeredId).success) {
|
|
679
|
-
fallbackAttemptId = registeredId ?? undefined;
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
catch {
|
|
683
|
-
// A hand-edited invalid URL is repaired below with a fresh attempt id.
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, harness, {
|
|
687
|
-
...options,
|
|
688
|
-
fallbackAttemptId,
|
|
689
|
-
});
|
|
690
|
-
// Never degraded: no sign-in command to be missing. False, not absent, so a `= false`
|
|
691
|
-
// filter still catches these harnesses.
|
|
692
|
-
const state = {
|
|
693
|
-
stage: 'prerequisite_check',
|
|
694
|
-
settled: false,
|
|
695
|
-
harnessDegraded: false,
|
|
696
|
-
};
|
|
697
|
-
await runGuarded(state, telemetry, async () => {
|
|
698
|
-
telemetry.track('started');
|
|
699
|
-
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
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();
|
|
720
|
-
state.stage = 'registration';
|
|
721
|
-
errLine(`Adding TinyFish to ${displayName}...`);
|
|
722
|
-
const mcpUrl = new URL(options.mcpUrl);
|
|
723
|
-
mcpUrl.searchParams.set('source', CONNECT_SOURCE);
|
|
724
|
-
mcpUrl.searchParams.set('client', harness);
|
|
725
|
-
mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
|
|
726
|
-
const result = spec.write(mcpUrl.toString(), resolvedKey);
|
|
727
|
-
if (result.status === 'corrupt_skip') {
|
|
728
|
-
// The message fallback would leak the config path; tag instead.
|
|
729
|
-
throw new ConnectStepError(`Could not update ${spec.configPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
730
|
-
`Fix the file, then re-run: tinyfish connect ${harness}`, 'invalid_config', { failureDetail: `${harness}_mcp_json_corrupt` });
|
|
731
|
-
}
|
|
732
|
-
if (result.backupPath) {
|
|
733
|
-
errLine(`Backed up existing ${displayName} MCP config to ${result.backupPath}`);
|
|
734
|
-
}
|
|
735
|
-
if (result.repaired) {
|
|
736
|
-
errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
|
|
737
|
-
}
|
|
738
|
-
telemetry.track('checkpoint', { phase: 'registered' });
|
|
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 });
|
|
748
|
-
const postInstallFailed = installSkillPostSettle(spec, displayName, state, telemetry, options);
|
|
749
|
-
if (keyless) {
|
|
750
|
-
errLine(`TinyFish keyless Search is connected in ${displayName}.`);
|
|
751
|
-
}
|
|
752
|
-
else if (resolvedKey) {
|
|
753
|
-
// Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
|
|
754
|
-
const verify = await verifyMcpAuth(resolvedKey, apiBaseFromMcpUrl(options.mcpUrl));
|
|
755
|
-
if (verify.ok) {
|
|
756
|
-
errLine(spec.copy.verified);
|
|
757
|
-
}
|
|
758
|
-
else {
|
|
759
|
-
errLine(spec.copy.authFailed(verify.reason ?? 'unknown'));
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
else if (detectHumanInitiated() && spec.tryLaunch(mcpUrl.toString())) {
|
|
763
|
-
errLine(spec.copy.launched);
|
|
764
|
-
}
|
|
765
|
-
else {
|
|
766
|
-
errLine(spec.copy.reload);
|
|
767
|
-
}
|
|
768
|
-
const note = spec.postConnectNote?.();
|
|
769
|
-
if (note)
|
|
770
|
-
errLine(note);
|
|
771
|
-
reportPostInstallFailure(harness, postInstallFailed, options.onPostInstallFailed);
|
|
772
|
-
});
|
|
773
|
-
return state.authMode;
|
|
774
|
-
}
|
|
775
|
-
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
776
|
-
export async function connectCursor(options) {
|
|
777
|
-
await connectConfigFileHarness('cursor', options);
|
|
778
|
-
}
|
|
779
|
-
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
|
780
|
-
function launchCursorDeeplink(mcpUrl) {
|
|
781
|
-
const result = openExternalUrl(cursorInstallDeeplink(mcpUrl));
|
|
782
|
-
return !result.error && result.status === 0;
|
|
783
|
-
}
|
|
784
|
-
/** Total over the non-native harnesses: a new one fails to compile until wired here. */
|
|
785
|
-
const LAUNCH_OVERRIDES = {
|
|
786
|
-
omp: launchOmpWalkthrough,
|
|
787
|
-
pi: launchPiWalkthrough,
|
|
788
|
-
openclaw: launchOpenClawWalkthrough,
|
|
789
|
-
// Cursor has no chat CLI to launch.
|
|
790
|
-
cursor: () => {
|
|
791
|
-
throw new Error('Cursor has no launchable walkthrough. Open Cursor and start chatting.');
|
|
792
|
-
},
|
|
793
|
-
};
|
|
794
|
-
const isNonNative = (harness) => harness in LAUNCH_OVERRIDES;
|
|
795
|
-
export async function launchAgent(client) {
|
|
796
|
-
if (isNonNative(client))
|
|
797
|
-
return void (await LAUNCH_OVERRIDES[client]());
|
|
798
|
-
await launchNativeMcpClient(requireNative(client));
|
|
799
|
-
}
|
|
800
|
-
/** Total over the non-native harnesses; native harnesses use the descriptor engine. */
|
|
801
|
-
const CONNECTOR_OVERRIDES = {
|
|
802
|
-
openclaw: async (options) => {
|
|
803
|
-
await connectOpenClaw(options);
|
|
804
|
-
return undefined;
|
|
805
|
-
},
|
|
806
|
-
cursor: async (options) => {
|
|
807
|
-
await connectCursor(options);
|
|
808
|
-
// `connect cursor --launch` still connects; it just cannot launch after.
|
|
809
|
-
if (options.launch) {
|
|
810
|
-
errLine('Cursor has no launchable walkthrough. Open Cursor and start chatting.');
|
|
811
|
-
}
|
|
812
|
-
return undefined;
|
|
813
|
-
},
|
|
814
|
-
omp: async (options) => {
|
|
815
|
-
const authMode = await connectConfigFileHarness('omp', options);
|
|
816
|
-
if (options.launch) {
|
|
817
|
-
try {
|
|
818
|
-
await launchOmpWalkthrough(undefined, authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
|
|
819
|
-
}
|
|
820
|
-
catch (error) {
|
|
821
|
-
// Post-settle, like the native walkthrough step: never fail the connect.
|
|
822
|
-
errLine(error instanceof Error ? error.message : String(error));
|
|
823
|
-
errLine(finishSetupHint('omp'));
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
return authMode;
|
|
827
|
-
},
|
|
828
|
-
pi: async (options) => {
|
|
829
|
-
await connectConfigFileHarness('pi', options);
|
|
830
|
-
if (options.launch) {
|
|
831
|
-
try {
|
|
832
|
-
await launchPiWalkthrough();
|
|
833
|
-
}
|
|
834
|
-
catch (error) {
|
|
835
|
-
errLine(error instanceof Error ? error.message : String(error));
|
|
836
|
-
errLine(finishSetupHint('pi'));
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
return undefined;
|
|
840
|
-
},
|
|
841
|
-
};
|
|
842
|
-
function requireNative(harness) {
|
|
843
|
-
const client = NATIVE_BY_HARNESS.get(harness);
|
|
844
|
-
if (!client)
|
|
845
|
-
throw new Error(`No native MCP descriptor for ${harness}`);
|
|
846
|
-
return client;
|
|
847
|
-
}
|
|
848
|
-
export async function connectHarness(harness, options) {
|
|
849
|
-
if (isNonNative(harness))
|
|
850
|
-
return CONNECTOR_OVERRIDES[harness](options);
|
|
851
|
-
return connectNativeMcpClient(requireNative(harness), options);
|
|
852
|
-
}
|
|
9
|
+
import { DEFAULT_MCP_URL } from '../lib/mcp-endpoint.js';
|
|
10
|
+
import { connectHarness } from '../lib/connect-harness.js';
|
|
853
11
|
function parseMcpUrlOption(options) {
|
|
854
12
|
let mcpUrl = options.url ?? DEFAULT_MCP_URL;
|
|
855
13
|
const parsedUrl = URL.parse(mcpUrl);
|