@tiny-fish/cli 0.14.1-next.151 → 0.14.1-next.154

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.
@@ -7,6 +7,12 @@ export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openc
7
7
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
8
8
  export declare class ConnectInterruptedError extends Error {
9
9
  }
10
+ interface NativeConnectOptions {
11
+ apiKey?: string;
12
+ mcpUrl: string;
13
+ launch: boolean;
14
+ attemptId?: string;
15
+ }
10
16
  export declare function installTinyFishCli(): void;
11
17
  /**
12
18
  * Refresh the skill we installed, for the harnesses we installed it in.
@@ -17,35 +23,22 @@ export declare function installTinyFishCli(): void;
17
23
  * every agent on the machine).
18
24
  */
19
25
  export declare function updateWebSkill(): void;
20
- export declare function connectClaudeCode(options: {
21
- apiKey?: string;
22
- mcpUrl: string;
23
- launch: boolean;
24
- }): Promise<void>;
25
- export declare function connectCodex(options: {
26
- apiKey?: string;
27
- mcpUrl: string;
28
- launch: boolean;
29
- }): Promise<void>;
30
- export declare function connectHermes(options: {
31
- apiKey?: string;
32
- mcpUrl: string;
33
- launch: boolean;
34
- }): Promise<void>;
35
- export declare function connectOpencode(options: {
36
- apiKey?: string;
37
- mcpUrl: string;
38
- launch: boolean;
39
- }): Promise<void>;
26
+ export declare function connectClaudeCode(options: NativeConnectOptions): Promise<void>;
27
+ export declare function connectCodex(options: NativeConnectOptions): Promise<void>;
28
+ export declare function connectHermes(options: NativeConnectOptions): Promise<void>;
29
+ export declare function connectOpencode(options: NativeConnectOptions): Promise<void>;
40
30
  export declare function connectOpenClaw(options: {
41
31
  mcpUrl: string;
42
32
  launch: boolean;
43
33
  installCli?: boolean;
34
+ attemptId?: string;
44
35
  }): Promise<void>;
45
36
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
46
37
  export declare function connectCursor(options: {
47
38
  apiKey?: string;
48
39
  mcpUrl: string;
40
+ attemptId?: string;
49
41
  }): Promise<void>;
50
42
  export declare function launchAgent(client: AgentClient): void;
51
43
  export declare function registerConnect(program: Command): void;
44
+ export {};
@@ -7,7 +7,8 @@ import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../
7
7
  import { runConnectAll } from "../lib/connect-all.js";
8
8
  import { detectHumanInitiated } from "../lib/harness.js";
9
9
  import { emitNotice } from "../lib/notice.js";
10
- import { errLine } from "../lib/output.js";
10
+ import { errLine, sanitizeLine } from "../lib/output.js";
11
+ import { z } from "zod";
11
12
  import { installSignalGuard } from "../lib/signals.js";
12
13
  import { postConnectEvent, telemetryDisabled } from "../lib/setup-telemetry.js";
13
14
  import { verifyMcpAuth } from "../lib/verify.js";
@@ -58,6 +59,15 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
58
59
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
59
60
  export class ConnectInterruptedError extends Error {
60
61
  }
62
+ class PrerequisiteError extends Error {
63
+ failureReason;
64
+ harnessVersion;
65
+ constructor(message, failureReason, opts) {
66
+ super(message, { cause: opts?.cause });
67
+ this.failureReason = failureReason;
68
+ this.harnessVersion = opts?.harnessVersion;
69
+ }
70
+ }
61
71
  function throwIfInterrupted(result) {
62
72
  // spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
63
73
  // network as the user walking away. A timeout is a failure and must report as one.
@@ -78,14 +88,15 @@ function settle(state, telemetry, stage, detail) {
78
88
  // (npm/npx/OAuth waits) land here.
79
89
  function installConnectSignalGuard(state, telemetry) {
80
90
  return installSignalGuard(async () => {
81
- if (state.settled)
82
- return;
83
- settle(state, telemetry, "aborted", { failedStage: state.stage });
84
- errLine("Setup interrupted — run the command again to finish.");
91
+ if (!state.settled) {
92
+ settle(state, telemetry, "aborted", { failedStage: state.stage });
93
+ errLine("Setup interrupted — run the command again to finish.");
94
+ }
95
+ // Settled spans the whole post-install phase; the `completed` POST may still be in flight.
85
96
  await telemetry.flush();
86
97
  });
87
98
  }
88
- /** Shared terminal handling: interrupted → aborted (or completed post-install), else failed. */
99
+ /** Shared terminal handling: interrupted → aborted, else failed. */
89
100
  async function runGuarded(state, telemetry, body) {
90
101
  const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
91
102
  try {
@@ -93,16 +104,17 @@ async function runGuarded(state, telemetry, body) {
93
104
  }
94
105
  catch (error) {
95
106
  if (error instanceof ConnectInterruptedError) {
96
- if (state.stage === "walkthrough_launch") {
97
- settle(state, telemetry, "completed");
98
- return;
99
- }
100
107
  settle(state, telemetry, "aborted", { failedStage: state.stage });
101
108
  errLine("Setup interrupted — run the command again to finish.");
102
109
  process.exitCode = 130;
103
110
  return;
104
111
  }
105
- settle(state, telemetry, "failed", { failedStage: state.stage });
112
+ settle(state, telemetry, "failed", {
113
+ failedStage: state.stage,
114
+ ...(error instanceof PrerequisiteError
115
+ ? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
116
+ : {}),
117
+ });
106
118
  throw error;
107
119
  }
108
120
  finally {
@@ -252,8 +264,9 @@ const OPENCLAW = {
252
264
  unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
253
265
  },
254
266
  };
255
- function createConnectTelemetry(mcpUrl, client, apiKey) {
256
- const attemptId = randomUUID();
267
+ function createConnectTelemetry(mcpUrl, client, opts) {
268
+ // A setup-page id (via --url) joins "copied the command" to this install attempt.
269
+ const attemptId = opts?.attemptId ?? randomUUID();
257
270
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
258
271
  const pending = [];
259
272
  async function deliver(body) {
@@ -265,7 +278,7 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
265
278
  endpoint,
266
279
  body,
267
280
  attempts: 2,
268
- apiKey: resolvedApiKey(apiKey),
281
+ apiKey: resolvedApiKey(opts?.apiKey),
269
282
  label: "connect",
270
283
  });
271
284
  }
@@ -279,6 +292,8 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
279
292
  stage,
280
293
  failed_stage: detail?.failedStage,
281
294
  phase: detail?.phase,
295
+ failure_reason: detail?.failureReason,
296
+ harness_version: detail?.harnessVersion,
282
297
  runtime_platform: process.platform,
283
298
  node_version: process.version,
284
299
  cli_version: CLI_VERSION,
@@ -296,19 +311,32 @@ function requireCommandSupport(client) {
296
311
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
297
312
  });
298
313
  if (commandNotFound(result.error)) {
299
- throw new Error(`${client.displayName} is not installed or not available on PATH.`, {
300
- cause: result.error,
301
- });
314
+ throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, "harness_not_installed", { cause: result.error });
302
315
  }
303
316
  if (result.error || result.status !== 0) {
304
317
  throwIfInterrupted(result);
305
- throw new Error(client.supportCheck.unavailableMessage, { cause: result.error });
318
+ throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
306
319
  }
307
320
  const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
308
321
  if (!client.supportCheck.patterns.every((pattern) => pattern.test(output))) {
309
- throw new Error(client.supportCheck.unavailableMessage);
322
+ throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
323
+ harnessVersion: probeHarnessVersion(client.command),
324
+ });
310
325
  }
311
326
  }
327
+ /** Best-effort; the server rejects non-printable characters and >32 chars. */
328
+ function probeHarnessVersion(command) {
329
+ const result = spawn.sync(command, ["--version"], {
330
+ encoding: "utf8",
331
+ timeout: NON_INTERACTIVE_TIMEOUT_MS,
332
+ });
333
+ if (result.error || result.status !== 0)
334
+ return undefined;
335
+ const version = sanitizeLine(result.stdout ?? "")
336
+ .replace(/[^\x20-\x7E]/g, "")
337
+ .trim();
338
+ return version ? version.slice(0, 32) : undefined;
339
+ }
312
340
  function commandNotFound(error) {
313
341
  return error?.code === "ENOENT";
314
342
  }
@@ -530,7 +558,7 @@ function launchOpenClawWalkthrough() {
530
558
  return true;
531
559
  }
532
560
  async function connectNativeMcpClient(client, options) {
533
- const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options.apiKey);
561
+ const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
534
562
  const state = { stage: "prerequisite_check", settled: false };
535
563
  await runGuarded(state, telemetry, () => {
536
564
  telemetry.track("started");
@@ -580,6 +608,22 @@ async function connectNativeMcpClient(client, options) {
580
608
  }
581
609
  telemetry.track("checkpoint", { phase: "oauth_done" });
582
610
  }
611
+ // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
612
+ settle(state, telemetry, "completed");
613
+ runPostInstallSteps(client, options, state, telemetry, useKeyAuth);
614
+ });
615
+ }
616
+ function connectedLine(client, useKeyAuth) {
617
+ // Deferred OAuth (codex/hermes): the CLI never observes auth, so don't claim "connected".
618
+ if (!client.loginArgs && !useKeyAuth) {
619
+ return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
620
+ "to sign in to TinyFish the first time you use it.");
621
+ }
622
+ return `TinyFish is connected. Open ${client.displayName} to start using it.`;
623
+ }
624
+ // Runs after `completed` settled: failures warn and report post_install_failed, exit 0.
625
+ function runPostInstallSteps(client, options, state, telemetry, useKeyAuth) {
626
+ try {
583
627
  state.stage = "cli_install";
584
628
  installTinyFishCli();
585
629
  telemetry.track("checkpoint", { phase: "cli_installed" });
@@ -596,15 +640,24 @@ async function connectNativeMcpClient(client, options) {
596
640
  if (client.postConnectNote)
597
641
  errLine(client.postConnectNote);
598
642
  if (!options.launch) {
599
- errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
600
- settle(state, telemetry, "completed");
643
+ errLine(connectedLine(client, useKeyAuth));
601
644
  return;
602
645
  }
603
646
  state.stage = "walkthrough_launch";
604
- // Install already done; walkthrough Ctrl+C still counts completed.
605
647
  launchNativeMcpClient(client);
606
- settle(state, telemetry, "completed");
607
- });
648
+ }
649
+ catch (error) {
650
+ reportPostInstallFailure(client.connectClient, client.displayName, state, telemetry, error);
651
+ }
652
+ }
653
+ /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
654
+ function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
655
+ if (!(error instanceof ConnectInterruptedError)) {
656
+ telemetry.track("post_install_failed", { failedStage: state.stage });
657
+ errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
658
+ `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
659
+ }
660
+ errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
608
661
  }
609
662
  export async function connectClaudeCode(options) {
610
663
  await connectNativeMcpClient(CLAUDE_CODE, options);
@@ -619,7 +672,7 @@ export async function connectOpencode(options) {
619
672
  await connectNativeMcpClient(OPENCODE, options);
620
673
  }
621
674
  export async function connectOpenClaw(options) {
622
- const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw");
675
+ const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
623
676
  const state = { stage: "prerequisite_check", settled: false };
624
677
  await runGuarded(state, telemetry, () => {
625
678
  telemetry.track("started");
@@ -668,20 +721,24 @@ export async function connectOpenClaw(options) {
668
721
  telemetry.track("checkpoint", { phase: "authenticated" });
669
722
  saveConnectContext("openclaw", telemetry.attemptId);
670
723
  errLine(UPGRADE_HINT);
724
+ // For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
725
+ settle(state, telemetry, "completed");
671
726
  if (!options.launch) {
672
727
  errLine("TinyFish is connected. Open OpenClaw to start using it.");
673
- settle(state, telemetry, "completed");
674
728
  return;
675
729
  }
676
730
  state.stage = "walkthrough_launch";
677
- // Install already done; walkthrough Ctrl+C still counts completed.
678
- launchOpenClawWalkthrough();
679
- settle(state, telemetry, "completed");
731
+ try {
732
+ launchOpenClawWalkthrough();
733
+ }
734
+ catch (error) {
735
+ reportPostInstallFailure("openclaw", "OpenClaw", state, telemetry, error);
736
+ }
680
737
  });
681
738
  }
682
739
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
683
740
  export async function connectCursor(options) {
684
- const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
741
+ const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options);
685
742
  const state = { stage: "prerequisite_check", settled: false };
686
743
  await runGuarded(state, telemetry, async () => {
687
744
  telemetry.track("started");
@@ -784,7 +841,7 @@ export function registerConnect(program) {
784
841
  if (options.apiKey && !validateKeyFormat(options.apiKey)) {
785
842
  throw new Error("Invalid --api-key value");
786
843
  }
787
- const mcpUrl = options.url ?? DEFAULT_MCP_URL;
844
+ let mcpUrl = options.url ?? DEFAULT_MCP_URL;
788
845
  if (mcpUrl.startsWith("-")) {
789
846
  throw new Error(`Invalid --url value: ${mcpUrl}`);
790
847
  }
@@ -798,6 +855,16 @@ export function registerConnect(program) {
798
855
  if (!["http:", "https:"].includes(parsedUrl.protocol)) {
799
856
  throw new Error(`Invalid --url value: ${mcpUrl}`);
800
857
  }
858
+ // Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
859
+ // base — registration re-stamps it, and nothing else may carry it.
860
+ const pageAttemptId = parsedUrl.searchParams.get("connect_attempt_id");
861
+ let attemptId;
862
+ if (pageAttemptId !== null) {
863
+ parsedUrl.searchParams.delete("connect_attempt_id");
864
+ mcpUrl = parsedUrl.toString();
865
+ if (z.uuid().safeParse(pageAttemptId).success)
866
+ attemptId = pageAttemptId;
867
+ }
801
868
  if (options.all) {
802
869
  if (client) {
803
870
  throw new Error("Pass either a client or --all, not both.");
@@ -823,7 +890,12 @@ export function registerConnect(program) {
823
890
  if (!client) {
824
891
  throw new Error("Pass a client (claude-code, codex, cursor, hermes, openclaw) or --all.");
825
892
  }
826
- const connectOptions = { apiKey: options.apiKey, mcpUrl, launch: options.launch ?? false };
893
+ const connectOptions = {
894
+ apiKey: options.apiKey,
895
+ mcpUrl,
896
+ launch: options.launch ?? false,
897
+ attemptId,
898
+ };
827
899
  if (client === "claude-code") {
828
900
  await connectClaudeCode(connectOptions);
829
901
  }
@@ -837,12 +909,12 @@ export function registerConnect(program) {
837
909
  await connectOpencode(connectOptions);
838
910
  }
839
911
  else if (client === "openclaw") {
840
- await connectOpenClaw({ mcpUrl: connectOptions.mcpUrl, launch: connectOptions.launch });
912
+ await connectOpenClaw(connectOptions);
841
913
  }
842
914
  else if (client === "cursor") {
843
915
  // `connect <client> --launch` is the shape every doc uses; connect anyway, then say
844
916
  // why nothing launched rather than throwing away a working install.
845
- await connectCursor({ apiKey: connectOptions.apiKey, mcpUrl: connectOptions.mcpUrl });
917
+ await connectCursor(connectOptions);
846
918
  if (connectOptions.launch) {
847
919
  errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
848
920
  }
@@ -3,7 +3,7 @@
3
3
  // value (unenumerated, lowercase-kebab, matching MCP client_name like
4
4
  // "claude-code"). Explicit TF_CLIENT_NAME always wins upstream in client.ts.
5
5
  //
6
- // Fingerprints verified by running scripts/harness-probe against each harness.
6
+ // Fingerprints verified by running scripts/harness-probe.mjs (repo root) in each harness.
7
7
  // Order matters: wrappers that embed another agent must precede the embedded
8
8
  // one. OpenClaw drives Claude Code via `sdk-cli`, so its env carries both
9
9
  // CLAUDECODE=1 and OPENCLAW_MCP_*; it must be checked before claude-code.
@@ -21,9 +21,7 @@ const HARNESS_FINGERPRINTS = [
21
21
  // prefix so we catch a Codex run regardless of which of its vars are set.
22
22
  { name: "codex", matches: (env) => hasVarWithPrefix(env, "CODEX_") },
23
23
  { name: "hermes", matches: (env) => hasVarWithPrefix(env, "HERMES_") },
24
- // OpenCode sets a bare `OPENCODE` env var globally at startup (opencode#9292), inherited by
25
- // the commands it shells out. Exact key, not a prefix — `OPENCODE_*` are user config inputs.
26
- // Sourced from OpenCode's docs, not yet confirmed against scripts/harness-probe.
24
+ // Exact key, not a prefix: opencode writes OPENCODE=1 at startup, reads every OPENCODE_* as config.
27
25
  { name: "opencode", matches: (env) => Boolean(env["OPENCODE"]) },
28
26
  {
29
27
  name: "claude-code",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.14.1-next.151",
3
+ "version": "0.14.1-next.154",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {