@tiny-fish/cli 0.12.1-next.135 → 0.12.1-next.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -277,6 +277,14 @@ TINYFISH_DEBUG=1 tinyfish agent run "..." --url https://example.com
277
277
  tinyfish --debug agent run "..." --url https://example.com
278
278
  ```
279
279
 
280
+ ### Telemetry
281
+
282
+ The CLI reports setup outcomes (which harnesses connected, whether verification passed) and
283
+ checks npm once a day for a newer CLI. Set `TINYFISH_NO_TELEMETRY` to any value other than
284
+ `0`/`false` to disable both. API keys are never sent — only a SHA-256 lookup hash, and only to
285
+ TinyFish-owned origins (`*.tinyfish.ai`, `*.tinyfish.io`) or a local development endpoint
286
+ (`localhost`, `127.0.0.1`, `[::1]`).
287
+
280
288
  ## CI/CD
281
289
 
282
290
  ```yaml
@@ -1,8 +1,9 @@
1
1
  import { Command } from "commander";
2
+ export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
2
3
  export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
3
4
  export declare const DEFAULT_ONBOARDING_PROMPT: string;
4
5
  export declare const OPENCLAW_ONBOARDING_PROMPT: string;
5
- export type AgentClient = "claude-code" | "codex" | "hermes" | "openclaw";
6
+ export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw";
6
7
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
7
8
  export declare class ConnectInterruptedError extends Error {
8
9
  }
@@ -36,5 +37,10 @@ export declare function connectOpenClaw(options: {
36
37
  launch: boolean;
37
38
  installCli?: boolean;
38
39
  }): Promise<void>;
40
+ /** Writes mcp.json directly — no `cursor mcp add` exists. */
41
+ export declare function connectCursor(options: {
42
+ apiKey?: string;
43
+ mcpUrl: string;
44
+ }): Promise<void>;
39
45
  export declare function launchAgent(client: AgentClient): void;
40
46
  export declare function registerConnect(program: Command): void;
@@ -1,10 +1,13 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import spawn from "cross-spawn";
4
4
  import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
5
5
  import { CLI_VERSION } from "../lib/constants.js";
6
+ import { cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
6
7
  import { errLine } from "../lib/output.js";
7
- const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
8
+ import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
9
+ import { recordInstalledVersion } from "../lib/version-marker.js";
10
+ export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
8
11
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
9
12
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
10
13
  const HERMES_SEED_TIMEOUT_MS = 120_000;
@@ -219,27 +222,6 @@ const OPENCLAW = {
219
222
  unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
220
223
  },
221
224
  };
222
- // TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
223
- // `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
224
- const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
225
- const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
226
- function isTrustedTelemetryOrigin(endpoint) {
227
- let url;
228
- try {
229
- url = new URL(endpoint);
230
- }
231
- catch {
232
- return false;
233
- }
234
- const host = url.hostname.toLowerCase();
235
- // Loopback may use http for local dev; every other trusted origin must be https so the
236
- // key is never sent over cleartext.
237
- if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
238
- return true;
239
- if (url.protocol !== "https:")
240
- return false;
241
- return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
242
- }
243
225
  function storedApiKey() {
244
226
  try {
245
227
  return loadConfig().api_key;
@@ -252,14 +234,11 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
252
234
  const attemptId = randomUUID();
253
235
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
254
236
  const telemetryKey = apiKey || process.env["TINYFISH_API_KEY"] || storedApiKey();
255
- const headers = { "content-type": "application/json" };
256
- // SHA-256 lookup key only — the raw key never rides a telemetry request
257
- // (OWASP: keep credentials out of anything logging infra might capture).
258
- if (telemetryKey && validateKeyFormat(telemetryKey) && isTrustedTelemetryOrigin(endpoint)) {
259
- headers["x-api-key-lookup"] = createHash("sha256").update(telemetryKey).digest("hex");
260
- }
237
+ const headers = telemetryHeaders(endpoint, telemetryKey);
261
238
  const pending = [];
262
239
  async function deliver(body) {
240
+ if (telemetryDisabled())
241
+ return;
263
242
  // One retry: stale keep-alive sockets eat post-install POSTs. 5xx retries;
264
243
  // 4xx never will (schema mismatch, e.g. new stages hitting an old server).
265
244
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -597,6 +576,7 @@ async function connectNativeMcpClient(client, options) {
597
576
  // Persist once here (skill installed + authenticated) so both success paths, launch
598
577
  // or not, record the install; buildConnectHeaders reads it on subsequent requests.
599
578
  saveConnectContext(client.connectClient, telemetry.attemptId);
579
+ recordInstalledVersion();
600
580
  errLine(UPGRADE_HINT);
601
581
  if (!options.launch) {
602
582
  errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
@@ -675,6 +655,7 @@ export async function connectOpenClaw(options) {
675
655
  }
676
656
  telemetry.track("checkpoint", { phase: "authenticated" });
677
657
  saveConnectContext("openclaw", telemetry.attemptId);
658
+ recordInstalledVersion();
678
659
  errLine(UPGRADE_HINT);
679
660
  if (!options.launch) {
680
661
  errLine("TinyFish is connected. Open OpenClaw to start using it.");
@@ -687,6 +668,40 @@ export async function connectOpenClaw(options) {
687
668
  settle(state, telemetry, "completed");
688
669
  });
689
670
  }
671
+ /** Writes mcp.json directly — no `cursor mcp add` exists. */
672
+ export async function connectCursor(options) {
673
+ const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
674
+ const state = { stage: "prerequisite_check", settled: false };
675
+ await runGuarded(state, telemetry, () => {
676
+ telemetry.track("started");
677
+ telemetry.track("checkpoint", { phase: "prerequisite_ok" });
678
+ state.stage = "registration";
679
+ errLine("Adding TinyFish to Cursor...");
680
+ const mcpUrl = new URL(options.mcpUrl);
681
+ mcpUrl.searchParams.set("source", CONNECT_SOURCE);
682
+ mcpUrl.searchParams.set("client", "cursor");
683
+ // Attempt id rides telemetry only; persisting it would break idempotency.
684
+ const result = writeCursorMcpConfig(mcpUrl.toString());
685
+ if (result.status === "corrupt_skip") {
686
+ throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
687
+ `Fix the file, then re-run: tinyfish connect cursor`);
688
+ }
689
+ if (result.backupPath) {
690
+ errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
691
+ }
692
+ telemetry.track("checkpoint", { phase: "registered" });
693
+ state.stage = "authentication";
694
+ ensureCliAuthenticated("cursor", options.apiKey);
695
+ telemetry.track("checkpoint", { phase: "authenticated" });
696
+ saveConnectContext("cursor", telemetry.attemptId);
697
+ recordInstalledVersion();
698
+ // A server written into mcp.json lands unauthenticated: only Cursor's own "Add to
699
+ // Cursor" deep link auto-starts OAuth, so name the click or the install never completes.
700
+ errLine("Added to Cursor. Restart Cursor (or reload the window), then open");
701
+ errLine('Settings -> Tools & MCP and click Connect on "tinyfish" to sign in.');
702
+ settle(state, telemetry, "completed");
703
+ });
704
+ }
690
705
  export function launchAgent(client) {
691
706
  if (client === "claude-code") {
692
707
  launchNativeMcpClient(CLAUDE_CODE);
@@ -697,15 +712,23 @@ export function launchAgent(client) {
697
712
  else if (client === "hermes") {
698
713
  launchNativeMcpClient(HERMES);
699
714
  }
700
- else {
715
+ else if (client === "openclaw") {
701
716
  launchOpenClawWalkthrough();
702
717
  }
718
+ else if (client === "cursor") {
719
+ // Cursor has no chat CLI to launch.
720
+ throw new Error("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
721
+ }
722
+ else {
723
+ const unhandled = client;
724
+ throw new Error(`No launch support for client: ${String(unhandled)}`);
725
+ }
703
726
  }
704
727
  export function registerConnect(program) {
705
728
  program
706
729
  .command("connect")
707
730
  .description("Connect TinyFish to an AI agent")
708
- .argument("<client>", "Agent client to connect (claude-code, codex, hermes, or openclaw)")
731
+ .argument("<client>", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
709
732
  .option("--api-key <apiKey>", "TinyFish API key to persist for the installed skill")
710
733
  .option("--launch", "Launch the agent and start the TinyFish walkthrough")
711
734
  .option("--url <mcpUrl>", "MCP endpoint override")
@@ -740,8 +763,18 @@ export function registerConnect(program) {
740
763
  else if (client === "openclaw") {
741
764
  await connectOpenClaw({ mcpUrl: connectOptions.mcpUrl, launch: connectOptions.launch });
742
765
  }
766
+ else if (client === "cursor") {
767
+ // `connect <client> --launch` is the shape every doc uses; connect anyway, then say
768
+ // why nothing launched rather than throwing away a working install.
769
+ await connectCursor({ apiKey: connectOptions.apiKey, mcpUrl: connectOptions.mcpUrl });
770
+ if (connectOptions.launch) {
771
+ errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
772
+ }
773
+ }
743
774
  else {
744
- throw new Error(`Unsupported client: ${client}. Supported clients: claude-code, codex, hermes, openclaw`);
775
+ throw new Error("Unsupported client: " +
776
+ client +
777
+ ". Supported clients: claude-code, codex, cursor, hermes, openclaw");
745
778
  }
746
779
  });
747
780
  }
@@ -7,7 +7,7 @@ export declare function buildConnectHeaders(connect: ConnectMap, caller: string
7
7
  export declare function runSync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunResponse>;
8
8
  export declare function runAsync(req: CliAgentRunParams, apiKey: string): Promise<AgentRunAsyncResponse>;
9
9
  export declare function runStream(req: CliAgentRunParams, apiKey: string, signal?: AbortSignal): AsyncGenerator<AgentRunWithStreamingResponse>;
10
- export declare function listRuns(opts: RunListParams, apiKey: string): Promise<RunListResponse>;
10
+ export declare function listRuns(opts: RunListParams, apiKey: string, timeout?: number): Promise<RunListResponse>;
11
11
  export declare function getRun(runId: string, apiKey: string): Promise<Run>;
12
12
  export declare function getRunSteps(runId: string, apiKey: string): Promise<RunStepsResponse>;
13
13
  export declare function searchQuery(params: SearchQueryParams, apiKey: string): Promise<SearchQueryResponse>;
@@ -256,9 +256,9 @@ export async function* runStream(req, apiKey, signal) {
256
256
  }
257
257
  }
258
258
  }
259
- export async function listRuns(opts, apiKey) {
259
+ export async function listRuns(opts, apiKey, timeout) {
260
260
  try {
261
- return await createSdkClient(apiKey).runs.list(opts);
261
+ return await createSdkClient(apiKey, timeout).runs.list(opts);
262
262
  }
263
263
  catch (error) {
264
264
  rethrowSdkError(error);
@@ -0,0 +1,13 @@
1
+ export declare function cursorConfigDir(): string;
2
+ export declare function cursorMcpPath(): string;
3
+ export interface CursorMcpWriteResult {
4
+ status: "written" | "unchanged" | "corrupt_skip";
5
+ backupPath?: string;
6
+ error?: string;
7
+ }
8
+ /** Dry-run description of the pending write; touches nothing. */
9
+ export declare function planCursorWrite(mcpUrl: string): string;
10
+ /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
11
+ export declare function writeCursorMcpConfig(mcpUrl: string): CursorMcpWriteResult;
12
+ /** Removes only the `tinyfish` key. */
13
+ export declare function removeCursorMcpServer(): CursorMcpWriteResult;
@@ -0,0 +1,115 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ // The one config file the CLI writes directly — hence backup/merge rigor.
5
+ const TINYFISH_SERVER_KEY = "tinyfish";
6
+ export function cursorConfigDir() {
7
+ return path.join(os.homedir(), ".cursor");
8
+ }
9
+ export function cursorMcpPath() {
10
+ return path.join(cursorConfigDir(), "mcp.json");
11
+ }
12
+ function isPlainRecord(value) {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ function buildTinyfishServerEntry(mcpUrl) {
16
+ return { url: mcpUrl };
17
+ }
18
+ function parseMcpJson(raw) {
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (!isPlainRecord(parsed))
22
+ return { ok: false, error: "mcp.json root is not a JSON object" };
23
+ if ("mcpServers" in parsed && !isPlainRecord(parsed.mcpServers)) {
24
+ return { ok: false, error: "mcp.json mcpServers is not a JSON object" };
25
+ }
26
+ return { ok: true, value: parsed };
27
+ }
28
+ catch (e) {
29
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
30
+ }
31
+ }
32
+ // Non-ENOENT read failures count as corruption: skip, never clobber.
33
+ function readExisting() {
34
+ let raw;
35
+ try {
36
+ raw = fs.readFileSync(cursorMcpPath(), "utf8");
37
+ }
38
+ catch (e) {
39
+ if (e?.code === "ENOENT")
40
+ return { parsed: {} };
41
+ return { error: e instanceof Error ? e.message : String(e) };
42
+ }
43
+ const parsed = parseMcpJson(raw);
44
+ if (!parsed.ok)
45
+ return { error: parsed.error };
46
+ return { raw, parsed: parsed.value };
47
+ }
48
+ /** Dry-run description of the pending write; touches nothing. */
49
+ export function planCursorWrite(mcpUrl) {
50
+ const existing = readExisting();
51
+ const filePath = cursorMcpPath();
52
+ if ("error" in existing) {
53
+ return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
54
+ }
55
+ const servers = existing.parsed.mcpServers;
56
+ const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
57
+ if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl))) {
58
+ return `${filePath}: already has the tinyfish MCP entry — no change`;
59
+ }
60
+ return existing.raw === undefined
61
+ ? `${filePath}: would create with a "tinyfish" MCP server entry`
62
+ : `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry`;
63
+ }
64
+ // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
65
+ function commitServers(filePath, existing, servers) {
66
+ fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
67
+ let backupPath;
68
+ if (existing.raw !== undefined) {
69
+ backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
70
+ fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
71
+ }
72
+ const next = { ...existing.parsed, mcpServers: servers };
73
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
74
+ fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
75
+ try {
76
+ fs.renameSync(tmpPath, filePath);
77
+ }
78
+ catch (e) {
79
+ fs.rmSync(tmpPath, { force: true });
80
+ throw e;
81
+ }
82
+ return { status: "written", backupPath };
83
+ }
84
+ /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
85
+ export function writeCursorMcpConfig(mcpUrl) {
86
+ const filePath = cursorMcpPath();
87
+ const existing = readExisting();
88
+ if ("error" in existing)
89
+ return { status: "corrupt_skip", error: existing.error };
90
+ const servers = isPlainRecord(existing.parsed.mcpServers)
91
+ ? { ...existing.parsed.mcpServers }
92
+ : {};
93
+ const nextEntry = buildTinyfishServerEntry(mcpUrl);
94
+ if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
95
+ return { status: "unchanged" };
96
+ }
97
+ servers[TINYFISH_SERVER_KEY] = nextEntry;
98
+ return commitServers(filePath, existing, servers);
99
+ }
100
+ /** Removes only the `tinyfish` key. */
101
+ export function removeCursorMcpServer() {
102
+ const filePath = cursorMcpPath();
103
+ const existing = readExisting();
104
+ if ("error" in existing)
105
+ return { status: "corrupt_skip", error: existing.error };
106
+ if (existing.raw === undefined)
107
+ return { status: "unchanged" };
108
+ const servers = isPlainRecord(existing.parsed.mcpServers)
109
+ ? { ...existing.parsed.mcpServers }
110
+ : {};
111
+ if (!(TINYFISH_SERVER_KEY in servers))
112
+ return { status: "unchanged" };
113
+ delete servers[TINYFISH_SERVER_KEY];
114
+ return commitServers(filePath, existing, servers);
115
+ }
@@ -0,0 +1,9 @@
1
+ export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
2
+ export type FiveHarness = (typeof FIVE_HARNESSES)[number];
3
+ export declare function harnessConfigPath(harness: FiveHarness): string;
4
+ export interface HarnessDetection {
5
+ harness: FiveHarness;
6
+ detected: boolean;
7
+ configPath: string;
8
+ }
9
+ export declare function detectInstalledHarnesses(): HarnessDetection[];
@@ -0,0 +1,28 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ export const FIVE_HARNESSES = ["claude-code", "codex", "cursor", "hermes", "openclaw"];
5
+ // Presence detection only — dir existence means "installed", nothing more.
6
+ const CONFIG_DIRS = {
7
+ "claude-code": ".claude",
8
+ codex: ".codex",
9
+ cursor: ".cursor",
10
+ hermes: ".hermes",
11
+ openclaw: ".openclaw",
12
+ };
13
+ export function harnessConfigPath(harness) {
14
+ return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
15
+ }
16
+ export function detectInstalledHarnesses() {
17
+ return FIVE_HARNESSES.map((harness) => {
18
+ const configPath = harnessConfigPath(harness);
19
+ let detected;
20
+ try {
21
+ detected = fs.statSync(configPath).isDirectory();
22
+ }
23
+ catch {
24
+ detected = false;
25
+ }
26
+ return { harness, detected, configPath };
27
+ });
28
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
4
+ * (setup_completed, connect-event, update check). Any value except `0`/`false`
5
+ * opts out — a privacy switch must not fail closed on `true`.
6
+ */
7
+ export declare function telemetryDisabled(): boolean;
8
+ export declare function isTrustedTelemetryOrigin(endpoint: string): boolean;
9
+ /** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
10
+ export declare function telemetryHeaders(endpoint: string, apiKey?: string): Record<string, string>;
11
+ declare const harnessResultSchema: z.ZodObject<{
12
+ harness: z.ZodEnum<{
13
+ openclaw: "openclaw";
14
+ cursor: "cursor";
15
+ codex: "codex";
16
+ hermes: "hermes";
17
+ "claude-code": "claude-code";
18
+ }>;
19
+ detected: z.ZodBoolean;
20
+ installed: z.ZodBoolean;
21
+ verify_depth: z.ZodNullable<z.ZodEnum<{
22
+ health: "health";
23
+ "health+auth": "health+auth";
24
+ }>>;
25
+ verify_ok: z.ZodNullable<z.ZodBoolean>;
26
+ }, z.core.$strip>;
27
+ export declare const setupCompletedPayloadSchema: z.ZodObject<{
28
+ harnesses: z.ZodArray<z.ZodObject<{
29
+ harness: z.ZodEnum<{
30
+ openclaw: "openclaw";
31
+ cursor: "cursor";
32
+ codex: "codex";
33
+ hermes: "hermes";
34
+ "claude-code": "claude-code";
35
+ }>;
36
+ detected: z.ZodBoolean;
37
+ installed: z.ZodBoolean;
38
+ verify_depth: z.ZodNullable<z.ZodEnum<{
39
+ health: "health";
40
+ "health+auth": "health+auth";
41
+ }>>;
42
+ verify_ok: z.ZodNullable<z.ZodBoolean>;
43
+ }, z.core.$strip>>;
44
+ cli_version: z.ZodString;
45
+ }, z.core.$strip>;
46
+ export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
47
+ export type HarnessResult = z.infer<typeof harnessResultSchema>;
48
+ /** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
49
+ export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
50
+ export {};
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { validateKeyFormat } from "./auth.js";
4
+ import { CLI_VERSION } from "./constants.js";
5
+ import { FIVE_HARNESSES } from "./harness-detect.js";
6
+ import { errLine } from "./output.js";
7
+ const SETUP_TELEMETRY_TIMEOUT_MS = 2_000;
8
+ /**
9
+ * `TINYFISH_NO_TELEMETRY` suppresses ALL CLI-emitted network analytics
10
+ * (setup_completed, connect-event, update check). Any value except `0`/`false`
11
+ * opts out — a privacy switch must not fail closed on `true`.
12
+ */
13
+ export function telemetryDisabled() {
14
+ const value = (process.env["TINYFISH_NO_TELEMETRY"] ?? "").trim().toLowerCase();
15
+ return value !== "" && value !== "0" && value !== "false";
16
+ }
17
+ // TinyFish-owned domains plus loopback are the only origins the API key may be sent to.
18
+ // `--url` accepts any http(s) URL, so a user-supplied origin must never receive the key.
19
+ const TRUSTED_TELEMETRY_HOST_SUFFIXES = [".tinyfish.ai", ".tinyfish.io"];
20
+ const TRUSTED_TELEMETRY_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
21
+ export function isTrustedTelemetryOrigin(endpoint) {
22
+ let url;
23
+ try {
24
+ url = new URL(endpoint);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ const host = url.hostname.toLowerCase();
30
+ // Loopback may use http for local dev; every other trusted origin must be https so the
31
+ // key is never sent over cleartext.
32
+ if (TRUSTED_TELEMETRY_LOOPBACK_HOSTS.includes(host))
33
+ return true;
34
+ if (url.protocol !== "https:")
35
+ return false;
36
+ return TRUSTED_TELEMETRY_HOST_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
37
+ }
38
+ /** SHA-256 lookup key only, and only to trusted origins — the raw key never rides telemetry. */
39
+ export function telemetryHeaders(endpoint, apiKey) {
40
+ const headers = { "content-type": "application/json" };
41
+ if (apiKey && validateKeyFormat(apiKey) && isTrustedTelemetryOrigin(endpoint)) {
42
+ headers["x-api-key-lookup"] = createHash("sha256").update(apiKey).digest("hex");
43
+ }
44
+ return headers;
45
+ }
46
+ const harnessResultSchema = z.object({
47
+ harness: z.enum(FIVE_HARNESSES),
48
+ detected: z.boolean(),
49
+ installed: z.boolean(),
50
+ verify_depth: z.enum(["health", "health+auth"]).nullable(),
51
+ verify_ok: z.boolean().nullable(),
52
+ });
53
+ export const setupCompletedPayloadSchema = z.object({
54
+ harnesses: z
55
+ .array(harnessResultSchema)
56
+ .length(FIVE_HARNESSES.length)
57
+ .refine((results) => new Set(results.map((r) => r.harness)).size === FIVE_HARNESSES.length, {
58
+ message: "expected one result per harness",
59
+ }),
60
+ cli_version: z.string(),
61
+ });
62
+ /** Fire-and-forget, 2s cap; raw keys never ride telemetry. */
63
+ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
64
+ if (telemetryDisabled())
65
+ return;
66
+ let endpoint;
67
+ let body;
68
+ try {
69
+ // Parse inside try — schema violation must not crash finished setup.
70
+ const payload = setupCompletedPayloadSchema.parse({ harnesses, cli_version: CLI_VERSION });
71
+ endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
72
+ body = JSON.stringify({ event: "setup_completed", ...payload });
73
+ }
74
+ catch (e) {
75
+ if (process.env["TINYFISH_DEBUG"]) {
76
+ errLine(`setup telemetry skipped: ${e instanceof Error ? e.message : String(e)}`);
77
+ }
78
+ return;
79
+ }
80
+ const headers = telemetryHeaders(endpoint, apiKey);
81
+ // One retry: stale keep-alive sockets eat post-install POSTs, and this is the
82
+ // headline `--all` metric. 5xx retries; 4xx never will.
83
+ for (let attempt = 0; attempt < 2; attempt++) {
84
+ try {
85
+ const response = await fetch(endpoint, {
86
+ method: "POST",
87
+ headers,
88
+ body,
89
+ redirect: "error",
90
+ signal: AbortSignal.timeout(SETUP_TELEMETRY_TIMEOUT_MS),
91
+ });
92
+ if (response.ok)
93
+ return;
94
+ if (response.status < 500) {
95
+ if (process.env["TINYFISH_DEBUG"]) {
96
+ errLine(`setup telemetry rejected: HTTP ${response.status}`);
97
+ }
98
+ return;
99
+ }
100
+ }
101
+ catch (e) {
102
+ if (process.env["TINYFISH_DEBUG"]) {
103
+ errLine(`setup telemetry failed: ${e instanceof Error ? e.message : String(e)}`);
104
+ }
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,10 @@
1
+ export type VerifyDepth = "health" | "health+auth";
2
+ export interface VerifyResult {
3
+ depth: VerifyDepth;
4
+ ok: boolean;
5
+ reason?: string;
6
+ }
7
+ /** Reachability check. Verify failure is a warning, never install failure. */
8
+ export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
9
+ /** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
10
+ export declare function verifyMcpAuth(apiKey: string): Promise<VerifyResult>;
@@ -0,0 +1,36 @@
1
+ import { listRuns } from "./client.js";
2
+ const VERIFY_TIMEOUT_MS = 5_000;
3
+ /** Reachability check. Verify failure is a warning, never install failure. */
4
+ export async function verifyMcpHealth(mcpUrl) {
5
+ try {
6
+ const response = await fetch(mcpUrl, {
7
+ method: "GET",
8
+ // Redirect (captive portal, typo'd --url) is not healthy.
9
+ redirect: "error",
10
+ signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
11
+ });
12
+ // MCP rejects bare GET with 4xx — still proves endpoint routed.
13
+ if (response.status >= 500) {
14
+ return { depth: "health", ok: false, reason: `endpoint returned HTTP ${response.status}` };
15
+ }
16
+ return { depth: "health", ok: true };
17
+ }
18
+ catch (e) {
19
+ return {
20
+ depth: "health",
21
+ ok: false,
22
+ reason: e instanceof Error ? e.message : "network error reaching MCP endpoint",
23
+ };
24
+ }
25
+ }
26
+ /** Authenticated check — only where the CLI holds the key (Cursor, OpenClaw). */
27
+ export async function verifyMcpAuth(apiKey) {
28
+ try {
29
+ // Bound it: the SDK default is 600s, and a warning-only check must never hang an install.
30
+ await listRuns({ limit: 1 }, apiKey, VERIFY_TIMEOUT_MS);
31
+ return { depth: "health+auth", ok: true };
32
+ }
33
+ catch (e) {
34
+ return { depth: "health+auth", ok: false, reason: e instanceof Error ? e.message : String(e) };
35
+ }
36
+ }
@@ -0,0 +1,10 @@
1
+ /** Install-time, offline-safe; best-effort — never fails a finished install. */
2
+ export declare function recordInstalledVersion(): void;
3
+ interface UpdateCheckResult {
4
+ updateAvailable: boolean;
5
+ latest?: string;
6
+ }
7
+ /** Throttled npm-currency check; never auto-mutates the install. */
8
+ export declare function checkForCliUpdate(now?: number): Promise<UpdateCheckResult>;
9
+ export declare function printUpdateNoticeIfDrifted(): Promise<void>;
10
+ export {};
@@ -0,0 +1,109 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { z } from "zod";
4
+ import { configDir } from "./auth.js";
5
+ import { CLI_VERSION } from "./constants.js";
6
+ import { errLine } from "./output.js";
7
+ import { telemetryDisabled } from "./setup-telemetry.js";
8
+ const MARKER_FILE = "skill-version-marker.json";
9
+ const THROTTLE_MS = 24 * 60 * 60 * 1000;
10
+ const CHECK_TIMEOUT_MS = 5_000;
11
+ // npm is the only currency signal with a publisher behind it: the cookbook repo has no
12
+ // releases and no release automation, so a tag check would report "up to date" forever.
13
+ const REGISTRY_URL = "https://registry.npmjs.org/@tiny-fish/cli/latest";
14
+ const markerSchema = z.object({
15
+ last_checked_at: z.number().optional(),
16
+ last_known_latest: z.string().optional(),
17
+ });
18
+ function markerPath() {
19
+ return path.join(configDir(), MARKER_FILE);
20
+ }
21
+ function readMarker() {
22
+ try {
23
+ const parsed = markerSchema.safeParse(JSON.parse(fs.readFileSync(markerPath(), "utf8")));
24
+ return parsed.success ? parsed.data : undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ function writeMarker(state) {
31
+ fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
32
+ const tmpPath = `${markerPath()}.tmp-${process.pid}`;
33
+ fs.writeFileSync(tmpPath, JSON.stringify(state), { mode: 0o600 });
34
+ fs.renameSync(tmpPath, markerPath());
35
+ }
36
+ /** Install-time, offline-safe; best-effort — never fails a finished install. */
37
+ export function recordInstalledVersion() {
38
+ try {
39
+ writeMarker({ ...readMarker() });
40
+ }
41
+ catch {
42
+ // marker is a nice-to-have
43
+ }
44
+ }
45
+ const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)$/;
46
+ /** Strictly newer; false on unparsable input. */
47
+ function isNewer(current, latest) {
48
+ const c = SEMVER.exec(current);
49
+ const l = SEMVER.exec(latest);
50
+ if (!c || !l)
51
+ return false;
52
+ for (let i = 1; i <= 3; i++) {
53
+ const cn = Number(c[i]);
54
+ const ln = Number(l[i]);
55
+ if (ln !== cn)
56
+ return ln > cn;
57
+ }
58
+ return false;
59
+ }
60
+ /** Throttled npm-currency check; never auto-mutates the install. */
61
+ export async function checkForCliUpdate(now = Date.now()) {
62
+ const marker = readMarker();
63
+ // No marker means the CLI never installed anything — nothing to nag about.
64
+ if (!marker || telemetryDisabled())
65
+ return { updateAvailable: false };
66
+ const cached = () => ({
67
+ updateAvailable: marker.last_known_latest !== undefined && isNewer(CLI_VERSION, marker.last_known_latest),
68
+ ...(marker.last_known_latest !== undefined ? { latest: marker.last_known_latest } : {}),
69
+ });
70
+ // Time-based only — failed fetch must not stall every invocation.
71
+ if (marker.last_checked_at !== undefined && now - marker.last_checked_at < THROTTLE_MS) {
72
+ return cached();
73
+ }
74
+ let latest;
75
+ try {
76
+ const response = await fetch(REGISTRY_URL, {
77
+ headers: { accept: "application/json" },
78
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
79
+ });
80
+ if (response.ok) {
81
+ const body = (await response.json());
82
+ if (typeof body.version === "string")
83
+ latest = body.version;
84
+ }
85
+ }
86
+ catch {
87
+ // Unreachable registry — don't fail setup, don't erase the cache.
88
+ }
89
+ try {
90
+ writeMarker({
91
+ ...marker,
92
+ last_checked_at: now,
93
+ last_known_latest: latest ?? marker.last_known_latest,
94
+ });
95
+ }
96
+ catch {
97
+ // best-effort
98
+ }
99
+ // Fall back to the cache so consecutive invocations can't disagree.
100
+ if (!latest)
101
+ return cached();
102
+ return { updateAvailable: isNewer(CLI_VERSION, latest), latest };
103
+ }
104
+ export async function printUpdateNoticeIfDrifted() {
105
+ const { updateAvailable } = await checkForCliUpdate();
106
+ if (updateAvailable) {
107
+ errLine("a newer TinyFish CLI is available — re-run `npx @tiny-fish/cli connect --all`");
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.12.1-next.135",
3
+ "version": "0.12.1-next.138",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {