@tiny-fish/cli 0.30.2-next.266 → 0.30.2-next.268
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.js +215 -177
- package/dist/lib/connect-all.js +339 -245
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -148,6 +148,124 @@ async function runMissingHarnessFallback(displayName, connectClient, state, tele
|
|
|
148
148
|
errLine('The TinyFish CLI is installed and working.');
|
|
149
149
|
errLine('Using a different agent? Run: tinyfish connect --all');
|
|
150
150
|
}
|
|
151
|
+
/** Resolved before the removals, so a refusal strands no registration. */
|
|
152
|
+
async function prepareInstallAuth(client, options, keyAuthSupported) {
|
|
153
|
+
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
154
|
+
const storedKey = validatedApiKey(options.apiKey);
|
|
155
|
+
const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
|
|
156
|
+
// Refuses here when key-required; others keep their OAuth argv.
|
|
157
|
+
const buildAddArgs = keyedAdd
|
|
158
|
+
? (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key)
|
|
159
|
+
: keylessAddArgs(client, keyAuthSupported, options.mcpUrl);
|
|
160
|
+
if (options.keyAuthOnly && !keyedAdd) {
|
|
161
|
+
// All native clients have keyAuth; missing flags mean an old install.
|
|
162
|
+
if (client.keyAuth) {
|
|
163
|
+
throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
|
|
164
|
+
`needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
|
|
165
|
+
}
|
|
166
|
+
throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
|
|
167
|
+
`terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
|
|
168
|
+
}
|
|
169
|
+
// Verify first: a revoked key in a foreign .env is unrecoverable.
|
|
170
|
+
if (keyedAdd?.spec.seedKey) {
|
|
171
|
+
await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
|
|
172
|
+
}
|
|
173
|
+
return { keyedAdd, buildAddArgs };
|
|
174
|
+
}
|
|
175
|
+
/** Returns the seeded-credential note when a key was written into a user-owned file. */
|
|
176
|
+
function performMcpAdd(client, options, plan, attemptId) {
|
|
177
|
+
const { keyedAdd, buildAddArgs } = plan;
|
|
178
|
+
const mcpUrl = new URL(options.mcpUrl);
|
|
179
|
+
mcpUrl.searchParams.set('source', CONNECT_SOURCE);
|
|
180
|
+
mcpUrl.searchParams.set('client', client.connectClient);
|
|
181
|
+
mcpUrl.searchParams.set('connect_attempt_id', attemptId);
|
|
182
|
+
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
183
|
+
// Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
|
|
184
|
+
const addArgs = buildAddArgs(mcpUrl.toString());
|
|
185
|
+
// Capturing an inline-OAuth add hides the sign-in URL and hangs.
|
|
186
|
+
const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
|
|
187
|
+
// After the removals: an interrupt there leaves no key behind.
|
|
188
|
+
const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
|
|
189
|
+
const addOptions = interactiveAdd
|
|
190
|
+
? { stdio: 'inherit', timeout: options.authTimeoutMs }
|
|
191
|
+
: seeded
|
|
192
|
+
? {
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
// Without it, the 1 MiB default overflows into a misread Ctrl+C.
|
|
195
|
+
maxBuffer: STEP_MAX_BUFFER,
|
|
196
|
+
// The unattended path had no bound at all before; captureStdio's is the floor.
|
|
197
|
+
timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
|
|
198
|
+
input: seeded.stdinInput,
|
|
199
|
+
env: seeded.env,
|
|
200
|
+
}
|
|
201
|
+
: captureStdio(options.verbose ?? false);
|
|
202
|
+
const addResult = spawn.sync(client.command, addArgs, addOptions);
|
|
203
|
+
// Rolls back first: signInStepError throws on a Ctrl+C.
|
|
204
|
+
const failAdd = (build) => {
|
|
205
|
+
seeded?.rollback();
|
|
206
|
+
const error = build();
|
|
207
|
+
replay(capturedOutput(addResult));
|
|
208
|
+
throw error;
|
|
209
|
+
};
|
|
210
|
+
if (addResult.error || addResult.status !== 0) {
|
|
211
|
+
failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
|
|
212
|
+
}
|
|
213
|
+
const registration = seeded?.confirmRegistered();
|
|
214
|
+
if (registration && !registration.ok) {
|
|
215
|
+
failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
|
|
216
|
+
`${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
|
|
217
|
+
}
|
|
218
|
+
return seeded?.note;
|
|
219
|
+
}
|
|
220
|
+
function performLoginStep(client, pendingLogin, options) {
|
|
221
|
+
errLine('Signing in to TinyFish...');
|
|
222
|
+
const loginResult = spawn.sync(client.command, [...pendingLogin], {
|
|
223
|
+
stdio: 'inherit',
|
|
224
|
+
timeout: options.authTimeoutMs,
|
|
225
|
+
});
|
|
226
|
+
if (loginResult.error || loginResult.status !== 0) {
|
|
227
|
+
throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
|
|
231
|
+
if (useKeyAuth) {
|
|
232
|
+
errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
|
|
233
|
+
// Every other credential the CLI writes into a user-owned file says so too.
|
|
234
|
+
if (seededNote)
|
|
235
|
+
errLine(seededNote);
|
|
236
|
+
}
|
|
237
|
+
else if (degradedNotes) {
|
|
238
|
+
// Printed here, not with the closing line, so a `--launch` run sees it before the handover.
|
|
239
|
+
errLine(degradedNotes.deferred);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function completeAuthFlow(client, options, state, telemetry, ctx) {
|
|
243
|
+
const { useKeyAuth, loginDegraded, loginArgs, probeAuthenticated } = ctx;
|
|
244
|
+
// Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
|
|
245
|
+
// whether the login step below still runs. Unknown keeps the pre-probe behaviour.
|
|
246
|
+
const addSignedIn = probeAuthenticated?.();
|
|
247
|
+
if (addSignedIn)
|
|
248
|
+
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
249
|
+
announceAuthNotes(useKeyAuth, loginDegraded ? client.degradedAuthNotes : undefined, ctx.seededNote);
|
|
250
|
+
// A probed client signs in again only when its inline OAuth is known to have been abandoned.
|
|
251
|
+
const retryLogin = probeAuthenticated ? addSignedIn === false : true;
|
|
252
|
+
const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
|
|
253
|
+
let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
254
|
+
if (pendingLogin) {
|
|
255
|
+
state.stage = 'client_oauth';
|
|
256
|
+
performLoginStep(client, pendingLogin, options);
|
|
257
|
+
if (probeAuthenticated?.() === false)
|
|
258
|
+
signInDeferred = true;
|
|
259
|
+
else
|
|
260
|
+
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
261
|
+
}
|
|
262
|
+
const authMode = useKeyAuth
|
|
263
|
+
? AuthMode.ApiKey
|
|
264
|
+
: signInDeferred
|
|
265
|
+
? 'deferred'
|
|
266
|
+
: AuthMode.OAuth;
|
|
267
|
+
return { authMode, signInDeferred };
|
|
268
|
+
}
|
|
151
269
|
async function connectNativeMcpClient(client, options) {
|
|
152
270
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
153
271
|
const state = { stage: 'prerequisite_check', settled: false };
|
|
@@ -160,118 +278,27 @@ async function connectNativeMcpClient(client, options) {
|
|
|
160
278
|
const { optionalSupported, keyAuthSupported, harnessVersion } = support;
|
|
161
279
|
telemetry.setHarnessVersion(harnessVersion);
|
|
162
280
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.deferOutro);
|
|
166
|
-
const useKeyAuth = !!keyedAdd;
|
|
167
|
-
// Refuses here when key-required; others keep their OAuth argv.
|
|
168
|
-
const buildAddArgs = keyedAdd
|
|
169
|
-
? (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key)
|
|
170
|
-
: keylessAddArgs(client, keyAuthSupported, options.mcpUrl);
|
|
171
|
-
if (options.keyAuthOnly && !useKeyAuth) {
|
|
172
|
-
// All native clients have keyAuth; missing flags mean an old install.
|
|
173
|
-
if (client.keyAuth) {
|
|
174
|
-
throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
|
|
175
|
-
`needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, 'harness_too_old');
|
|
176
|
-
}
|
|
177
|
-
throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
|
|
178
|
-
`terminal. Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
|
|
179
|
-
}
|
|
180
|
-
// Verify first: a revoked key in a foreign .env is unrecoverable.
|
|
181
|
-
if (keyedAdd?.spec.seedKey) {
|
|
182
|
-
await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
|
|
183
|
-
}
|
|
281
|
+
const plan = await prepareInstallAuth(client, options, keyAuthSupported);
|
|
282
|
+
const useKeyAuth = !!plan.keyedAdd;
|
|
184
283
|
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
185
284
|
state.stage = 'registration_cleanup';
|
|
186
285
|
for (const removal of client.removals)
|
|
187
286
|
removeExistingRegistration(client, removal, options.verbose ?? false);
|
|
188
|
-
const mcpUrl = new URL(options.mcpUrl);
|
|
189
|
-
mcpUrl.searchParams.set('source', CONNECT_SOURCE);
|
|
190
|
-
mcpUrl.searchParams.set('client', client.connectClient);
|
|
191
|
-
mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
|
|
192
287
|
// Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
|
|
193
288
|
const loginDegraded = state.harnessDegraded === true;
|
|
194
289
|
const loginArgs = loginDegraded ? undefined : client.loginArgs;
|
|
195
290
|
// A probed client authenticates inside `mcp add` too, so a failure there is either.
|
|
196
291
|
state.stage =
|
|
197
292
|
loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
|
|
198
|
-
|
|
199
|
-
// Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
|
|
200
|
-
const addArgs = buildAddArgs(mcpUrl.toString());
|
|
201
|
-
// Capturing an inline-OAuth add hides the sign-in URL and hangs.
|
|
202
|
-
const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
|
|
203
|
-
// After the removals: an interrupt there leaves no key behind.
|
|
204
|
-
const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
|
|
205
|
-
const addOptions = interactiveAdd
|
|
206
|
-
? { stdio: 'inherit', timeout: options.authTimeoutMs }
|
|
207
|
-
: seeded
|
|
208
|
-
? {
|
|
209
|
-
encoding: 'utf8',
|
|
210
|
-
// Without it, the 1 MiB default overflows into a misread Ctrl+C.
|
|
211
|
-
maxBuffer: STEP_MAX_BUFFER,
|
|
212
|
-
// The unattended path had no bound at all before; captureStdio's is the floor.
|
|
213
|
-
timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
|
|
214
|
-
input: seeded.stdinInput,
|
|
215
|
-
env: seeded.env,
|
|
216
|
-
}
|
|
217
|
-
: captureStdio(options.verbose ?? false);
|
|
218
|
-
const addResult = spawn.sync(client.command, addArgs, addOptions);
|
|
219
|
-
// Rolls back first: signInStepError throws on a Ctrl+C.
|
|
220
|
-
const failAdd = (build) => {
|
|
221
|
-
seeded?.rollback();
|
|
222
|
-
const error = build();
|
|
223
|
-
replay(capturedOutput(addResult));
|
|
224
|
-
throw error;
|
|
225
|
-
};
|
|
226
|
-
if (addResult.error || addResult.status !== 0) {
|
|
227
|
-
failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
|
|
228
|
-
}
|
|
229
|
-
const registration = seeded?.confirmRegistered();
|
|
230
|
-
if (registration && !registration.ok) {
|
|
231
|
-
failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
|
|
232
|
-
`${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config'));
|
|
233
|
-
}
|
|
293
|
+
const seededNote = performMcpAdd(client, options, plan, telemetry.attemptId);
|
|
234
294
|
telemetry.track('checkpoint', { phase: 'registered' });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
|
|
243
|
-
// Every other credential the CLI writes into a user-owned file says so too.
|
|
244
|
-
if (seeded)
|
|
245
|
-
errLine(seeded.note);
|
|
246
|
-
}
|
|
247
|
-
else if (degradedNotes) {
|
|
248
|
-
// Printed here, not with the closing line, so a `--launch` run sees it before the handover.
|
|
249
|
-
errLine(degradedNotes.deferred);
|
|
250
|
-
}
|
|
251
|
-
// A probed client signs in again only when its inline OAuth is known to have been abandoned.
|
|
252
|
-
const retryLogin = probeAuthenticated ? addSignedIn === false : true;
|
|
253
|
-
const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
|
|
254
|
-
let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
255
|
-
if (pendingLogin) {
|
|
256
|
-
state.stage = 'client_oauth';
|
|
257
|
-
errLine('Signing in to TinyFish...');
|
|
258
|
-
const loginResult = spawn.sync(client.command, pendingLogin, {
|
|
259
|
-
stdio: 'inherit',
|
|
260
|
-
timeout: options.authTimeoutMs,
|
|
261
|
-
});
|
|
262
|
-
if (loginResult.error || loginResult.status !== 0) {
|
|
263
|
-
throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
|
|
264
|
-
}
|
|
265
|
-
if (probeAuthenticated?.() === false)
|
|
266
|
-
signInDeferred = true;
|
|
267
|
-
else
|
|
268
|
-
telemetry.track('checkpoint', { phase: 'oauth_done' });
|
|
269
|
-
}
|
|
270
|
-
const authMode = useKeyAuth
|
|
271
|
-
? AuthMode.ApiKey
|
|
272
|
-
: signInDeferred
|
|
273
|
-
? 'deferred'
|
|
274
|
-
: AuthMode.OAuth;
|
|
295
|
+
const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
|
|
296
|
+
useKeyAuth,
|
|
297
|
+
loginDegraded,
|
|
298
|
+
loginArgs,
|
|
299
|
+
probeAuthenticated,
|
|
300
|
+
seededNote,
|
|
301
|
+
});
|
|
275
302
|
state.authMode = authMode;
|
|
276
303
|
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
277
304
|
settle(state, telemetry, 'completed', { authMode });
|
|
@@ -611,6 +638,89 @@ const CLIENT_CONNECTORS = {
|
|
|
611
638
|
}
|
|
612
639
|
},
|
|
613
640
|
};
|
|
641
|
+
function parseMcpUrlOption(options) {
|
|
642
|
+
let mcpUrl = options.url ?? DEFAULT_MCP_URL;
|
|
643
|
+
if (mcpUrl.startsWith('-')) {
|
|
644
|
+
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
645
|
+
}
|
|
646
|
+
let parsedUrl;
|
|
647
|
+
try {
|
|
648
|
+
parsedUrl = new URL(mcpUrl);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
652
|
+
}
|
|
653
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
654
|
+
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
655
|
+
}
|
|
656
|
+
// Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
|
|
657
|
+
// base — registration re-stamps it, and nothing else may carry it.
|
|
658
|
+
const pageAttemptId = parsedUrl.searchParams.get('connect_attempt_id');
|
|
659
|
+
let attemptId;
|
|
660
|
+
if (pageAttemptId !== null) {
|
|
661
|
+
parsedUrl.searchParams.delete('connect_attempt_id');
|
|
662
|
+
mcpUrl = parsedUrl.toString();
|
|
663
|
+
if (z.uuid().safeParse(pageAttemptId).success)
|
|
664
|
+
attemptId = pageAttemptId;
|
|
665
|
+
}
|
|
666
|
+
return { mcpUrl, attemptId };
|
|
667
|
+
}
|
|
668
|
+
async function connectAllPath(options, mcpUrl) {
|
|
669
|
+
if (options.launch) {
|
|
670
|
+
throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
|
|
671
|
+
}
|
|
672
|
+
const exitCode = await runConnectAll({
|
|
673
|
+
apiKey: options.apiKey,
|
|
674
|
+
mcpUrl,
|
|
675
|
+
dryRun: options.dryRun ?? false,
|
|
676
|
+
uninstall: options.uninstall ?? false,
|
|
677
|
+
noLaunch: options.skipLaunch === true,
|
|
678
|
+
verbose: options.verbose ?? false,
|
|
679
|
+
});
|
|
680
|
+
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
681
|
+
if (!process.exitCode)
|
|
682
|
+
process.exitCode = exitCode;
|
|
683
|
+
}
|
|
684
|
+
async function connectPickPath(options, mcpUrl) {
|
|
685
|
+
if (options.launch) {
|
|
686
|
+
throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
|
|
687
|
+
}
|
|
688
|
+
const humanInitiated = detectHumanInitiated();
|
|
689
|
+
if (!humanInitiated) {
|
|
690
|
+
errLine('No terminal detected: connecting every detected harness (same as tinyfish connect --all).');
|
|
691
|
+
}
|
|
692
|
+
const exitCode = await runConnectAll({
|
|
693
|
+
apiKey: options.apiKey,
|
|
694
|
+
mcpUrl,
|
|
695
|
+
dryRun: false,
|
|
696
|
+
uninstall: false,
|
|
697
|
+
pick: humanInitiated,
|
|
698
|
+
verbose: options.verbose ?? false,
|
|
699
|
+
});
|
|
700
|
+
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
701
|
+
if (!process.exitCode)
|
|
702
|
+
process.exitCode = exitCode;
|
|
703
|
+
}
|
|
704
|
+
async function connectSingleClient(client, options, mcpUrl, attemptId) {
|
|
705
|
+
const connectOptions = {
|
|
706
|
+
apiKey: options.apiKey,
|
|
707
|
+
mcpUrl,
|
|
708
|
+
launch: options.launch ?? false,
|
|
709
|
+
attemptId,
|
|
710
|
+
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
711
|
+
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
712
|
+
// Only here: a missing harness rescues via the CLI (PF-3707).
|
|
713
|
+
fallbackWhenMissing: true,
|
|
714
|
+
verbose: options.verbose ?? false,
|
|
715
|
+
};
|
|
716
|
+
const connector = CLIENT_CONNECTORS[client];
|
|
717
|
+
if (!connector) {
|
|
718
|
+
throw new Error('Unsupported client: ' +
|
|
719
|
+
client +
|
|
720
|
+
'. Supported clients: claude-code, codex, cursor, grok, hermes, openclaw, opencode');
|
|
721
|
+
}
|
|
722
|
+
await connector(connectOptions);
|
|
723
|
+
}
|
|
614
724
|
export function registerConnect(program) {
|
|
615
725
|
program
|
|
616
726
|
.command('connect')
|
|
@@ -628,94 +738,22 @@ export function registerConnect(program) {
|
|
|
628
738
|
if (options.apiKey && !validateKeyFormat(options.apiKey)) {
|
|
629
739
|
throw new Error('Invalid --api-key value');
|
|
630
740
|
}
|
|
631
|
-
|
|
632
|
-
if (mcpUrl.startsWith('-')) {
|
|
633
|
-
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
634
|
-
}
|
|
635
|
-
let parsedUrl;
|
|
636
|
-
try {
|
|
637
|
-
parsedUrl = new URL(mcpUrl);
|
|
638
|
-
}
|
|
639
|
-
catch {
|
|
640
|
-
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
641
|
-
}
|
|
642
|
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
643
|
-
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
644
|
-
}
|
|
645
|
-
// Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
|
|
646
|
-
// base — registration re-stamps it, and nothing else may carry it.
|
|
647
|
-
const pageAttemptId = parsedUrl.searchParams.get('connect_attempt_id');
|
|
648
|
-
let attemptId;
|
|
649
|
-
if (pageAttemptId !== null) {
|
|
650
|
-
parsedUrl.searchParams.delete('connect_attempt_id');
|
|
651
|
-
mcpUrl = parsedUrl.toString();
|
|
652
|
-
if (z.uuid().safeParse(pageAttemptId).success)
|
|
653
|
-
attemptId = pageAttemptId;
|
|
654
|
-
}
|
|
741
|
+
const { mcpUrl, attemptId } = parseMcpUrlOption(options);
|
|
655
742
|
if (options.all) {
|
|
656
743
|
if (client) {
|
|
657
744
|
throw new Error('Pass either a client or --all, not both.');
|
|
658
745
|
}
|
|
659
|
-
|
|
660
|
-
throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
|
|
661
|
-
}
|
|
662
|
-
const exitCode = await runConnectAll({
|
|
663
|
-
apiKey: options.apiKey,
|
|
664
|
-
mcpUrl,
|
|
665
|
-
dryRun: options.dryRun ?? false,
|
|
666
|
-
uninstall: options.uninstall ?? false,
|
|
667
|
-
noLaunch: options.skipLaunch === true,
|
|
668
|
-
verbose: options.verbose ?? false,
|
|
669
|
-
});
|
|
670
|
-
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
671
|
-
if (!process.exitCode)
|
|
672
|
-
process.exitCode = exitCode;
|
|
746
|
+
await connectAllPath(options, mcpUrl);
|
|
673
747
|
return;
|
|
674
748
|
}
|
|
675
749
|
if (options.dryRun || options.uninstall || options.skipLaunch) {
|
|
676
750
|
throw new Error('--dry-run, --uninstall and --skip-launch are only supported with --all.');
|
|
677
751
|
}
|
|
678
752
|
if (!client) {
|
|
679
|
-
|
|
680
|
-
throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
|
|
681
|
-
}
|
|
682
|
-
const humanInitiated = detectHumanInitiated();
|
|
683
|
-
if (!humanInitiated) {
|
|
684
|
-
errLine('No terminal detected: connecting every detected harness (same as tinyfish connect --all).');
|
|
685
|
-
}
|
|
686
|
-
const exitCode = await runConnectAll({
|
|
687
|
-
apiKey: options.apiKey,
|
|
688
|
-
mcpUrl,
|
|
689
|
-
dryRun: false,
|
|
690
|
-
uninstall: false,
|
|
691
|
-
pick: humanInitiated,
|
|
692
|
-
verbose: options.verbose ?? false,
|
|
693
|
-
});
|
|
694
|
-
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
695
|
-
if (!process.exitCode)
|
|
696
|
-
process.exitCode = exitCode;
|
|
753
|
+
await connectPickPath(options, mcpUrl);
|
|
697
754
|
return;
|
|
698
755
|
}
|
|
699
|
-
|
|
700
|
-
apiKey: options.apiKey,
|
|
701
|
-
mcpUrl,
|
|
702
|
-
launch: options.launch ?? false,
|
|
703
|
-
attemptId,
|
|
704
|
-
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
705
|
-
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
706
|
-
// Only here: a missing harness rescues via the CLI (PF-3707).
|
|
707
|
-
fallbackWhenMissing: true,
|
|
708
|
-
verbose: options.verbose ?? false,
|
|
709
|
-
};
|
|
710
|
-
const connector = CLIENT_CONNECTORS[client];
|
|
711
|
-
if (connector) {
|
|
712
|
-
await connector(connectOptions);
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
throw new Error('Unsupported client: ' +
|
|
716
|
-
client +
|
|
717
|
-
'. Supported clients: claude-code, codex, cursor, grok, hermes, openclaw, opencode');
|
|
718
|
-
}
|
|
756
|
+
await connectSingleClient(client, options, mcpUrl, attemptId);
|
|
719
757
|
// Deferred to here so the notice lands after install output, not interleaved with it.
|
|
720
758
|
emitNotice();
|
|
721
759
|
});
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -63,17 +63,17 @@ const HARNESS_CONNECTORS = {
|
|
|
63
63
|
return undefined;
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
|
-
async function runHarnessConnect(harness, opts,
|
|
66
|
+
async function runHarnessConnect(harness, opts, extras) {
|
|
67
67
|
return HARNESS_CONNECTORS[harness]({
|
|
68
68
|
apiKey: opts.apiKey,
|
|
69
69
|
mcpUrl: opts.mcpUrl,
|
|
70
70
|
launch: false,
|
|
71
|
-
keyAuthOnly,
|
|
72
|
-
authTimeoutMs,
|
|
73
|
-
hoistedCliInstall,
|
|
74
|
-
onPostInstallFailed,
|
|
71
|
+
keyAuthOnly: extras.keyAuthOnly,
|
|
72
|
+
authTimeoutMs: extras.authTimeoutMs,
|
|
73
|
+
hoistedCliInstall: extras.hoistedCliInstall,
|
|
74
|
+
onPostInstallFailed: extras.onPostInstallFailed,
|
|
75
75
|
verbose: opts.verbose,
|
|
76
|
-
deferOutro,
|
|
76
|
+
deferOutro: extras.deferOutro,
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
79
|
/** Undefined means Ctrl+C: the caller stops the whole run. */
|
|
@@ -168,89 +168,80 @@ function hermesKeyKeptAdvisory() {
|
|
|
168
168
|
return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
|
|
169
169
|
'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
|
|
170
170
|
}
|
|
171
|
-
function
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return result;
|
|
181
|
-
// Appended, never replacing: an advisory must not hide the removal's message.
|
|
182
|
-
return {
|
|
183
|
-
...result,
|
|
184
|
-
errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
|
|
188
|
-
const detection = { harness, detected: true, configPath, installed: false };
|
|
189
|
-
if (harness !== 'cursor') {
|
|
190
|
-
const removal = uninstallRuns(harness);
|
|
191
|
-
if (!removal) {
|
|
192
|
-
return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
|
|
193
|
-
}
|
|
194
|
-
// Stale config: the harness binary is gone, so its removal command cannot run.
|
|
195
|
-
// openclaw is exempt — its removal runs through npx, not the harness binary.
|
|
196
|
-
if (harness !== 'openclaw' && !binaryOnPath) {
|
|
197
|
-
return {
|
|
198
|
-
...detection,
|
|
199
|
-
outcome: 'uninstall_pointer',
|
|
200
|
-
errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
|
|
201
|
-
fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
// Absent marker = never connected here, so a non-zero removal exit is a no-op.
|
|
205
|
-
// Unreadable config cannot prove that, so it keeps the pointer.
|
|
206
|
-
const connectedBefore = connectContextState(harness) !== 'absent';
|
|
207
|
-
let removed = false;
|
|
208
|
-
try {
|
|
209
|
-
for (const args of removal.runs) {
|
|
210
|
-
const result = spawnRemoval(removal.command, args, removal.timeoutMs);
|
|
211
|
-
if (result.status !== 0 && verbose) {
|
|
212
|
-
const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
|
|
213
|
-
errLine(`${removal.command} ${args.join(' ')}: ${details}`);
|
|
214
|
-
}
|
|
215
|
-
// Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
|
|
216
|
-
if (result.error) {
|
|
217
|
-
return {
|
|
218
|
-
...detection,
|
|
219
|
-
outcome: 'uninstalled',
|
|
220
|
-
removalFailed: true,
|
|
221
|
-
errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
|
|
222
|
-
fixCommand: uninstallPointer(harness),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
// Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
|
|
226
|
-
if (result.status === 0)
|
|
227
|
-
removed = true;
|
|
171
|
+
function runRemovalCommands(detection, removal, verbose) {
|
|
172
|
+
const { harness } = detection;
|
|
173
|
+
let removed = false;
|
|
174
|
+
try {
|
|
175
|
+
for (const args of removal.runs) {
|
|
176
|
+
const result = spawnRemoval(removal.command, args, removal.timeoutMs);
|
|
177
|
+
if (result.status !== 0 && verbose) {
|
|
178
|
+
const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
|
|
179
|
+
errLine(`${removal.command} ${args.join(' ')}: ${details}`);
|
|
228
180
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (e instanceof ConnectInterruptedError) {
|
|
181
|
+
// Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
|
|
182
|
+
if (result.error) {
|
|
232
183
|
return {
|
|
233
184
|
...detection,
|
|
234
|
-
outcome: '
|
|
235
|
-
|
|
185
|
+
outcome: 'uninstalled',
|
|
186
|
+
removalFailed: true,
|
|
187
|
+
errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
|
|
188
|
+
fixCommand: uninstallPointer(harness),
|
|
236
189
|
};
|
|
237
190
|
}
|
|
238
|
-
|
|
191
|
+
// Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
|
|
192
|
+
if (result.status === 0)
|
|
193
|
+
removed = true;
|
|
239
194
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return {
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (e instanceof ConnectInterruptedError) {
|
|
198
|
+
return {
|
|
199
|
+
...detection,
|
|
200
|
+
outcome: 'interrupted',
|
|
201
|
+
fixCommand: 'tinyfish connect --all --uninstall',
|
|
202
|
+
};
|
|
244
203
|
}
|
|
245
|
-
|
|
246
|
-
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
return { kind: 'continue', removed };
|
|
207
|
+
}
|
|
208
|
+
function uninstallViaCommands(detection, verbose, binaryOnPath) {
|
|
209
|
+
const { harness } = detection;
|
|
210
|
+
const removal = uninstallRuns(harness);
|
|
211
|
+
if (!removal) {
|
|
212
|
+
return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
|
|
213
|
+
}
|
|
214
|
+
// Stale config: the harness binary is gone, so its removal command cannot run.
|
|
215
|
+
// openclaw is exempt — its removal runs through npx, not the harness binary.
|
|
216
|
+
if (harness !== 'openclaw' && !binaryOnPath) {
|
|
247
217
|
return {
|
|
248
218
|
...detection,
|
|
249
219
|
outcome: 'uninstall_pointer',
|
|
250
|
-
errorMessage:
|
|
251
|
-
fixCommand: `
|
|
220
|
+
errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
|
|
221
|
+
fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
|
|
252
222
|
};
|
|
253
223
|
}
|
|
224
|
+
// Absent marker = never connected here, so a non-zero removal exit is a no-op.
|
|
225
|
+
// Unreadable config cannot prove that, so it keeps the pointer.
|
|
226
|
+
const connectedBefore = connectContextState(harness) !== 'absent';
|
|
227
|
+
const ran = runRemovalCommands(detection, removal, verbose);
|
|
228
|
+
if (!('kind' in ran))
|
|
229
|
+
return ran;
|
|
230
|
+
if (ran.removed) {
|
|
231
|
+
// Kept marker makes doctor emit a registration-lost repair that undoes this.
|
|
232
|
+
clearConnectContext(harness);
|
|
233
|
+
return { ...detection, outcome: 'uninstalled' };
|
|
234
|
+
}
|
|
235
|
+
if (!connectedBefore)
|
|
236
|
+
return { ...detection, outcome: 'uninstall_noop' };
|
|
237
|
+
return {
|
|
238
|
+
...detection,
|
|
239
|
+
outcome: 'uninstall_pointer',
|
|
240
|
+
errorMessage: 'could not confirm removal (command exited non-zero; there may have been nothing to remove).',
|
|
241
|
+
fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function uninstallCursor(detection) {
|
|
254
245
|
let result;
|
|
255
246
|
try {
|
|
256
247
|
result = removeCursorMcpServer();
|
|
@@ -273,9 +264,117 @@ function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
|
|
|
273
264
|
clearConnectContext('cursor');
|
|
274
265
|
return { ...detection, outcome: 'uninstalled' };
|
|
275
266
|
}
|
|
276
|
-
|
|
277
|
-
const { harness, configPath,
|
|
278
|
-
|
|
267
|
+
function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
|
|
268
|
+
const detection = { harness, detected: true, configPath, installed: false };
|
|
269
|
+
if (harness === 'cursor')
|
|
270
|
+
return uninstallCursor(detection);
|
|
271
|
+
return uninstallViaCommands(detection, verbose, binaryOnPath);
|
|
272
|
+
}
|
|
273
|
+
function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
|
|
274
|
+
const result = removeHarnessRegistration(harness, configPath, verbose, binaryOnPath);
|
|
275
|
+
if (harness !== 'hermes')
|
|
276
|
+
return result;
|
|
277
|
+
// Clearing a key the entry still references would break a working install.
|
|
278
|
+
const removed = !result.removalFailed &&
|
|
279
|
+
(result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
|
|
280
|
+
const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
|
|
281
|
+
if (!advisory)
|
|
282
|
+
return result;
|
|
283
|
+
// Appended, never replacing: an advisory must not hide the removal's message.
|
|
284
|
+
return {
|
|
285
|
+
...result,
|
|
286
|
+
errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function headlessGate(base, oauthBudget, fixCommand) {
|
|
290
|
+
const { harness } = base;
|
|
291
|
+
// A CLI-login sign-in is interactive-only — unboundable headless.
|
|
292
|
+
if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
|
|
293
|
+
return {
|
|
294
|
+
result: { ...base, outcome: 'auth_pending', fixCommand },
|
|
295
|
+
headlessAuth: false,
|
|
296
|
+
spentBrowserSlot: false,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
let spentBrowserSlot = false;
|
|
300
|
+
if (BROWSER_SIGN_IN.has(harness)) {
|
|
301
|
+
// One browser sign-in per run; the rest wait for a re-run.
|
|
302
|
+
if (oauthBudget.spent) {
|
|
303
|
+
return {
|
|
304
|
+
result: { ...base, outcome: 'auth_pending', fixCommand },
|
|
305
|
+
headlessAuth: false,
|
|
306
|
+
spentBrowserSlot: false,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
oauthBudget.spent = true;
|
|
310
|
+
spentBrowserSlot = true;
|
|
311
|
+
errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
|
|
312
|
+
`opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
|
|
313
|
+
}
|
|
314
|
+
return { headlessAuth: true, spentBrowserSlot };
|
|
315
|
+
}
|
|
316
|
+
/** Null: proceed. A result: settle with it. */
|
|
317
|
+
async function awaitSignInPrompt(base, prompt, fixCommand) {
|
|
318
|
+
errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[base.harness]}`);
|
|
319
|
+
try {
|
|
320
|
+
await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[base.harness]}... `);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// Ctrl+C stops the run; Ctrl+D only skips this harness.
|
|
325
|
+
if (error instanceof ConnectInterruptedError) {
|
|
326
|
+
errLine('Setup interrupted — run the command again to finish.');
|
|
327
|
+
process.exitCode = 130;
|
|
328
|
+
return { ...base, outcome: 'interrupted', fixCommand };
|
|
329
|
+
}
|
|
330
|
+
return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand) {
|
|
334
|
+
if (e instanceof SignInTimeoutError) {
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
outcome: 'auth_pending',
|
|
338
|
+
errorMessage: 'sign-in did not finish in time',
|
|
339
|
+
fixCommand,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// A pre-browser failure must not burn the run's only browser slot.
|
|
343
|
+
if (spentBrowserSlot &&
|
|
344
|
+
e instanceof ConnectStepError &&
|
|
345
|
+
NO_BROWSER_REASONS.has(e.failureReason)) {
|
|
346
|
+
oauthBudget.spent = false;
|
|
347
|
+
}
|
|
348
|
+
// Detection saw a name on PATH; the probe proved nothing is behind it.
|
|
349
|
+
if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
|
|
350
|
+
return { ...base, detected: false, outcome: 'not_installed' };
|
|
351
|
+
}
|
|
352
|
+
// Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
|
|
353
|
+
if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
|
|
354
|
+
return { ...base, outcome: 'harness_too_old', errorMessage: e.message, fixCommand };
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
...base,
|
|
358
|
+
outcome: 'failed',
|
|
359
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
360
|
+
fixCommand,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function authGate(base, oauthBudget, fixCommand, ctx) {
|
|
364
|
+
if (!ctx.isTTY && !ctx.keyed) {
|
|
365
|
+
return headlessGate(base, oauthBudget, fixCommand);
|
|
366
|
+
}
|
|
367
|
+
// Key-required harnesses refuse below; offering a browser sign-in would mislead.
|
|
368
|
+
if (ctx.isTTY && !ctx.keyed && ctx.prompt && !KEY_REQUIRED.has(base.harness)) {
|
|
369
|
+
const settled = await awaitSignInPrompt(base, ctx.prompt, fixCommand);
|
|
370
|
+
if (settled)
|
|
371
|
+
return { result: settled, headlessAuth: false, spentBrowserSlot: false };
|
|
372
|
+
}
|
|
373
|
+
return { headlessAuth: false, spentBrowserSlot: false };
|
|
374
|
+
}
|
|
375
|
+
/** The non-connect settlements: undetected, stale, dry-run, uninstall. */
|
|
376
|
+
function settleWithoutConnect(detection, opts, base) {
|
|
377
|
+
const { harness, configPath } = detection;
|
|
279
378
|
const noWork = noWorkOutcome(detection, opts.uninstall);
|
|
280
379
|
if (noWork === 'not_detected')
|
|
281
380
|
return { ...base, outcome: noWork };
|
|
@@ -288,94 +387,47 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
|
|
|
288
387
|
: planText(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
|
|
289
388
|
return { ...base, outcome: 'dry_run', fixCommand: plan };
|
|
290
389
|
}
|
|
291
|
-
if (opts.uninstall)
|
|
390
|
+
if (opts.uninstall) {
|
|
292
391
|
return uninstallHarness(harness, configPath, opts.verbose ?? false, detection.binaryOnPath);
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliInstall, deferOutro) {
|
|
396
|
+
const { harness, configPath, detected } = detection;
|
|
397
|
+
const base = { harness, detected, configPath, installed: false };
|
|
398
|
+
const settled = settleWithoutConnect(detection, opts, base);
|
|
399
|
+
if (settled)
|
|
400
|
+
return settled;
|
|
293
401
|
const isTTY = detectHumanInitiated();
|
|
294
402
|
const apiKey = validatedApiKey(opts.apiKey);
|
|
295
403
|
// A key only leaves the own-OAuth set where the harness can actually carry one.
|
|
296
404
|
const keyed = !!apiKey && KEY_AUTH_CAPABLE.has(harness);
|
|
297
405
|
const fixCommand = `tinyfish connect ${harness}`;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
|
|
303
|
-
return { ...base, outcome: 'auth_pending', fixCommand };
|
|
304
|
-
}
|
|
305
|
-
if (BROWSER_SIGN_IN.has(harness)) {
|
|
306
|
-
// One browser sign-in per run; the rest wait for a re-run.
|
|
307
|
-
if (oauthBudget.spent)
|
|
308
|
-
return { ...base, outcome: 'auth_pending', fixCommand };
|
|
309
|
-
oauthBudget.spent = true;
|
|
310
|
-
spentBrowserSlot = true;
|
|
311
|
-
errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
|
|
312
|
-
`opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
|
|
313
|
-
}
|
|
314
|
-
headlessAuth = true;
|
|
315
|
-
}
|
|
316
|
-
// Key-required harnesses refuse below; offering a browser sign-in would mislead.
|
|
317
|
-
if (isTTY && !keyed && prompt && !KEY_REQUIRED.has(harness)) {
|
|
318
|
-
errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
|
|
319
|
-
try {
|
|
320
|
-
await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
|
|
321
|
-
}
|
|
322
|
-
catch (error) {
|
|
323
|
-
// Ctrl+C stops the run; Ctrl+D only skips this harness.
|
|
324
|
-
if (error instanceof ConnectInterruptedError) {
|
|
325
|
-
errLine('Setup interrupted — run the command again to finish.');
|
|
326
|
-
process.exitCode = 130;
|
|
327
|
-
return { ...base, outcome: 'interrupted', fixCommand };
|
|
328
|
-
}
|
|
329
|
-
return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
|
|
330
|
-
}
|
|
331
|
-
}
|
|
406
|
+
const gate = await authGate(base, oauthBudget, fixCommand, { prompt, isTTY, keyed });
|
|
407
|
+
if (gate.result)
|
|
408
|
+
return gate.result;
|
|
409
|
+
const { headlessAuth, spentBrowserSlot } = gate;
|
|
332
410
|
// runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
|
|
333
411
|
const interruptedBefore = process.exitCode === 130;
|
|
334
412
|
let authMode;
|
|
335
413
|
let postInstallFailed = false;
|
|
336
414
|
try {
|
|
337
415
|
// A keyed install too old for key auth fails, no browser.
|
|
338
|
-
authMode = await runHarnessConnect(harness, opts,
|
|
339
|
-
|
|
340
|
-
|
|
416
|
+
authMode = await runHarnessConnect(harness, opts, {
|
|
417
|
+
keyAuthOnly: keyed && !isTTY,
|
|
418
|
+
authTimeoutMs: headlessAuth ? OAUTH_SIGN_IN_TIMEOUT_MS : undefined,
|
|
419
|
+
hoistedCliInstall,
|
|
420
|
+
onPostInstallFailed: () => {
|
|
421
|
+
postInstallFailed = true;
|
|
422
|
+
},
|
|
423
|
+
deferOutro,
|
|
424
|
+
});
|
|
341
425
|
}
|
|
342
426
|
catch (e) {
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
...base,
|
|
346
|
-
outcome: 'auth_pending',
|
|
347
|
-
errorMessage: 'sign-in did not finish in time',
|
|
348
|
-
fixCommand,
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
// A pre-browser failure must not burn the run's only browser slot.
|
|
352
|
-
if (spentBrowserSlot &&
|
|
353
|
-
e instanceof ConnectStepError &&
|
|
354
|
-
NO_BROWSER_REASONS.has(e.failureReason)) {
|
|
355
|
-
oauthBudget.spent = false;
|
|
356
|
-
}
|
|
357
|
-
// Detection saw a name on PATH; the probe proved nothing is behind it.
|
|
358
|
-
if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
|
|
359
|
-
return { ...base, detected: false, outcome: 'not_installed' };
|
|
360
|
-
}
|
|
361
|
-
// Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
|
|
362
|
-
if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
|
|
363
|
-
return {
|
|
364
|
-
...base,
|
|
365
|
-
outcome: 'harness_too_old',
|
|
366
|
-
errorMessage: e.message,
|
|
367
|
-
fixCommand: `tinyfish connect ${harness}`,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
return {
|
|
371
|
-
...base,
|
|
372
|
-
outcome: 'failed',
|
|
373
|
-
errorMessage: e instanceof Error ? e.message : String(e),
|
|
374
|
-
fixCommand: `tinyfish connect ${harness}`,
|
|
375
|
-
};
|
|
427
|
+
return mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand);
|
|
376
428
|
}
|
|
377
429
|
if (!interruptedBefore && process.exitCode === 130) {
|
|
378
|
-
return { ...base, outcome: 'interrupted', fixCommand
|
|
430
|
+
return { ...base, outcome: 'interrupted', fixCommand };
|
|
379
431
|
}
|
|
380
432
|
// The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
|
|
381
433
|
const keyAuthed = authMode ? authMode === 'api-key' : keyed;
|
|
@@ -391,20 +443,21 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
|
|
|
391
443
|
postInstallFailed,
|
|
392
444
|
};
|
|
393
445
|
}
|
|
394
|
-
function
|
|
446
|
+
function absentSummaryLine(result, uninstall) {
|
|
395
447
|
const name = DISPLAY_NAMES[result.harness];
|
|
448
|
+
const next = uninstall
|
|
449
|
+
? 'nothing to remove'
|
|
450
|
+
: `Install ${name}, then run: tinyfish connect ${result.harness}`;
|
|
396
451
|
if (result.outcome === 'not_installed') {
|
|
397
|
-
const next = uninstall
|
|
398
|
-
? 'nothing to remove'
|
|
399
|
-
: `Install ${name}, then run: tinyfish connect ${result.harness}`;
|
|
400
452
|
return `- ${name} — not installed (\`${HARNESS_COMMANDS[result.harness]}\` is on PATH but does not run). ${next}`;
|
|
401
453
|
}
|
|
402
454
|
if (!result.detected) {
|
|
403
|
-
const next = uninstall
|
|
404
|
-
? 'nothing to remove'
|
|
405
|
-
: `Install ${name}, then run: tinyfish connect ${result.harness}`;
|
|
406
455
|
return `- ${name} — not detected (no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, no ${result.configPath}). ${next}`;
|
|
407
456
|
}
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
function skippedSummaryLine(result) {
|
|
460
|
+
const name = DISPLAY_NAMES[result.harness];
|
|
408
461
|
if (result.outcome === 'stale_config') {
|
|
409
462
|
return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
|
|
410
463
|
}
|
|
@@ -422,6 +475,19 @@ function summaryLine(result, uninstall) {
|
|
|
422
475
|
? `… ${name} — ${result.errorMessage}. Finish it in a terminal: ${result.fixCommand}`
|
|
423
476
|
: `… ${name} — needs a browser sign-in. Run in a terminal: ${result.fixCommand}`;
|
|
424
477
|
}
|
|
478
|
+
if (result.outcome === 'interrupted') {
|
|
479
|
+
return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
|
|
480
|
+
}
|
|
481
|
+
if (result.outcome === 'harness_too_old') {
|
|
482
|
+
return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
|
|
483
|
+
}
|
|
484
|
+
if (result.outcome === 'failed') {
|
|
485
|
+
return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
|
|
486
|
+
}
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
function uninstallSummaryLine(result) {
|
|
490
|
+
const name = DISPLAY_NAMES[result.harness];
|
|
425
491
|
if (result.outcome === 'uninstall_pointer') {
|
|
426
492
|
// errorMessage set = a removal was attempted or impossible; absent = no removal command exists.
|
|
427
493
|
return result.errorMessage
|
|
@@ -439,15 +505,10 @@ function summaryLine(result, uninstall) {
|
|
|
439
505
|
return `⚠ ${name} — removed, but ${result.errorMessage}`;
|
|
440
506
|
return `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`;
|
|
441
507
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
|
|
447
|
-
}
|
|
448
|
-
if (result.outcome === 'failed') {
|
|
449
|
-
return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
|
|
450
|
-
}
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
function installedSummaryLine(result) {
|
|
511
|
+
const name = DISPLAY_NAMES[result.harness];
|
|
451
512
|
// The summary is what users read; stderr hints scroll away.
|
|
452
513
|
if (result.postInstallFailed) {
|
|
453
514
|
return (`⚠ ${name} — installed, but a finishing step failed. ` +
|
|
@@ -464,6 +525,12 @@ function summaryLine(result, uninstall) {
|
|
|
464
525
|
}
|
|
465
526
|
return `✓ ${name} — installed, ${verifyLabel}`;
|
|
466
527
|
}
|
|
528
|
+
function summaryLine(result, uninstall) {
|
|
529
|
+
return (absentSummaryLine(result, uninstall) ??
|
|
530
|
+
skippedSummaryLine(result) ??
|
|
531
|
+
uninstallSummaryLine(result) ??
|
|
532
|
+
installedSummaryLine(result));
|
|
533
|
+
}
|
|
467
534
|
/** One line for every harness: seven near-identical paragraphs is what this replaces. */
|
|
468
535
|
function reloadHint(results) {
|
|
469
536
|
const byAction = new Map();
|
|
@@ -529,16 +596,7 @@ function computeExitCode(results) {
|
|
|
529
596
|
return 0;
|
|
530
597
|
return attempted.some((r) => r.outcome === 'installed') ? 0 : 1;
|
|
531
598
|
}
|
|
532
|
-
|
|
533
|
-
const isTTY = detectHumanInitiated();
|
|
534
|
-
// Open interface holds stdin raw; hermes' input() then wedges.
|
|
535
|
-
const prompt = isTTY ? createStdinPrompt() : undefined;
|
|
536
|
-
const detections = detectInstalledHarnesses();
|
|
537
|
-
const gateKey = validatedApiKey(opts.apiKey);
|
|
538
|
-
// Keyed installs first: working tools land before sign-ins can block.
|
|
539
|
-
if (gateKey) {
|
|
540
|
-
detections.sort((a, b) => Number(KEY_AUTH_CAPABLE.has(b.harness)) - Number(KEY_AUTH_CAPABLE.has(a.harness)));
|
|
541
|
-
}
|
|
599
|
+
async function resolvePick(detections, opts, prompt) {
|
|
542
600
|
let deselected = new Set();
|
|
543
601
|
let mode = 'all';
|
|
544
602
|
// Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
|
|
@@ -549,18 +607,15 @@ export async function runConnectAll(opts) {
|
|
|
549
607
|
const pick = await pickHarnesses(offerable, connected, { prompt });
|
|
550
608
|
mode = pick.mode;
|
|
551
609
|
if (pick.selection === 'cancel') {
|
|
552
|
-
|
|
553
|
-
outLine('Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>');
|
|
554
|
-
return 0;
|
|
610
|
+
return { deselected, mode, cancelled: true };
|
|
555
611
|
}
|
|
556
612
|
const chosen = new Set(pick.selection);
|
|
557
613
|
deselected = new Set(offerable.filter((d) => !chosen.has(d.harness)).map((d) => d.harness));
|
|
558
614
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
(!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
|
|
615
|
+
return { deselected, mode, cancelled: false };
|
|
616
|
+
}
|
|
617
|
+
/** 'interrupted' stops the whole run; undefined means no hoist was needed. */
|
|
618
|
+
function hoistCliIfNeeded(detections, opts, willAttempt) {
|
|
564
619
|
// Machine-global, so once per invocation; config-writing connects never install it.
|
|
565
620
|
const cliNeeded = !opts.dryRun &&
|
|
566
621
|
!opts.uninstall &&
|
|
@@ -568,24 +623,21 @@ export async function runConnectAll(opts) {
|
|
|
568
623
|
d.binaryOnPath &&
|
|
569
624
|
!HARNESS_CAPABILITIES[d.harness].cliWritesConfig &&
|
|
570
625
|
willAttempt(d.harness));
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
// A Set dedupes: Codex and Grok defer the same export line.
|
|
588
|
-
const outro = new Set();
|
|
626
|
+
if (!cliNeeded)
|
|
627
|
+
return undefined;
|
|
628
|
+
errLine('First: the TinyFish CLI, shared by every harness.');
|
|
629
|
+
const outcome = runHoistedCliInstall(opts.verbose ?? false);
|
|
630
|
+
if (!outcome)
|
|
631
|
+
return 'interrupted';
|
|
632
|
+
let taken = false;
|
|
633
|
+
// Every harness gets the outcome; only the first take reports it.
|
|
634
|
+
return () => {
|
|
635
|
+
const firstTake = !taken;
|
|
636
|
+
taken = true;
|
|
637
|
+
return { outcome, firstTake };
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
async function connectLoop(detections, opts, deselected, io) {
|
|
589
641
|
const results = [];
|
|
590
642
|
const oauthBudget = { spent: false };
|
|
591
643
|
// Shares processHarness's no-work states so stepTotal cannot desync.
|
|
@@ -611,7 +663,7 @@ export async function runConnectAll(opts) {
|
|
|
611
663
|
let result;
|
|
612
664
|
try {
|
|
613
665
|
setErrIndent(STEP_INDENT);
|
|
614
|
-
result = await processHarness(detection, opts, prompt, oauthBudget, takeHoistedCli, (line) => outro.add(line));
|
|
666
|
+
result = await processHarness(detection, opts, io.prompt, oauthBudget, io.takeHoistedCli, (line) => io.outro.add(line));
|
|
615
667
|
}
|
|
616
668
|
catch (e) {
|
|
617
669
|
// Isolation: one harness throwing never stops the others.
|
|
@@ -649,6 +701,86 @@ export async function runConnectAll(opts) {
|
|
|
649
701
|
fixCommand: `tinyfish connect ${detection.harness}`,
|
|
650
702
|
});
|
|
651
703
|
}
|
|
704
|
+
return results;
|
|
705
|
+
}
|
|
706
|
+
async function noActionableExit(results, opts, prompt) {
|
|
707
|
+
if (!opts.dryRun && !opts.uninstall) {
|
|
708
|
+
try {
|
|
709
|
+
const outcome = await runCliFallback({
|
|
710
|
+
apiKey: opts.apiKey,
|
|
711
|
+
verbose: opts.verbose ?? false,
|
|
712
|
+
prompt,
|
|
713
|
+
retryCommand: 'npx @tiny-fish/cli connect --all',
|
|
714
|
+
});
|
|
715
|
+
return outcome === 'cli_verified' ? 0 : 1;
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
if (!(error instanceof ConnectInterruptedError))
|
|
719
|
+
throw error;
|
|
720
|
+
errLine('Setup interrupted — run the command again to finish.');
|
|
721
|
+
process.exitCode = 130;
|
|
722
|
+
return 130;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const stale = results.filter((r) => r.outcome === 'stale_config');
|
|
726
|
+
outLine('');
|
|
727
|
+
outLine(stale.length > 0
|
|
728
|
+
? `No runnable harness found. Reinstall ${stale.map((r) => DISPLAY_NAMES[r.harness]).join(', ')}, or install one of: ${ALL_HARNESSES.join(', ')}`
|
|
729
|
+
: 'No supported harness found. Install one of: ' + ALL_HARNESSES.join(', '));
|
|
730
|
+
if (!opts.uninstall)
|
|
731
|
+
outLine('Then run: npx @tiny-fish/cli connect --all');
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
async function finishRun(results, opts, isTTY) {
|
|
735
|
+
const exitCode = computeExitCode(results);
|
|
736
|
+
const interrupted = results.some((r) => r.outcome === 'interrupted');
|
|
737
|
+
if (exitCode === 0 &&
|
|
738
|
+
!interrupted &&
|
|
739
|
+
!opts.dryRun &&
|
|
740
|
+
!opts.uninstall &&
|
|
741
|
+
results.some((r) => r.installed)) {
|
|
742
|
+
outLine('');
|
|
743
|
+
if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
|
|
744
|
+
return exitCode;
|
|
745
|
+
}
|
|
746
|
+
outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
|
|
747
|
+
}
|
|
748
|
+
return exitCode;
|
|
749
|
+
}
|
|
750
|
+
export async function runConnectAll(opts) {
|
|
751
|
+
const isTTY = detectHumanInitiated();
|
|
752
|
+
// Open interface holds stdin raw; hermes' input() then wedges.
|
|
753
|
+
const prompt = isTTY ? createStdinPrompt() : undefined;
|
|
754
|
+
const detections = detectInstalledHarnesses();
|
|
755
|
+
const gateKey = validatedApiKey(opts.apiKey);
|
|
756
|
+
// Keyed installs first: working tools land before sign-ins can block.
|
|
757
|
+
if (gateKey) {
|
|
758
|
+
detections.sort((a, b) => Number(KEY_AUTH_CAPABLE.has(b.harness)) - Number(KEY_AUTH_CAPABLE.has(a.harness)));
|
|
759
|
+
}
|
|
760
|
+
const pick = await resolvePick(detections, opts, prompt);
|
|
761
|
+
if (pick.cancelled) {
|
|
762
|
+
// Before summary and telemetry: an explicit nothing is not a setup.
|
|
763
|
+
outLine('Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>');
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
const { deselected, mode } = pick;
|
|
767
|
+
// Mirrors processHarness's gate: headless, only keyless CLI-login harnesses skip.
|
|
768
|
+
const willAttempt = (harness) => !deselected.has(harness) &&
|
|
769
|
+
(isTTY ||
|
|
770
|
+
!HARNESS_CAPABILITIES[harness].signInViaCliLogin ||
|
|
771
|
+
(!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
|
|
772
|
+
const hoisted = hoistCliIfNeeded(detections, opts, willAttempt);
|
|
773
|
+
if (hoisted === 'interrupted') {
|
|
774
|
+
errLine('Setup interrupted — run the command again to finish.');
|
|
775
|
+
return 130;
|
|
776
|
+
}
|
|
777
|
+
// A Set dedupes: Codex and Grok defer the same export line.
|
|
778
|
+
const outro = new Set();
|
|
779
|
+
const results = await connectLoop(detections, opts, deselected, {
|
|
780
|
+
prompt,
|
|
781
|
+
takeHoistedCli: hoisted,
|
|
782
|
+
outro,
|
|
783
|
+
});
|
|
652
784
|
outLine('');
|
|
653
785
|
outLine('TinyFish setup summary:');
|
|
654
786
|
for (const line of renderSummary(results, opts.uninstall))
|
|
@@ -662,32 +794,7 @@ export async function runConnectAll(opts) {
|
|
|
662
794
|
for (const line of outro)
|
|
663
795
|
errLine(line);
|
|
664
796
|
if (!results.some(actionable)) {
|
|
665
|
-
|
|
666
|
-
try {
|
|
667
|
-
const outcome = await runCliFallback({
|
|
668
|
-
apiKey: opts.apiKey,
|
|
669
|
-
verbose: opts.verbose ?? false,
|
|
670
|
-
prompt,
|
|
671
|
-
retryCommand: 'npx @tiny-fish/cli connect --all',
|
|
672
|
-
});
|
|
673
|
-
return outcome === 'cli_verified' ? 0 : 1;
|
|
674
|
-
}
|
|
675
|
-
catch (error) {
|
|
676
|
-
if (!(error instanceof ConnectInterruptedError))
|
|
677
|
-
throw error;
|
|
678
|
-
errLine('Setup interrupted — run the command again to finish.');
|
|
679
|
-
process.exitCode = 130;
|
|
680
|
-
return 130;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
const stale = results.filter((r) => r.outcome === 'stale_config');
|
|
684
|
-
outLine('');
|
|
685
|
-
outLine(stale.length > 0
|
|
686
|
-
? `No runnable harness found. Reinstall ${stale.map((r) => DISPLAY_NAMES[r.harness]).join(', ')}, or install one of: ${ALL_HARNESSES.join(', ')}`
|
|
687
|
-
: 'No supported harness found. Install one of: ' + ALL_HARNESSES.join(', '));
|
|
688
|
-
if (!opts.uninstall)
|
|
689
|
-
outLine('Then run: npx @tiny-fish/cli connect --all');
|
|
690
|
-
return 1;
|
|
797
|
+
return noActionableExit(results, opts, prompt);
|
|
691
798
|
}
|
|
692
799
|
if (!opts.dryRun && !opts.uninstall) {
|
|
693
800
|
const harnessTelemetry = results.map((r) => ({
|
|
@@ -703,20 +810,7 @@ export async function runConnectAll(opts) {
|
|
|
703
810
|
// After the summary and before the launch, which is where the user is reading.
|
|
704
811
|
emitNotice();
|
|
705
812
|
}
|
|
706
|
-
|
|
707
|
-
const interrupted = results.some((r) => r.outcome === 'interrupted');
|
|
708
|
-
if (exitCode === 0 &&
|
|
709
|
-
!interrupted &&
|
|
710
|
-
!opts.dryRun &&
|
|
711
|
-
!opts.uninstall &&
|
|
712
|
-
results.some((r) => r.installed)) {
|
|
713
|
-
outLine('');
|
|
714
|
-
if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
|
|
715
|
-
return exitCode;
|
|
716
|
-
}
|
|
717
|
-
outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
|
|
718
|
-
}
|
|
719
|
-
return exitCode;
|
|
813
|
+
return finishRun(results, opts, isTTY);
|
|
720
814
|
}
|
|
721
815
|
export const FIRST_TASK_PROMPT = 'My TinyFish setup just finished — verify it with a real run. Use the MCP server named ' +
|
|
722
816
|
'`tinyfish` for every TinyFish tool call; if another TinyFish server is also registered, still ' +
|