@tiny-fish/cli 0.45.2-next.354 → 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.
@@ -0,0 +1,389 @@
1
+ import spawn from 'cross-spawn';
2
+ import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validatedApiKey, } from './auth.js';
3
+ import { KEYLESS_ONBOARDING_PROMPT, launchNativeMcpClient, } from './connect-clients.js';
4
+ import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from './cli-install.js';
5
+ import { ensureCliAuthenticated } from './connect-auth.js';
6
+ import { installWebSkill } from './skill-install.js';
7
+ import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, runGuarded, settle, SignInTimeoutError, spawnRemoval, spawnStepError, } from './connect-runtime.js';
8
+ import { TINYFISH_API_KEY_VAR } from './constants.js';
9
+ import { AuthMode, RELOAD_ACTION } from './harness-detect.js';
10
+ import { SIGN_IN_PROBES } from './registration-detect.js';
11
+ import { errLine, warnLine } from './output.js';
12
+ import { verifyMcpAuth } from './verify.js';
13
+ import { apiBaseFromMcpUrl } from './mcp-endpoint.js';
14
+ import { finishSetupHint, requireKeylessMcp, requireSupportOrRescue, runCliInstallStep, trackPostInstallFailure, walkthroughPhase, } from './connect-steps.js';
15
+ function removeExistingRegistration(client, removal, verbose) {
16
+ const result = spawnRemoval(client.command, removal.args);
17
+ if (result.status === 0)
18
+ return;
19
+ // Best-effort: a stale entry makes `mcp add` error or upsert, with a truer message.
20
+ if (!verbose)
21
+ return;
22
+ const details = result.stderr?.trim() || result.error?.message || 'unknown error';
23
+ warnLine(`Could not remove existing ${removal.label}: ${details}`);
24
+ }
25
+ /** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
26
+ function exportApiKey(apiKey, displayName, deferOutro) {
27
+ try {
28
+ persistApiKeyToEnvironment(apiKey);
29
+ }
30
+ catch (error) {
31
+ // Cause only: headless refuses below, so this must not promise a sign-in (PF-3803).
32
+ errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}).`);
33
+ return false;
34
+ }
35
+ process.env[TINYFISH_API_KEY_VAR] = apiKey;
36
+ if (deferOutro) {
37
+ deferOutro('Exported TINYFISH_API_KEY to your shell profile — restart your agents (or open a new ' +
38
+ 'terminal) before use.');
39
+ }
40
+ else {
41
+ errLine(`Exported TINYFISH_API_KEY to your shell profile — ${displayName} reads the key from there, ` +
42
+ 'so restart it (or open a new terminal) before use.');
43
+ }
44
+ return true;
45
+ }
46
+ function resolveKeyedAdd(client, storedKey, keyAuthSupported, keyAuthOnly, deferOutro) {
47
+ if (!storedKey || !keyAuthSupported || !client.keyAuth)
48
+ return undefined;
49
+ const keyed = { key: storedKey, spec: client.keyAuth };
50
+ // Before the removals: a failed export must not strand the old registration.
51
+ if (keyed.spec.envVar && !exportApiKey(keyed.key, client.displayName, deferOutro)) {
52
+ if (keyAuthOnly)
53
+ refuseFailedKeyExport(client);
54
+ errLine(`Signing in to ${client.displayName} instead.`);
55
+ return undefined;
56
+ }
57
+ return keyed;
58
+ }
59
+ /** No sign-in to degrade to, so the install stops. */
60
+ function refuseKeylessInstall(client, keyAuthSupported, headless = false) {
61
+ const noSignIn = headless
62
+ ? 'signing in needs a terminal'
63
+ : `TinyFish does not sign in to ${client.displayName} any other way`;
64
+ const rerun = `re-run: tinyfish connect ${client.connectClient}`;
65
+ if (!client.keyAuth) {
66
+ throw new ConnectStepError(`${client.displayName} cannot take a TinyFish API key, and ${noSignIn}. ` +
67
+ `Run: tinyfish connect ${client.connectClient}`, 'key_auth_unsupported');
68
+ }
69
+ if (!keyAuthSupported || headless) {
70
+ throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and ${noSignIn}. ` +
71
+ `Update it, then ${rerun}`, 'harness_too_old');
72
+ }
73
+ throw new ConnectStepError(`Connecting ${client.displayName} needs a TinyFish API key, and ${noSignIn}. ` +
74
+ `Run: tinyfish auth login, then ${rerun}`, 'invalid_config', { failureDetail: 'keyless_install_refused' });
75
+ }
76
+ /** Headless has no sign-in to degrade to, and the harness version is not the fault (PF-3803). */
77
+ function refuseFailedKeyExport(client) {
78
+ // Never names a file: on Windows the write is `setx`, not a shell profile.
79
+ throw new ConnectStepError(`Could not save ${TINYFISH_API_KEY_VAR} to your environment, so ${client.displayName} has no ` +
80
+ `way to read your key. Fix that, then re-run: tinyfish connect ${client.connectClient}`, 'invalid_config', { failureDetail: 'key_export_failed' });
81
+ }
82
+ /** Resolved before the removals, so a refusal strands no registration. */
83
+ function keylessAddArgs(client, keyAuthSupported) {
84
+ if (!client.addArgs)
85
+ refuseKeylessInstall(client, keyAuthSupported);
86
+ return client.addArgs;
87
+ }
88
+ /** Runs before any write: a refused key strands nothing. */
89
+ async function verifyKeyBeforeSeeding(apiKey, displayName, mcpUrl) {
90
+ // One host serves `/mcp` and `/v1`, so `--url` decides where the key is checked.
91
+ const verified = await verifyMcpAuth(apiKey, apiBaseFromMcpUrl(mcpUrl));
92
+ if (verified.ok)
93
+ return;
94
+ // No status means the network failed, not the key.
95
+ if (verified.status === undefined) {
96
+ throw new ConnectStepError(`Could not reach TinyFish to check your API key (${verified.code ?? 'unknown'}), so ` +
97
+ `nothing was written to ${displayName}. Check your connection and retry.`, 'timeout');
98
+ }
99
+ // The tag carries the HTTP status; `verified.code` is authored prose.
100
+ throw new ConnectStepError(`Your TinyFish API key was rejected (${verified.code ?? 'unknown'}), so it was not ` +
101
+ `written to ${displayName}. Run: tinyfish auth login`, 'invalid_config', { failureDetail: `key_rejected status=${verified.status}` });
102
+ }
103
+ /** A bounded spawn's timeout means the sign-in never finished. */
104
+ function signInStepError(message, result, bounded) {
105
+ const error = spawnStepError(message, result);
106
+ return bounded && error.failureReason === 'timeout'
107
+ ? new SignInTimeoutError(error.message, error.cause)
108
+ : error;
109
+ }
110
+ /** Resolved before the removals, so a refusal strands no registration. */
111
+ async function prepareInstallAuth(client, options, keyAuthSupported) {
112
+ // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
113
+ const storedKey = validatedApiKey(options.apiKey);
114
+ const keyedAdd = resolveKeyedAdd(client, storedKey, keyAuthSupported, options.keyAuthOnly === true, options.deferOutro);
115
+ const keylessArgs = !storedKey ? client.keylessAddArgs : undefined;
116
+ const directInstall = !storedKey ? client.prepareKeylessInstall?.() : undefined;
117
+ let keyless = !!directInstall;
118
+ let buildAddArgs;
119
+ if (keyedAdd) {
120
+ buildAddArgs = (mcpUrl) => keyedAdd.spec.addArgs(mcpUrl, keyedAdd.key);
121
+ }
122
+ else if (keylessArgs && keyAuthSupported) {
123
+ buildAddArgs = keylessArgs;
124
+ keyless = true;
125
+ }
126
+ else if (keylessArgs && options.authTimeoutMs !== undefined) {
127
+ refuseKeylessInstall(client, false, true);
128
+ }
129
+ else if (!directInstall) {
130
+ buildAddArgs = keylessAddArgs(client, keyAuthSupported);
131
+ }
132
+ if (options.keyAuthOnly && !keyedAdd)
133
+ refuseKeylessInstall(client, keyAuthSupported, true);
134
+ // Verify first: a revoked key in a foreign .env is unrecoverable.
135
+ if (keyedAdd?.spec.seedKey) {
136
+ await verifyKeyBeforeSeeding(keyedAdd.key, client.displayName, options.mcpUrl);
137
+ }
138
+ return { keyedAdd, keyless, buildAddArgs, directInstall };
139
+ }
140
+ /** Inherit for an inline-OAuth add; capture otherwise, piping prompts when seeded. */
141
+ function addSpawnOptions(options, interactiveAdd, seeded) {
142
+ if (interactiveAdd)
143
+ return { stdio: 'inherit', timeout: options.authTimeoutMs };
144
+ if (!seeded)
145
+ return captureStdio(options.verbose ?? false);
146
+ return {
147
+ encoding: 'utf8',
148
+ // Without it, the 1 MiB default overflows into a misread Ctrl+C.
149
+ maxBuffer: STEP_MAX_BUFFER,
150
+ // The unattended path had no bound at all before; captureStdio's is the floor.
151
+ timeout: options.authTimeoutMs ?? SKILL_INSTALL_TIMEOUT_MS,
152
+ input: seeded.stdinInput,
153
+ env: seeded.env,
154
+ };
155
+ }
156
+ function performDirectInstall(client, install, mcpUrl) {
157
+ const registered = install.register(mcpUrl.toString());
158
+ if (registered.ok)
159
+ return { seededHome: install.home };
160
+ const detail = registered.detail ? `: ${registered.detail}` : '';
161
+ throw new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there${detail}.`, 'invalid_config', { failureDetail: registered.tag });
162
+ }
163
+ /** Returns the seeded-credential note when a key was written into a user-owned file. */
164
+ function performMcpAdd(client, options, plan, attemptId) {
165
+ const { keyedAdd, buildAddArgs, directInstall } = plan;
166
+ const mcpUrl = new URL(options.mcpUrl);
167
+ mcpUrl.searchParams.set('source', CONNECT_SOURCE);
168
+ mcpUrl.searchParams.set('client', client.connectClient);
169
+ mcpUrl.searchParams.set('connect_attempt_id', attemptId);
170
+ errLine(`Adding TinyFish to ${client.displayName}...`);
171
+ if (directInstall)
172
+ return performDirectInstall(client, directInstall, mcpUrl);
173
+ if (!buildAddArgs)
174
+ throw new Error(`No MCP add arguments exist for ${client.displayName}`);
175
+ // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
176
+ const addArgs = buildAddArgs(mcpUrl.toString());
177
+ // Capturing an inline-OAuth add hides the sign-in URL and hangs.
178
+ const interactiveAdd = !(client.nonInteractiveAdd || keyedAdd);
179
+ // After the removals: an interrupt there leaves no key behind.
180
+ const seeded = keyedAdd?.spec.seedKey?.(keyedAdd.key);
181
+ // Writing the entry ourselves skips the harness probe that saves it disabled.
182
+ if (seeded?.register(mcpUrl.toString())) {
183
+ return { note: seeded.note, seededHome: seeded.home };
184
+ }
185
+ const addResult = spawn.sync(client.command, addArgs, addSpawnOptions(options, interactiveAdd, seeded));
186
+ // Rolls back first: signInStepError throws on a Ctrl+C.
187
+ const failAdd = (build) => {
188
+ seeded?.rollback();
189
+ const error = build();
190
+ replay(capturedOutput(addResult));
191
+ throw error;
192
+ };
193
+ if (addResult.error || addResult.status !== 0) {
194
+ failAdd(() => signInStepError(`Could not add TinyFish to ${client.displayName}`, addResult, interactiveAdd && options.authTimeoutMs !== undefined));
195
+ }
196
+ const registration = seeded?.confirmRegistered();
197
+ if (registration && !registration.ok) {
198
+ failAdd(() => new ConnectStepError(`${client.displayName} reported success but TinyFish is not enabled there` +
199
+ `${registration.detail ? `: ${registration.detail}` : ''}.`, 'invalid_config',
200
+ // The tag keeps the path-bearing prose out of telemetry.
201
+ { failureDetail: registration.tag }));
202
+ }
203
+ return { note: seeded?.note, seededHome: seeded?.home };
204
+ }
205
+ function performLoginStep(client, pendingLogin, options) {
206
+ errLine('Signing in to TinyFish...');
207
+ const loginResult = spawn.sync(client.command, [...pendingLogin], {
208
+ stdio: 'inherit',
209
+ timeout: options.authTimeoutMs,
210
+ });
211
+ if (loginResult.error || loginResult.status !== 0) {
212
+ throw signInStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult, options.authTimeoutMs !== undefined);
213
+ }
214
+ }
215
+ function announceAuthNotes(useKeyAuth, degradedNotes, seededNote) {
216
+ if (useKeyAuth) {
217
+ errLine(degradedNotes?.keyed ?? 'Using your stored TinyFish API key — no browser sign-in needed.');
218
+ // Every other credential the CLI writes into a user-owned file says so too.
219
+ if (seededNote)
220
+ errLine(seededNote);
221
+ }
222
+ else if (degradedNotes) {
223
+ // Printed here, not with the closing line, so a `--launch` run sees it before the handover.
224
+ errLine(degradedNotes.deferred);
225
+ }
226
+ }
227
+ function completeAuthFlow(client, options, state, telemetry, ctx) {
228
+ const { useKeyAuth, useKeyless, loginDegraded, loginArgs, probeAuthenticated } = ctx;
229
+ if (useKeyless) {
230
+ errLine('Using keyless TinyFish Search — no account or browser sign-in needed.');
231
+ return { authMode: AuthMode.Keyless, signInDeferred: false };
232
+ }
233
+ // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
234
+ // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
235
+ const addSignedIn = probeAuthenticated?.();
236
+ if (addSignedIn)
237
+ telemetry.track('checkpoint', { phase: 'oauth_done' });
238
+ announceAuthNotes(useKeyAuth, loginDegraded ? client.degradedAuthNotes : undefined, ctx.seededNote);
239
+ // A probed client signs in again only when its inline OAuth is known to have been abandoned.
240
+ const retryLogin = probeAuthenticated ? addSignedIn === false : true;
241
+ const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
242
+ let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
243
+ if (pendingLogin) {
244
+ state.stage = 'client_oauth';
245
+ performLoginStep(client, pendingLogin, options);
246
+ if (probeAuthenticated?.() === false)
247
+ signInDeferred = true;
248
+ else
249
+ telemetry.track('checkpoint', { phase: 'oauth_done' });
250
+ }
251
+ const authMode = useKeyAuth
252
+ ? AuthMode.ApiKey
253
+ : signInDeferred
254
+ ? 'deferred'
255
+ : AuthMode.OAuth;
256
+ return { authMode, signInDeferred };
257
+ }
258
+ export async function connectNativeMcpClient(client, options) {
259
+ const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
260
+ const state = { stage: 'prerequisite_check', settled: false };
261
+ const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
262
+ await runGuarded(state, telemetry, async () => {
263
+ telemetry.track('started');
264
+ const support = await requireSupportOrRescue(client, client.connectClient, state, telemetry, options);
265
+ if (!support)
266
+ return;
267
+ const { optionalSupported, keyAuthSupported, harnessVersion } = support;
268
+ telemetry.setHarnessVersion(harnessVersion);
269
+ state.harnessDegraded = !!client.loginArgs && !optionalSupported;
270
+ const plan = await prepareInstallAuth(client, options, keyAuthSupported);
271
+ const useKeyAuth = !!plan.keyedAdd;
272
+ const useKeyless = plan.keyless;
273
+ if (useKeyless)
274
+ await requireKeylessMcp(options.mcpUrl);
275
+ telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
276
+ state.stage = 'registration_cleanup';
277
+ for (const removal of client.removals)
278
+ removeExistingRegistration(client, removal, options.verbose ?? false);
279
+ // Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
280
+ const loginDegraded = state.harnessDegraded === true;
281
+ const loginArgs = loginDegraded ? undefined : client.loginArgs;
282
+ // A probed client authenticates inside `mcp add` too, so a failure there is either.
283
+ state.stage =
284
+ loginArgs && !probeAuthenticated ? 'registration' : 'registration_or_authentication';
285
+ const { note: seededNote, seededHome } = performMcpAdd(client, options, plan, telemetry.attemptId);
286
+ telemetry.track('checkpoint', { phase: 'registered' });
287
+ const { authMode, signInDeferred } = completeAuthFlow(client, options, state, telemetry, {
288
+ useKeyAuth,
289
+ useKeyless,
290
+ loginDegraded,
291
+ loginArgs,
292
+ probeAuthenticated,
293
+ seededNote,
294
+ });
295
+ state.authMode = authMode;
296
+ // Registration/OAuth make MCP work; the steps below must not fail the attempt.
297
+ settle(state, telemetry, 'completed', { authMode });
298
+ await runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome);
299
+ });
300
+ return state.authMode;
301
+ }
302
+ function connectedLine(client, signInDeferred) {
303
+ // The CLI never observes deferred auth, so don't claim "connected".
304
+ if (signInDeferred) {
305
+ if (client.signInHint)
306
+ return client.signInHint;
307
+ return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
308
+ 'to sign in to TinyFish the first time you use it.');
309
+ }
310
+ return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
311
+ `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
312
+ }
313
+ // Runs after `completed` settled: steps fail alone, the rest still run.
314
+ async function runPostInstallSteps(client, options, state, telemetry, signInDeferred, seededHome) {
315
+ let failed = false;
316
+ // Modelled on upgrade's attempt(); interrupts rethrow and abandon remaining steps.
317
+ const attempt = async (stage, run) => {
318
+ state.stage = stage;
319
+ try {
320
+ await run();
321
+ }
322
+ catch (error) {
323
+ if (error instanceof ConnectInterruptedError)
324
+ throw error;
325
+ failed = true;
326
+ trackPostInstallFailure(client.displayName, state, telemetry, error);
327
+ }
328
+ };
329
+ try {
330
+ // Above every step: a Ctrl+C below must not lose a registration already reported (PF-3803).
331
+ saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
332
+ if (options.cliInstall !== false) {
333
+ await attempt('cli_install', () => runCliInstallStep(options, telemetry));
334
+ }
335
+ if (client.skillAgent) {
336
+ await attempt('skill_install', () => {
337
+ installWebSkill(client, { verbose: options.verbose ?? false });
338
+ telemetry.track('checkpoint', { phase: 'skill_installed' });
339
+ });
340
+ }
341
+ if (client.extraPostInstall && state.authMode !== AuthMode.Keyless) {
342
+ await attempt('plugin_install', () => {
343
+ const pluginVersion = client.extraPostInstall?.({
344
+ apiKey: validatedApiKey(options.apiKey),
345
+ verbose: options.verbose ?? false,
346
+ seededHome,
347
+ });
348
+ telemetry.track('checkpoint', { phase: 'plugin_installed', pluginVersion });
349
+ });
350
+ }
351
+ if (state.authMode !== AuthMode.Keyless) {
352
+ await attempt('authentication', () => {
353
+ ensureCliAuthenticated(client.connectClient, options.apiKey, {
354
+ interactiveLogin: options.authTimeoutMs === undefined,
355
+ });
356
+ telemetry.track('checkpoint', { phase: 'authenticated' });
357
+ });
358
+ }
359
+ if (client.postConnectNote)
360
+ errLine(client.postConnectNote);
361
+ if (!options.launch) {
362
+ // Under --all the summary replaces this; sign-in hints still print.
363
+ if (signInDeferred || !options.deferOutro)
364
+ errLine(connectedLine(client, signInDeferred));
365
+ }
366
+ else {
367
+ await attempt('walkthrough_launch', async () => {
368
+ // Before the handover, or the only sign-in instructions scroll past under the agent.
369
+ if (signInDeferred && client.signInHint)
370
+ errLine(client.signInHint);
371
+ await launchNativeMcpClient(client, async (outcome) => {
372
+ telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
373
+ // Flushed pre-block; the session-long handover would strand the delivery.
374
+ await telemetry.flush();
375
+ }, state.authMode === AuthMode.Keyless ? KEYLESS_ONBOARDING_PROMPT : undefined);
376
+ });
377
+ }
378
+ }
379
+ catch (error) {
380
+ // Interrupts stop quietly; anything else escapes to runGuarded.
381
+ if (!(error instanceof ConnectInterruptedError))
382
+ throw error;
383
+ failed = true;
384
+ }
385
+ if (failed) {
386
+ errLine(finishSetupHint(client.connectClient));
387
+ options.onPostInstallFailed?.();
388
+ }
389
+ }
@@ -0,0 +1,3 @@
1
+ import { type NativeConnectOptions } from './connect-steps.js';
2
+ /** Skill install only: no key handoff or post-install callback to honour. */
3
+ export declare function connectOpenClaw(options: Omit<NativeConnectOptions, 'keyAuthOnly' | 'onPostInstallFailed'>): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import spawn from 'cross-spawn';
2
+ import { saveConnectContext } from './auth.js';
3
+ import { OPENCLAW, openclawSkillInstallArgs, launchOpenClawWalkthrough, } from './connect-clients.js';
4
+ import { captureStdio, capturedOutput, replay } from './cli-install.js';
5
+ import { ensureCliAuthenticated } from './connect-auth.js';
6
+ import { createConnectTelemetry, runGuarded, settle, spawnStepError, } from './connect-runtime.js';
7
+ import { RELOAD_ACTION } from './harness-detect.js';
8
+ import { errLine } from './output.js';
9
+ import { finishSetupHint, requireSupportOrRescue, runCliInstallStep, trackPostInstallFailure, walkthroughPhase, } from './connect-steps.js';
10
+ /** Skill install only: no key handoff or post-install callback to honour. */
11
+ export async function connectOpenClaw(options) {
12
+ const telemetry = createConnectTelemetry(options.mcpUrl, 'openclaw', options);
13
+ // Skill install, no MCP server — never degraded, and false keeps it inside a `= false` filter.
14
+ const state = {
15
+ stage: 'prerequisite_check',
16
+ settled: false,
17
+ harnessDegraded: false,
18
+ };
19
+ await runGuarded(state, telemetry, async () => {
20
+ telemetry.track('started');
21
+ const support = await requireSupportOrRescue(OPENCLAW, 'openclaw', state, telemetry, options);
22
+ if (!support)
23
+ return;
24
+ telemetry.setHarnessVersion(support.harnessVersion);
25
+ telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
26
+ if (options.cliInstall !== false) {
27
+ state.stage = 'cli_install';
28
+ runCliInstallStep(options, telemetry);
29
+ }
30
+ state.stage = 'skill_install';
31
+ errLine('Installing the TinyFish skill in OpenClaw...');
32
+ const skillInstallResult = spawn.sync('openclaw', openclawSkillInstallArgs(support.matchedVariant), captureStdio(options.verbose ?? false));
33
+ if (skillInstallResult.error || skillInstallResult.status !== 0) {
34
+ // Built first: its interrupt check must run before any replay.
35
+ const error = spawnStepError('Could not install the TinyFish skill in OpenClaw', skillInstallResult);
36
+ replay(capturedOutput(skillInstallResult));
37
+ throw error;
38
+ }
39
+ telemetry.track('checkpoint', { phase: 'skill_installed' });
40
+ // Stores the key before probing, so a passed --api-key never reaches an interactive login.
41
+ state.stage = 'authentication';
42
+ // A set timeout means headless; never shell an interactive login.
43
+ ensureCliAuthenticated('openclaw', options.apiKey, {
44
+ interactiveLogin: options.authTimeoutMs === undefined,
45
+ });
46
+ telemetry.track('checkpoint', { phase: 'authenticated' });
47
+ saveConnectContext('openclaw', telemetry.attemptId);
48
+ // For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
49
+ settle(state, telemetry, 'completed');
50
+ if (!options.launch) {
51
+ // Under --all the summary already lists OpenClaw.
52
+ if (!options.deferOutro) {
53
+ errLine('TinyFish is connected. Open OpenClaw; if it is already running, ' +
54
+ `${RELOAD_ACTION.openclaw} to pick up the skill.`);
55
+ }
56
+ return;
57
+ }
58
+ state.stage = 'walkthrough_launch';
59
+ try {
60
+ await launchOpenClawWalkthrough(async (outcome) => {
61
+ telemetry.track('checkpoint', { phase: walkthroughPhase(outcome) });
62
+ // Flushed pre-block; the session-long handover would strand the delivery.
63
+ await telemetry.flush();
64
+ });
65
+ }
66
+ catch (error) {
67
+ trackPostInstallFailure('OpenClaw', state, telemetry, error);
68
+ errLine(finishSetupHint('openclaw'));
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,38 @@
1
+ import { type TakeHoistedCliInstall } from './cli-install.js';
2
+ import { type ConnectCheckpoint, requireCommandSupport, type AgentClient, type ConnectRunState, type ConnectTelemetry, type SupportedCommand } from './connect-runtime.js';
3
+ import type { WalkthroughOutcome } from './connect-clients.js';
4
+ export interface NativeConnectOptions {
5
+ apiKey?: string;
6
+ mcpUrl: string;
7
+ launch: boolean;
8
+ attemptId?: string;
9
+ /** Headless: refuse rather than fall back to an unfinishable browser sign-in. */
10
+ keyAuthOnly?: boolean;
11
+ /** Headless --all: bounds sign-in spawns and bars interactive CLI login. */
12
+ authTimeoutMs?: number;
13
+ /** Internal only: false leaves the global npm tree untouched. */
14
+ cliInstall?: boolean;
15
+ /** Set by `--all`: the CLI install already ran once, upfront. */
16
+ hoistedCliInstall?: TakeHoistedCliInstall;
17
+ /** Lets `--all` warn in its summary instead of all-green. */
18
+ onPostInstallFailed?: () => void;
19
+ /** Inherit subprocess stdio instead of replaying it only on failure. */
20
+ verbose?: boolean;
21
+ /** Set by `--all`: repeated outro lines print once, after the summary. */
22
+ deferOutro?: (line: string) => void;
23
+ /** Targeted connects only: --all keeps its per-harness failed row. */
24
+ fallbackWhenMissing?: boolean;
25
+ }
26
+ export declare function requireKeylessMcp(mcpUrl: string): Promise<void>;
27
+ /** Undefined means the rescue ran; the caller returns immediately. */
28
+ export declare function requireSupportOrRescue(client: SupportedCommand, connectClient: AgentClient, state: ConnectRunState, telemetry: ConnectTelemetry, options: {
29
+ apiKey?: string;
30
+ verbose?: boolean;
31
+ fallbackWhenMissing?: boolean;
32
+ }): Promise<ReturnType<typeof requireCommandSupport> | undefined>;
33
+ /** Consumes the `--all` hoist when present; installs inline otherwise. */
34
+ export declare function runCliInstallStep(options: NativeConnectOptions, telemetry: ConnectTelemetry): void;
35
+ export declare function walkthroughPhase(outcome: WalkthroughOutcome): ConnectCheckpoint;
36
+ export declare function finishSetupHint(connectClient: AgentClient): string;
37
+ /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
38
+ export declare function trackPostInstallFailure(displayName: string, state: ConnectRunState, telemetry: ConnectTelemetry, error: unknown): void;
@@ -0,0 +1,104 @@
1
+ import { verifyMcpHealth } from './verify.js';
2
+ import { installTinyFishCli } from './cli-install.js';
3
+ import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
4
+ import { detectHumanInitiated } from './harness.js';
5
+ import { errLine } from './output.js';
6
+ import { ConnectInterruptedError, ConnectStepError, HoistedFailureReportedError, finishSetupCommand, requireCommandSupport, settle, stageDurationOf, withStageDuration, } from './connect-runtime.js';
7
+ export async function requireKeylessMcp(mcpUrl) {
8
+ if ((await verifyMcpHealth(mcpUrl, true)).ok)
9
+ return;
10
+ throw new ConnectStepError('TinyFish keyless Search is unavailable', 'invalid_config');
11
+ }
12
+ function isMissingHarness(error) {
13
+ return error instanceof ConnectStepError && error.failureReason === 'harness_not_installed';
14
+ }
15
+ /** Undefined means the rescue ran; the caller returns immediately. */
16
+ export async function requireSupportOrRescue(client, connectClient, state, telemetry, options) {
17
+ try {
18
+ return requireCommandSupport(client);
19
+ }
20
+ catch (error) {
21
+ if (!options.fallbackWhenMissing || !isMissingHarness(error))
22
+ throw error;
23
+ await runMissingHarnessFallback(client.displayName, connectClient, state, telemetry, options);
24
+ return undefined;
25
+ }
26
+ }
27
+ // The harness attempt stays failed; the rescue rides fallback_outcome (PF-3707).
28
+ async function runMissingHarnessFallback(displayName, connectClient, state, telemetry, options) {
29
+ // Stamped before any step: Ctrl+C settles before an outcome exists.
30
+ telemetry.setFallbackOutcome('abandoned');
31
+ errLine(`${displayName} was not found on PATH. Setting up the TinyFish CLI instead.`);
32
+ const outcome = await runCliFallback({
33
+ apiKey: options.apiKey,
34
+ verbose: options.verbose ?? false,
35
+ prompt: detectHumanInitiated() ? createStdinPrompt() : undefined,
36
+ retryCommand: `npx @tiny-fish/cli connect ${connectClient}`,
37
+ hooks: {
38
+ // Terminal detail is explicit below; abort rows still name the step.
39
+ onStepStart: (stage) => {
40
+ state.stage = stage;
41
+ },
42
+ onStepDone: (phase) => telemetry.track('checkpoint', { phase }),
43
+ },
44
+ });
45
+ telemetry.setFallbackOutcome(outcome);
46
+ // Never `completed`: dashboards read that as "the harness install worked".
47
+ settle(state, telemetry, 'failed', {
48
+ failedStage: 'prerequisite_check',
49
+ failureReason: 'harness_not_installed',
50
+ });
51
+ if (outcome !== 'cli_verified') {
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ errLine('The TinyFish CLI is installed and working.');
56
+ errLine('Using a different agent? Run: tinyfish connect --all');
57
+ }
58
+ /** Fails on a carried failure; only the first take reports anything. */
59
+ function takeHoistedCliInstall(take, telemetry) {
60
+ const { outcome, firstTake } = take();
61
+ if (!outcome.ok) {
62
+ // Late takers fail too; only the first reports the install.
63
+ if (!firstTake)
64
+ throw new HoistedFailureReportedError(outcome.error.message);
65
+ throw withStageDuration(outcome.error, outcome.durationMs);
66
+ }
67
+ if (firstTake) {
68
+ telemetry.track('checkpoint', {
69
+ phase: 'cli_installed',
70
+ stageDurationMs: outcome.durationMs,
71
+ });
72
+ }
73
+ }
74
+ /** Consumes the `--all` hoist when present; installs inline otherwise. */
75
+ export function runCliInstallStep(options, telemetry) {
76
+ if (options.hoistedCliInstall) {
77
+ takeHoistedCliInstall(options.hoistedCliInstall, telemetry);
78
+ return;
79
+ }
80
+ installTinyFishCli({ verbose: options.verbose ?? false, announce: true });
81
+ telemetry.track('checkpoint', { phase: 'cli_installed' });
82
+ }
83
+ export function walkthroughPhase(outcome) {
84
+ return outcome === 'printed' ? 'walkthrough_prompt_printed' : 'walkthrough_launched';
85
+ }
86
+ export function finishSetupHint(connectClient) {
87
+ return `Finish setup with: ${finishSetupCommand(connectClient)}`;
88
+ }
89
+ /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
90
+ export function trackPostInstallFailure(displayName, state, telemetry, error) {
91
+ if (error instanceof ConnectInterruptedError)
92
+ return;
93
+ // An earlier harness already owns the shared install's failure record.
94
+ if (!(error instanceof HoistedFailureReportedError)) {
95
+ telemetry.track('post_install_failed', {
96
+ failedStage: state.stage,
97
+ failureReason: error instanceof ConnectStepError ? error.failureReason : 'unexpected_error',
98
+ failureDetail: error instanceof ConnectStepError ? (error.failureDetail ?? error.message) : undefined,
99
+ stageDurationMs: stageDurationOf(error),
100
+ });
101
+ }
102
+ errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
103
+ `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
@@ -1,5 +1,5 @@
1
1
  import spawn from 'cross-spawn';
2
- import { connectHarness } from '../commands/connect.js';
2
+ import { connectHarness } from './connect-harness.js';
3
3
  import { OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-all-auth.js';
4
4
  import { Registered } from './harness-detect.js';
5
5
  import { detectHumanInitiated } from './harness.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.45.2-next.354",
3
+ "version": "0.45.2-next.356",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {