@tiny-fish/cli 0.30.1-next.247 → 0.30.1-next.249

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.
@@ -21,6 +21,8 @@ export interface NativeConnectOptions {
21
21
  verbose?: boolean;
22
22
  /** Set by `--all`: repeated outro lines print once, after the summary. */
23
23
  deferOutro?: (line: string) => void;
24
+ /** Targeted connects only: --all keeps its per-harness failed row. */
25
+ fallbackWhenMissing?: boolean;
24
26
  }
25
27
  export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
26
28
  export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
@@ -38,6 +40,8 @@ export declare function connectOpenClaw(options: {
38
40
  verbose?: boolean;
39
41
  /** Set by `--all`: repeated outro lines print once, after the summary. */
40
42
  deferOutro?: (line: string) => void;
43
+ /** Targeted connects only: --all keeps its per-harness failed row. */
44
+ fallbackWhenMissing?: boolean;
41
45
  }): Promise<void>;
42
46
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
43
47
  export declare function connectCursor(options: {
@@ -3,6 +3,7 @@ import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validat
3
3
  import { CLAUDE_CODE, CODEX, CURSOR_SKILL_TARGET, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
4
4
  import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyFishCli, replay, } from "../lib/cli-install.js";
5
5
  import { ensureCliAuthenticated } from "../lib/connect-auth.js";
6
+ import { createStdinPrompt, runCliFallback } from "../lib/connect-fallback.js";
6
7
  import { installWebSkill } from "../lib/skill-install.js";
7
8
  import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
8
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from "../lib/cursor-config.js";
@@ -58,13 +59,62 @@ function signInStepError(message, result, bounded) {
58
59
  ? new SignInTimeoutError(error.message, error.cause)
59
60
  : error;
60
61
  }
62
+ function isMissingHarness(error) {
63
+ return error instanceof ConnectStepError && error.failureReason === "harness_not_installed";
64
+ }
65
+ /** Undefined means the rescue ran; the caller returns immediately. */
66
+ async function requireSupportOrRescue(client, connectClient, state, telemetry, options) {
67
+ try {
68
+ return requireCommandSupport(client);
69
+ }
70
+ catch (error) {
71
+ if (!options.fallbackWhenMissing || !isMissingHarness(error))
72
+ throw error;
73
+ await runMissingHarnessFallback(client.displayName, connectClient, state, telemetry, options);
74
+ return undefined;
75
+ }
76
+ }
77
+ // The harness attempt stays failed; the rescue rides fallback_outcome (PF-3707).
78
+ async function runMissingHarnessFallback(displayName, connectClient, state, telemetry, options) {
79
+ // Stamped before any step: Ctrl+C settles before an outcome exists.
80
+ telemetry.setFallbackOutcome("abandoned");
81
+ errLine(`${displayName} was not found on PATH. Setting up the TinyFish CLI instead.`);
82
+ const outcome = await runCliFallback({
83
+ apiKey: options.apiKey,
84
+ verbose: options.verbose ?? false,
85
+ prompt: detectHumanInitiated() ? createStdinPrompt() : undefined,
86
+ retryCommand: `npx @tiny-fish/cli connect ${connectClient}`,
87
+ hooks: {
88
+ // Terminal detail is explicit below; abort rows still name the step.
89
+ onStepStart: (stage) => {
90
+ state.stage = stage;
91
+ },
92
+ onStepDone: (phase) => telemetry.track("checkpoint", { phase }),
93
+ },
94
+ });
95
+ telemetry.setFallbackOutcome(outcome);
96
+ // Never `completed`: dashboards read that as "the harness install worked".
97
+ settle(state, telemetry, "failed", {
98
+ failedStage: "prerequisite_check",
99
+ failureReason: "harness_not_installed",
100
+ });
101
+ if (outcome !== "cli_verified") {
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ errLine("The TinyFish CLI is installed and working.");
106
+ errLine("Using a different agent? Run: tinyfish connect --all");
107
+ }
61
108
  async function connectNativeMcpClient(client, options) {
62
109
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
63
110
  const state = { stage: "prerequisite_check", settled: false };
64
111
  const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
65
- await runGuarded(state, telemetry, () => {
112
+ await runGuarded(state, telemetry, async () => {
66
113
  telemetry.track("started");
67
- const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
114
+ const support = await requireSupportOrRescue(client, client.connectClient, state, telemetry, options);
115
+ if (!support)
116
+ return;
117
+ const { optionalSupported, keyAuthSupported, harnessVersion } = support;
68
118
  telemetry.setHarnessVersion(harnessVersion);
69
119
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
70
120
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
@@ -293,9 +343,12 @@ export async function connectOpenClaw(options) {
293
343
  settled: false,
294
344
  harnessDegraded: false,
295
345
  };
296
- await runGuarded(state, telemetry, () => {
346
+ await runGuarded(state, telemetry, async () => {
297
347
  telemetry.track("started");
298
- telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
348
+ const support = await requireSupportOrRescue(OPENCLAW, "openclaw", state, telemetry, options);
349
+ if (!support)
350
+ return;
351
+ telemetry.setHarnessVersion(support.harnessVersion);
299
352
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
300
353
  if (options.hoistedCliInstall) {
301
354
  state.stage = "cli_install";
@@ -570,6 +623,8 @@ export function registerConnect(program) {
570
623
  attemptId,
571
624
  // A key that the install cannot use leaves only a browser hop no agent can finish.
572
625
  keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
626
+ // Only here: a missing harness rescues via the CLI (PF-3707).
627
+ fallbackWhenMissing: true,
573
628
  verbose: options.verbose ?? false,
574
629
  };
575
630
  const connector = CLIENT_CONNECTORS[client];
@@ -2,11 +2,10 @@ import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
4
  import { loadConfig, validatedApiKey } from "./auth.js";
5
- import { TINYFISH_API_KEY_VAR } from "./constants.js";
6
5
  import { installTinyFishCli, } from "./cli-install.js";
7
6
  import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS } from "./connect-clients.js";
8
- import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
9
- import { commandNotFound, ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, throwIfInterrupted, } from "./connect-runtime.js";
7
+ import { createStdinPrompt, runCliFallback } from "./connect-fallback.js";
8
+ import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, } from "./connect-runtime.js";
10
9
  import { pickHarnesses } from "./connect-picker.js";
11
10
  import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from "./harness-detect.js";
12
11
  import { detectHumanInitiated } from "./harness.js";
@@ -184,9 +183,14 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
184
183
  try {
185
184
  await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
186
185
  }
187
- catch {
188
- // Ctrl+D closed stdin — treat like the no-TTY skip.
189
- return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
186
+ catch (error) {
187
+ // Ctrl+C stops the run; Ctrl+D only skips this harness.
188
+ if (error instanceof ConnectInterruptedError) {
189
+ errLine("Setup interrupted — run the command again to finish.");
190
+ process.exitCode = 130;
191
+ return { ...base, outcome: "interrupted", fixCommand };
192
+ }
193
+ return { ...base, outcome: "no_tty_auth_skip", fixCommand };
190
194
  }
191
195
  }
192
196
  // runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
@@ -361,96 +365,10 @@ function computeExitCode(results) {
361
365
  return 0;
362
366
  return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
363
367
  }
364
- const SEARCH_RETRY = 'tinyfish search query --pretty -- "<your query>"';
365
- function searchRetry(query) {
366
- if (process.platform === "win32")
367
- return SEARCH_RETRY;
368
- return `tinyfish search query --pretty -- '${query.replaceAll("'", "'\\''")}'`;
369
- }
370
- async function runCliFallback(opts, prompt) {
371
- try {
372
- installTinyFishCli({ verbose: opts.verbose ?? false, announce: true });
373
- }
374
- catch (error) {
375
- if (error instanceof ConnectInterruptedError)
376
- throw error;
377
- errLine(error instanceof Error ? error.message : String(error));
378
- outLine("Retry: npx @tiny-fish/cli connect --all");
379
- return 1;
380
- }
381
- const apiKey = opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR];
382
- if (!prompt && !apiKey) {
383
- outLine("Retry in a terminal: npx @tiny-fish/cli connect --all");
384
- return 1;
385
- }
386
- try {
387
- ensureCliAuthenticated("cli", apiKey);
388
- }
389
- catch (error) {
390
- if (error instanceof ConnectInterruptedError)
391
- throw error;
392
- errLine(error instanceof Error ? error.message : String(error));
393
- outLine(error instanceof ConnectStepError && error.failureReason === "command_not_found"
394
- ? "Retry in a new terminal: tinyfish auth login"
395
- : "Retry: tinyfish auth login");
396
- return 1;
397
- }
398
- if (!prompt) {
399
- outLine(`Retry in a terminal: ${SEARCH_RETRY}`);
400
- return 1;
401
- }
402
- let query;
403
- try {
404
- query = (await prompt("What would you like to search? ")).trim();
405
- }
406
- catch {
407
- outLine(`Retry: ${SEARCH_RETRY}`);
408
- return 1;
409
- }
410
- if (!query) {
411
- outLine(`Retry: ${SEARCH_RETRY}`);
412
- return 1;
413
- }
414
- const search = spawn.sync("tinyfish", ["search", "query", "--pretty", "--", query], {
415
- stdio: "inherit",
416
- env: { ...process.env, TF_SETUP_VERIFICATION: "1" },
417
- });
418
- if (commandNotFound(search.error)) {
419
- errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
420
- outLine(`Retry in a new terminal: ${searchRetry(query)}`);
421
- return 1;
422
- }
423
- throwIfInterrupted(search);
424
- if (search.error || search.status !== 0) {
425
- outLine(`Retry: ${searchRetry(query)}`);
426
- return 1;
427
- }
428
- return 0;
429
- }
430
368
  export async function runConnectAll(opts) {
431
369
  const isTTY = detectHumanInitiated();
432
- let stdinClosed = false;
433
370
  // Open interface holds stdin raw; hermes' input() then wedges.
434
- const prompt = isTTY
435
- ? async (question) => {
436
- if (stdinClosed)
437
- throw new Error("stdin is closed");
438
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
439
- // Without a close-linked signal, question() never settles on Ctrl+D.
440
- const closed = new AbortController();
441
- rl.on("close", () => closed.abort());
442
- try {
443
- return await rl.question(question, { signal: closed.signal });
444
- }
445
- catch (e) {
446
- stdinClosed = true;
447
- throw e;
448
- }
449
- finally {
450
- rl.close();
451
- }
452
- }
453
- : undefined;
371
+ const prompt = isTTY ? createStdinPrompt() : undefined;
454
372
  const detections = detectInstalledHarnesses();
455
373
  const gateKey = validatedApiKey(opts.apiKey);
456
374
  // Keyed installs first: working tools land before sign-ins can block.
@@ -577,7 +495,13 @@ export async function runConnectAll(opts) {
577
495
  if (!results.some(actionable)) {
578
496
  if (!opts.dryRun && !opts.uninstall) {
579
497
  try {
580
- return await runCliFallback(opts, prompt);
498
+ const outcome = await runCliFallback({
499
+ apiKey: opts.apiKey,
500
+ verbose: opts.verbose ?? false,
501
+ prompt,
502
+ retryCommand: "npx @tiny-fish/cli connect --all",
503
+ });
504
+ return outcome === "cli_verified" ? 0 : 1;
581
505
  }
582
506
  catch (error) {
583
507
  if (!(error instanceof ConnectInterruptedError))
@@ -0,0 +1,24 @@
1
+ import { type CliFallbackOutcome, type ConnectCheckpoint, type ConnectFailureStage } from "./connect-runtime.js";
2
+ export type Prompt = (question: string) => Promise<string>;
3
+ export declare const SEARCH_RETRY = "tinyfish search query --pretty -- \"<your query>\"";
4
+ export declare const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
5
+ export declare const MALFORMED_ENV_KEY_MESSAGE = "TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.";
6
+ export declare const MALFORMED_STORED_KEY_MESSAGE = "The stored TinyFish API key is malformed. Run: tinyfish auth login";
7
+ export interface CliFallbackHooks {
8
+ onStepStart?: (stage: ConnectFailureStage) => void;
9
+ onStepDone?: (phase: ConnectCheckpoint) => void;
10
+ }
11
+ export interface CliFallbackOptions {
12
+ apiKey?: string;
13
+ verbose: boolean;
14
+ /** Absent means headless: no interactive login, no query prompt. */
15
+ prompt?: Prompt;
16
+ /** Whole-command retry line, per entry point. */
17
+ retryCommand: string;
18
+ /** Absent under --all's zero-harness path, which has no telemetry object. */
19
+ hooks?: CliFallbackHooks;
20
+ }
21
+ /** One readline per question; a held-open interface wedges raw-stdin children. */
22
+ export declare function createStdinPrompt(): Prompt;
23
+ /** Steps print retry lines and return outcomes; only interrupts throw. */
24
+ export declare function runCliFallback(opts: CliFallbackOptions): Promise<CliFallbackOutcome>;
@@ -0,0 +1,136 @@
1
+ import * as readline from "node:readline/promises";
2
+ import spawn from "cross-spawn";
3
+ import { resolvedApiKey, validateKeyFormat } from "./auth.js";
4
+ import { installTinyFishCli } from "./cli-install.js";
5
+ import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
6
+ import { TINYFISH_API_KEY_VAR } from "./constants.js";
7
+ import { commandNotFound, ConnectInterruptedError, ConnectStepError, throwIfInterrupted, } from "./connect-runtime.js";
8
+ import { errLine, outLine } from "./output.js";
9
+ export const SEARCH_RETRY = 'tinyfish search query --pretty -- "<your query>"';
10
+ // Echoes the finale suggestion, so headless rescues rehearse the same task.
11
+ export const DEFAULT_VERIFICATION_QUERY = "today's top Hacker News story";
12
+ // Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
13
+ const HEADLESS_VERIFY_TIMEOUT_MS = 90_000;
14
+ // An exported var must be unset, not re-logged-in.
15
+ export const MALFORMED_ENV_KEY_MESSAGE = "TINYFISH_API_KEY is set but malformed; it will override any stored key. Fix or unset it, then re-run.";
16
+ export const MALFORMED_STORED_KEY_MESSAGE = "The stored TinyFish API key is malformed. Run: tinyfish auth login";
17
+ function searchRetry(query) {
18
+ if (process.platform === "win32")
19
+ return SEARCH_RETRY;
20
+ return `tinyfish search query --pretty -- '${query.replaceAll("'", "'\\''")}'`;
21
+ }
22
+ /** One readline per question; a held-open interface wedges raw-stdin children. */
23
+ export function createStdinPrompt() {
24
+ let stdinClosed = false;
25
+ return async (question) => {
26
+ if (stdinClosed)
27
+ throw new Error("stdin is closed");
28
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
29
+ // Without a close-linked signal, question() never settles on Ctrl+D.
30
+ const closed = new AbortController();
31
+ rl.on("close", () => closed.abort());
32
+ // Raw mode swallows Ctrl+C here; resurface it as the interrupt.
33
+ let interrupted = false;
34
+ rl.on("SIGINT", () => {
35
+ interrupted = true;
36
+ rl.close();
37
+ });
38
+ try {
39
+ return await rl.question(question, { signal: closed.signal });
40
+ }
41
+ catch (error) {
42
+ if (interrupted)
43
+ throw new ConnectInterruptedError("Setup interrupted");
44
+ stdinClosed = true;
45
+ throw error;
46
+ }
47
+ finally {
48
+ rl.close();
49
+ }
50
+ };
51
+ }
52
+ /** Steps print retry lines and return outcomes; only interrupts throw. */
53
+ export async function runCliFallback(opts) {
54
+ const { hooks, prompt } = opts;
55
+ hooks?.onStepStart?.("cli_install");
56
+ try {
57
+ installTinyFishCli({ verbose: opts.verbose, announce: true });
58
+ }
59
+ catch (error) {
60
+ if (error instanceof ConnectInterruptedError)
61
+ throw error;
62
+ errLine(error instanceof Error ? error.message : String(error));
63
+ outLine(`Retry: ${opts.retryCommand}`);
64
+ return "cli_install_failed";
65
+ }
66
+ hooks?.onStepDone?.("cli_installed");
67
+ hooks?.onStepStart?.("authentication");
68
+ // Resolved raw: validatedApiKey collapses a malformed key into "no key".
69
+ const rawKey = resolvedApiKey(opts.apiKey);
70
+ if (rawKey && !validateKeyFormat(rawKey)) {
71
+ // Terminal even in a TTY: login writes config; env shadows it.
72
+ errLine(process.env[TINYFISH_API_KEY_VAR] ? MALFORMED_ENV_KEY_MESSAGE : MALFORMED_STORED_KEY_MESSAGE);
73
+ outLine(`Retry: ${opts.retryCommand}`);
74
+ return "invalid_credentials";
75
+ }
76
+ if (!prompt && !rawKey) {
77
+ outLine(`Retry in a terminal: ${opts.retryCommand}`);
78
+ return "no_credentials";
79
+ }
80
+ try {
81
+ // Gate-only widening: a config-only key must not re-persist anywhere.
82
+ // Headless has no one to answer a login prompt; never inherit stdio there.
83
+ ensureCliAuthenticated("cli", opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR], {
84
+ interactiveLogin: !!prompt,
85
+ });
86
+ }
87
+ catch (error) {
88
+ if (error instanceof ConnectInterruptedError)
89
+ throw error;
90
+ errLine(error instanceof Error ? error.message : String(error));
91
+ outLine(error instanceof ConnectStepError && error.failureReason === "command_not_found"
92
+ ? "Retry in a new terminal: tinyfish auth login"
93
+ : "Retry: tinyfish auth login");
94
+ return "authentication_failed";
95
+ }
96
+ hooks?.onStepDone?.("authenticated");
97
+ hooks?.onStepStart?.("cli_verification");
98
+ let query;
99
+ if (prompt) {
100
+ try {
101
+ query = (await prompt("What would you like to search? ")).trim();
102
+ }
103
+ catch (error) {
104
+ if (error instanceof ConnectInterruptedError)
105
+ throw error;
106
+ outLine(`Retry: ${SEARCH_RETRY}`);
107
+ return "abandoned";
108
+ }
109
+ if (!query) {
110
+ outLine(`Retry: ${SEARCH_RETRY}`);
111
+ return "abandoned";
112
+ }
113
+ }
114
+ else {
115
+ // Headless with a key: a canned query still proves the pipe.
116
+ query = DEFAULT_VERIFICATION_QUERY;
117
+ }
118
+ const search = spawn.sync("tinyfish", ["search", "query", "--pretty", "--", query], {
119
+ stdio: "inherit",
120
+ env: { ...process.env, TF_SETUP_VERIFICATION: "1" },
121
+ // A user-driven search must never be cut off.
122
+ timeout: prompt ? undefined : HEADLESS_VERIFY_TIMEOUT_MS,
123
+ });
124
+ if (commandNotFound(search.error)) {
125
+ errLine(TINYFISH_CLI_NOT_FOUND_MESSAGE);
126
+ outLine(`Retry in a new terminal: ${searchRetry(query)}`);
127
+ return "verification_failed";
128
+ }
129
+ throwIfInterrupted(search);
130
+ if (search.error || search.status !== 0) {
131
+ outLine(`Retry: ${searchRetry(query)}`);
132
+ return "verification_failed";
133
+ }
134
+ hooks?.onStepDone?.("cli_verified");
135
+ return "cli_verified";
136
+ }
@@ -2,11 +2,12 @@ import { AuthMode } from "./harness-detect.js";
2
2
  export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
3
3
  export type AgentClient = "claude-code" | "codex" | "cursor" | "grok" | "hermes" | "openclaw" | "opencode";
4
4
  type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
5
- type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
5
+ export type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch" | "cli_verification";
6
6
  export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "key_auth_unsupported", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
7
7
  export type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
8
8
  export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
9
- type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
9
+ export type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated" | "cli_verified";
10
+ export type CliFallbackOutcome = "cli_verified" | "cli_install_failed" | "no_credentials" | "invalid_credentials" | "authentication_failed" | "verification_failed" | "abandoned";
10
11
  /** Ctrl+C killed a setup child — abandonment, not error. */
11
12
  export declare class ConnectInterruptedError extends Error {
12
13
  }
@@ -60,6 +61,7 @@ export interface ConnectTelemetry {
60
61
  attemptId: string;
61
62
  track(stage: ConnectStage, detail?: ConnectStageDetail): void;
62
63
  setHarnessVersion(version?: string): void;
64
+ setFallbackOutcome(outcome: CliFallbackOutcome): void;
63
65
  flush(): Promise<void>;
64
66
  }
65
67
  /** Terminal events exactly once, whichever of flow or signal handler wins. */
@@ -210,6 +210,7 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
210
210
  const pending = [];
211
211
  let lastTrackAt;
212
212
  let harnessVersion;
213
+ let fallbackOutcome;
213
214
  async function deliver(body) {
214
215
  // Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
215
216
  if (telemetryDisabled())
@@ -229,6 +230,9 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
229
230
  setHarnessVersion(version) {
230
231
  harnessVersion = version;
231
232
  },
233
+ setFallbackOutcome(outcome) {
234
+ fallbackOutcome = outcome;
235
+ },
232
236
  track(stage, detail) {
233
237
  const now = Date.now();
234
238
  // Clamped: a backward clock step would send a route-rejected negative.
@@ -244,6 +248,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
244
248
  failed_stage: detail?.failedStage,
245
249
  phase: detail?.phase,
246
250
  failure_reason: detail?.failureReason,
251
+ // Terminal rows only: rescue checkpoints stay wire-identical to normal ones.
252
+ fallback_outcome: stage === "failed" || stage === "aborted" ? fallbackOutcome : undefined,
247
253
  harness_version: detail?.harnessVersion ?? harnessVersion,
248
254
  auth_mode: detail?.authMode,
249
255
  harness_degraded: detail?.harnessDegraded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.30.1-next.247",
3
+ "version": "0.30.1-next.249",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {