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

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,11 +3,12 @@ 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";
9
10
  import { runConnectAll } from "../lib/connect-all.js";
10
- import { AuthMode } from "../lib/harness-detect.js";
11
+ import { AuthMode, RELOAD_ACTION } from "../lib/harness-detect.js";
11
12
  import { detectHumanInitiated } from "../lib/harness.js";
12
13
  import { TINYFISH_API_KEY_VAR } from "../lib/constants.js";
13
14
  import { emitNotice } from "../lib/notice.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.
@@ -170,7 +220,8 @@ function connectedLine(client, signInDeferred) {
170
220
  return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
171
221
  "to sign in to TinyFish the first time you use it.");
172
222
  }
173
- return `TinyFish is connected. Open ${client.displayName} to start using it.`;
223
+ return (`TinyFish is connected. Open ${client.displayName}; if it is already running, ` +
224
+ `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
174
225
  }
175
226
  // Runs after `completed` settled: steps fail alone, the rest still run.
176
227
  function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
@@ -293,9 +344,12 @@ export async function connectOpenClaw(options) {
293
344
  settled: false,
294
345
  harnessDegraded: false,
295
346
  };
296
- await runGuarded(state, telemetry, () => {
347
+ await runGuarded(state, telemetry, async () => {
297
348
  telemetry.track("started");
298
- telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
349
+ const support = await requireSupportOrRescue(OPENCLAW, "openclaw", state, telemetry, options);
350
+ if (!support)
351
+ return;
352
+ telemetry.setHarnessVersion(support.harnessVersion);
299
353
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
300
354
  if (options.hoistedCliInstall) {
301
355
  state.stage = "cli_install";
@@ -334,8 +388,10 @@ export async function connectOpenClaw(options) {
334
388
  settle(state, telemetry, "completed");
335
389
  if (!options.launch) {
336
390
  // Under --all the summary already lists OpenClaw.
337
- if (!options.deferOutro)
338
- errLine("TinyFish is connected. Open OpenClaw to start using it.");
391
+ if (!options.deferOutro) {
392
+ errLine("TinyFish is connected. Open OpenClaw; if it is already running, " +
393
+ `${RELOAD_ACTION.openclaw} to pick up the skill.`);
394
+ }
339
395
  return;
340
396
  }
341
397
  state.stage = "walkthrough_launch";
@@ -570,6 +626,8 @@ export function registerConnect(program) {
570
626
  attemptId,
571
627
  // A key that the install cannot use leaves only a browser hop no agent can finish.
572
628
  keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
629
+ // Only here: a missing harness rescues via the CLI (PF-3707).
630
+ fallbackWhenMissing: true,
573
631
  verbose: options.verbose ?? false,
574
632
  };
575
633
  const connector = CLIENT_CONNECTORS[client];
@@ -2,13 +2,12 @@ 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
- import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from "./harness-detect.js";
10
+ import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, RELOAD_ACTION, } from "./harness-detect.js";
12
11
  import { detectHumanInitiated } from "./harness.js";
13
12
  import { emitNotice } from "./notice.js";
14
13
  import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.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.
@@ -316,6 +320,20 @@ function summaryLine(result, uninstall) {
316
320
  }
317
321
  return `✓ ${name} — installed, ${verifyLabel}`;
318
322
  }
323
+ /** One line for every harness: seven near-identical paragraphs is what this replaces. */
324
+ function reloadHint(results) {
325
+ const byAction = new Map();
326
+ for (const result of results) {
327
+ if (!result.installed)
328
+ continue;
329
+ const action = RELOAD_ACTION[result.harness];
330
+ byAction.set(action, [...(byAction.get(action) ?? []), DISPLAY_NAMES[result.harness]]);
331
+ }
332
+ if (byAction.size === 0)
333
+ return undefined;
334
+ const parts = [...byAction].map(([action, names]) => `${names.join(", ")} — ${action}`);
335
+ return `TinyFish tools load when a harness starts. If one is already open: ${parts.join("; ")}.`;
336
+ }
319
337
  /** Absent and unpicked harnesses are noise once something real happened. */
320
338
  function renderSummary(results, uninstall) {
321
339
  if (!results.some(actionable))
@@ -361,96 +379,10 @@ function computeExitCode(results) {
361
379
  return 0;
362
380
  return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
363
381
  }
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
382
  export async function runConnectAll(opts) {
431
383
  const isTTY = detectHumanInitiated();
432
- let stdinClosed = false;
433
384
  // 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;
385
+ const prompt = isTTY ? createStdinPrompt() : undefined;
454
386
  const detections = detectInstalledHarnesses();
455
387
  const gateKey = validatedApiKey(opts.apiKey);
456
388
  // Keyed installs first: working tools land before sign-ins can block.
@@ -571,13 +503,24 @@ export async function runConnectAll(opts) {
571
503
  outLine("TinyFish setup summary:");
572
504
  for (const line of renderSummary(results, opts.uninstall))
573
505
  outLine(line);
506
+ const reload = opts.uninstall ? undefined : reloadHint(results);
507
+ if (reload) {
508
+ outLine("");
509
+ outLine(reload);
510
+ }
574
511
  // Before the launch handover: anything printed after it is never seen.
575
512
  for (const line of outro)
576
513
  errLine(line);
577
514
  if (!results.some(actionable)) {
578
515
  if (!opts.dryRun && !opts.uninstall) {
579
516
  try {
580
- return await runCliFallback(opts, prompt);
517
+ const outcome = await runCliFallback({
518
+ apiKey: opts.apiKey,
519
+ verbose: opts.verbose ?? false,
520
+ prompt,
521
+ retryCommand: "npx @tiny-fish/cli connect --all",
522
+ });
523
+ return outcome === "cli_verified" ? 0 : 1;
581
524
  }
582
525
  catch (error) {
583
526
  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,
@@ -1,6 +1,8 @@
1
1
  export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
2
2
  export type Harness = (typeof ALL_HARNESSES)[number];
3
3
  export declare const HARNESS_DISPLAY_NAMES: Record<Harness, string>;
4
+ /** MCP config is read at startup, so a running harness needs this to see TinyFish. */
5
+ export declare const RELOAD_ACTION: Record<Harness, string>;
4
6
  export declare enum Registered {
5
7
  Yes = "yes",
6
8
  No = "no",
@@ -20,6 +20,17 @@ export const HARNESS_DISPLAY_NAMES = {
20
20
  openclaw: "OpenClaw",
21
21
  opencode: "OpenCode",
22
22
  };
23
+ /** MCP config is read at startup, so a running harness needs this to see TinyFish. */
24
+ export const RELOAD_ACTION = {
25
+ "claude-code": "restart it",
26
+ codex: "restart it",
27
+ // docs.x.ai/build/features/mcp-servers: `r` in /mcps rereads the config.
28
+ grok: "run /mcps and press r",
29
+ hermes: "restart it",
30
+ openclaw: "restart it",
31
+ opencode: "restart it",
32
+ cursor: "reload the window",
33
+ };
23
34
  // Values are the wire contract; the report schema derives from these.
24
35
  export var Registered;
25
36
  (function (Registered) {
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.250",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {