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

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.
@@ -3,9 +3,12 @@ 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
+ import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig } from "../lib/cursor-config.js";
7
+ import { runConnectAll } from "../lib/connect-all.js";
8
+ import { detectHumanInitiated } from "../lib/harness.js";
7
9
  import { errLine } from "../lib/output.js";
8
10
  import { telemetryDisabled, telemetryHeaders } from "../lib/setup-telemetry.js";
11
+ import { verifyMcpAuth } from "../lib/verify.js";
9
12
  import { recordInstalledVersion } from "../lib/version-marker.js";
10
13
  export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
11
14
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
@@ -94,7 +97,7 @@ function installSignalGuard(state, telemetry) {
94
97
  async function runGuarded(state, telemetry, body) {
95
98
  const uninstallSignalGuard = installSignalGuard(state, telemetry);
96
99
  try {
97
- body();
100
+ await body();
98
101
  }
99
102
  catch (error) {
100
103
  if (error instanceof ConnectInterruptedError) {
@@ -125,6 +128,7 @@ const CLAUDE_CODE = {
125
128
  patterns: [/^\s*login(?:\s|\[)/m],
126
129
  unavailableMessage: MCP_LOGIN_UNAVAILABLE_MESSAGE,
127
130
  },
131
+ headerAuthSupported: true,
128
132
  loginArgs: ["mcp", "login", "tinyfish"],
129
133
  // Project scope is shared in .mcp.json; a user setup command must not rewrite it.
130
134
  removals: [
@@ -538,9 +542,17 @@ async function connectNativeMcpClient(client, options) {
538
542
  mcpUrl.searchParams.set("source", CONNECT_SOURCE);
539
543
  mcpUrl.searchParams.set("client", client.connectClient);
540
544
  mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
545
+ // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
546
+ const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
547
+ const useKeyAuth = client.headerAuthSupported === true && !!storedKey && validateKeyFormat(storedKey);
541
548
  state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
542
549
  errLine(`Adding TinyFish to ${client.displayName}...`);
543
- const addResult = spawn.sync(client.command, client.addArgs(mcpUrl.toString(), options.mcpUrl), {
550
+ const addResult = spawn.sync(client.command, [
551
+ ...client.addArgs(mcpUrl.toString(), options.mcpUrl),
552
+ // Key rides argv — readable via /proc/<pid>/cmdline. Accepted because these CLIs
553
+ // take headers no other way.
554
+ ...(useKeyAuth ? ["--header", `X-API-Key: ${storedKey}`] : []),
555
+ ], {
544
556
  stdio: "inherit",
545
557
  });
546
558
  if (addResult.error || addResult.status !== 0) {
@@ -550,7 +562,10 @@ async function connectNativeMcpClient(client, options) {
550
562
  });
551
563
  }
552
564
  telemetry.track("checkpoint", { phase: "registered" });
553
- if (client.loginArgs) {
565
+ if (useKeyAuth) {
566
+ errLine("Using your stored TinyFish API key — no browser sign-in needed.");
567
+ }
568
+ if (client.loginArgs && !useKeyAuth) {
554
569
  state.stage = "client_oauth";
555
570
  errLine("Signing in to TinyFish...");
556
571
  const loginResult = spawn.sync(client.command, client.loginArgs, {
@@ -607,15 +622,7 @@ export async function connectOpenClaw(options) {
607
622
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
608
623
  if (options.installCli !== false) {
609
624
  state.stage = "cli_install";
610
- errLine("Installing the TinyFish CLI...");
611
- const cliInstallResult = spawn.sync("npm", ["install", "--global", "@tiny-fish/cli@latest"], {
612
- stdio: "inherit",
613
- timeout: SKILL_INSTALL_TIMEOUT_MS,
614
- });
615
- if (cliInstallResult.error || cliInstallResult.status !== 0) {
616
- throwIfInterrupted(cliInstallResult);
617
- throw new Error("Could not install the TinyFish CLI", { cause: cliInstallResult.error });
618
- }
625
+ installTinyFishCli();
619
626
  telemetry.track("checkpoint", { phase: "cli_installed" });
620
627
  }
621
628
  state.stage = "skill_install";
@@ -672,16 +679,21 @@ export async function connectOpenClaw(options) {
672
679
  export async function connectCursor(options) {
673
680
  const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
674
681
  const state = { stage: "prerequisite_check", settled: false };
675
- await runGuarded(state, telemetry, () => {
682
+ await runGuarded(state, telemetry, async () => {
676
683
  telemetry.track("started");
677
684
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
685
+ // Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
686
+ state.stage = "authentication";
687
+ ensureCliAuthenticated("cursor", options.apiKey);
688
+ telemetry.track("checkpoint", { phase: "authenticated" });
689
+ const resolvedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
678
690
  state.stage = "registration";
679
691
  errLine("Adding TinyFish to Cursor...");
680
692
  const mcpUrl = new URL(options.mcpUrl);
681
693
  mcpUrl.searchParams.set("source", CONNECT_SOURCE);
682
694
  mcpUrl.searchParams.set("client", "cursor");
683
695
  // Attempt id rides telemetry only; persisting it would break idempotency.
684
- const result = writeCursorMcpConfig(mcpUrl.toString());
696
+ const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
685
697
  if (result.status === "corrupt_skip") {
686
698
  throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
687
699
  `Fix the file, then re-run: tinyfish connect cursor`);
@@ -689,19 +701,43 @@ export async function connectCursor(options) {
689
701
  if (result.backupPath) {
690
702
  errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
691
703
  }
704
+ if (result.repaired) {
705
+ errLine("Repaired the existing TinyFish entry (updated auth/config to current).");
706
+ }
692
707
  telemetry.track("checkpoint", { phase: "registered" });
693
- state.stage = "authentication";
694
- ensureCliAuthenticated("cursor", options.apiKey);
695
- telemetry.track("checkpoint", { phase: "authenticated" });
696
708
  saveConnectContext("cursor", telemetry.attemptId);
697
709
  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.');
710
+ if (resolvedKey) {
711
+ // Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
712
+ const verify = await verifyMcpAuth(resolvedKey);
713
+ if (verify.ok) {
714
+ errLine("TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.");
715
+ }
716
+ else {
717
+ errLine(`TinyFish is configured, but the authenticated check failed (${verify.reason ?? "unknown"}). ` +
718
+ "Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor");
719
+ }
720
+ }
721
+ else if (detectHumanInitiated() && launchCursorDeeplink(mcpUrl.toString())) {
722
+ errLine("Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.");
723
+ }
724
+ else {
725
+ errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
726
+ }
702
727
  settle(state, telemetry, "completed");
703
728
  });
704
729
  }
730
+ /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
731
+ function launchCursorDeeplink(mcpUrl) {
732
+ const deepLink = cursorInstallDeeplink(mcpUrl);
733
+ const [command, args] = process.platform === "darwin"
734
+ ? ["open", [deepLink]]
735
+ : process.platform === "win32"
736
+ ? ["cmd", ["/c", "start", "", deepLink]]
737
+ : ["xdg-open", [deepLink]];
738
+ const result = spawn.sync(command, args, { stdio: "ignore" });
739
+ return !result.error && result.status === 0;
740
+ }
705
741
  export function launchAgent(client) {
706
742
  if (client === "claude-code") {
707
743
  launchNativeMcpClient(CLAUDE_CODE);
@@ -728,7 +764,11 @@ export function registerConnect(program) {
728
764
  program
729
765
  .command("connect")
730
766
  .description("Connect TinyFish to an AI agent")
731
- .argument("<client>", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
767
+ .argument("[client]", "Agent client to connect (claude-code, codex, cursor, hermes, or openclaw)")
768
+ .option("--all", "Detect and connect every supported harness found on this machine")
769
+ .option("--dry-run", "Print planned writes per harness without touching anything (--all only)")
770
+ .option("--uninstall", "Remove TinyFish entries written by connect --all")
771
+ .option("--skip-launch", "Skip the end-of-install agent launch (--all only)")
732
772
  .option("--api-key <apiKey>", "TinyFish API key to persist for the installed skill")
733
773
  .option("--launch", "Launch the agent and start the TinyFish walkthrough")
734
774
  .option("--url <mcpUrl>", "MCP endpoint override")
@@ -750,6 +790,31 @@ export function registerConnect(program) {
750
790
  if (!["http:", "https:"].includes(parsedUrl.protocol)) {
751
791
  throw new Error(`Invalid --url value: ${mcpUrl}`);
752
792
  }
793
+ if (options.all) {
794
+ if (client) {
795
+ throw new Error("Pass either a client or --all, not both.");
796
+ }
797
+ if (options.launch) {
798
+ throw new Error("--launch is not supported with --all. Connect a single client to launch it.");
799
+ }
800
+ const exitCode = await runConnectAll({
801
+ apiKey: options.apiKey,
802
+ mcpUrl,
803
+ dryRun: options.dryRun ?? false,
804
+ uninstall: options.uninstall ?? false,
805
+ noLaunch: options.skipLaunch === true,
806
+ });
807
+ // Preserve the 130 runGuarded set on Ctrl+C.
808
+ if (!process.exitCode)
809
+ process.exitCode = exitCode;
810
+ return;
811
+ }
812
+ if (options.dryRun || options.uninstall) {
813
+ throw new Error("--dry-run and --uninstall are only supported with --all.");
814
+ }
815
+ if (!client) {
816
+ throw new Error("Pass a client (claude-code, codex, cursor, hermes, openclaw) or --all.");
817
+ }
753
818
  const connectOptions = { apiKey: options.apiKey, mcpUrl, launch: options.launch ?? false };
754
819
  if (client === "claude-code") {
755
820
  await connectClaudeCode(connectOptions);
@@ -0,0 +1,9 @@
1
+ export interface ConnectAllOptions {
2
+ apiKey?: string;
3
+ mcpUrl: string;
4
+ dryRun: boolean;
5
+ uninstall: boolean;
6
+ /** Skip the end-of-install agent launch (finale still prints the ask). */
7
+ noLaunch?: boolean;
8
+ }
9
+ export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
@@ -0,0 +1,345 @@
1
+ import * as readline from "node:readline/promises";
2
+ import spawn from "cross-spawn";
3
+ import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
4
+ import { loadConfig, validateKeyFormat } from "./auth.js";
5
+ import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
6
+ import { detectHumanInitiated } from "./harness.js";
7
+ import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
8
+ import { errLine, outLine } from "./output.js";
9
+ import { sendSetupCompleted } from "./setup-telemetry.js";
10
+ import { verifyMcpAuth, verifyMcpHealth } from "./verify.js";
11
+ import { printUpdateNoticeIfDrifted, recordInstalledVersion } from "./version-marker.js";
12
+ const DISPLAY_NAMES = {
13
+ "claude-code": "Claude Code",
14
+ codex: "Codex",
15
+ cursor: "Cursor",
16
+ hermes: "Hermes",
17
+ openclaw: "OpenClaw",
18
+ };
19
+ // `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
20
+ const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
21
+ // Harnesses that drive their own OAuth at install — a stored TinyFish key says nothing
22
+ // about their session, so it must never unlock a headless run for them.
23
+ const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes"]);
24
+ const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
25
+ // Real removal commands — harness id is not the binary name.
26
+ const UNINSTALL_POINTERS = {
27
+ "claude-code": "claude mcp remove tinyfish --scope user",
28
+ codex: "codex mcp remove tinyfish",
29
+ hermes: "hermes mcp remove tinyfish",
30
+ openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
31
+ };
32
+ function resolvedApiKey(explicitApiKey) {
33
+ if (explicitApiKey)
34
+ return explicitApiKey;
35
+ const envKey = process.env["TINYFISH_API_KEY"];
36
+ if (envKey)
37
+ return envKey;
38
+ try {
39
+ return loadConfig().api_key;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function hasStoredAuth(explicitApiKey) {
46
+ const key = resolvedApiKey(explicitApiKey);
47
+ return !!key && validateKeyFormat(key);
48
+ }
49
+ async function runHarnessConnect(harness, opts) {
50
+ const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
51
+ if (harness === "claude-code")
52
+ return connectClaudeCode(base);
53
+ if (harness === "codex")
54
+ return connectCodex(base);
55
+ if (harness === "hermes")
56
+ return connectHermes(base);
57
+ if (harness === "openclaw")
58
+ return connectOpenClaw({ mcpUrl: opts.mcpUrl, launch: false });
59
+ return connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
60
+ }
61
+ async function verifyHarness(harness, mcpUrl, apiKey) {
62
+ const health = await verifyMcpHealth(mcpUrl);
63
+ // claude-code is auth-checkable only in key mode (header-authed config).
64
+ const capable = AUTH_CHECK_CAPABLE.has(harness) || (harness === "claude-code" && !!apiKey);
65
+ if (!capable || !health.ok)
66
+ return health;
67
+ if (!apiKey)
68
+ return { depth: "health+auth", ok: false, reason: "no stored API key" };
69
+ return verifyMcpAuth(apiKey);
70
+ }
71
+ function planText(harness, mcpUrl, apiKey) {
72
+ if (harness === "cursor")
73
+ return planCursorWrite(mcpUrl, apiKey);
74
+ return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
75
+ }
76
+ function uninstallPlanText(harness) {
77
+ if (harness === "cursor") {
78
+ return "would remove the tinyfish entry from ~/.cursor/mcp.json";
79
+ }
80
+ return `would print the removal command \`${UNINSTALL_POINTERS[harness]}\` (config is harness-owned; the CLI removes nothing)`;
81
+ }
82
+ function uninstallHarness(harness, configPath) {
83
+ const detection = { harness, detected: true, configPath, installed: false };
84
+ if (harness !== "cursor") {
85
+ // Harness-owned config — removal happens via each tool's own CLI.
86
+ return { ...detection, outcome: "uninstall_pointer", fixCommand: UNINSTALL_POINTERS[harness] };
87
+ }
88
+ let result;
89
+ try {
90
+ result = removeCursorMcpServer();
91
+ }
92
+ catch (e) {
93
+ result = { status: "corrupt_skip", error: e instanceof Error ? e.message : String(e) };
94
+ }
95
+ if (result.status === "corrupt_skip") {
96
+ return {
97
+ ...detection,
98
+ outcome: "uninstalled",
99
+ errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
100
+ fixCommand: "Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall",
101
+ };
102
+ }
103
+ if (result.status === "unchanged") {
104
+ return { ...detection, outcome: "uninstall_noop" };
105
+ }
106
+ return { ...detection, outcome: "uninstalled" };
107
+ }
108
+ async function processHarness(detection, opts, prompt) {
109
+ const { harness, configPath } = detection;
110
+ if (!detection.detected) {
111
+ return { harness, detected: false, configPath, installed: false, outcome: "not_detected" };
112
+ }
113
+ const base = { harness, detected: true, configPath, installed: false };
114
+ // dryRun first: --dry-run --uninstall must plan, never reach removeCursorMcpServer().
115
+ if (opts.dryRun) {
116
+ const plan = opts.uninstall ? uninstallPlanText(harness) : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
117
+ return { ...base, outcome: "dry_run", fixCommand: plan };
118
+ }
119
+ if (opts.uninstall)
120
+ return uninstallHarness(harness, configPath);
121
+ const isTTY = detectHumanInitiated();
122
+ // claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
123
+ // X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
124
+ const keyed = hasStoredAuth(opts.apiKey);
125
+ const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
126
+ const alreadyAuthed = !ownAuthHarness && keyed;
127
+ if (!isTTY && !alreadyAuthed) {
128
+ return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
129
+ }
130
+ if (isTTY && !alreadyAuthed && prompt) {
131
+ errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
132
+ try {
133
+ await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
134
+ }
135
+ catch {
136
+ // Ctrl+D closed stdin — treat like the no-TTY skip.
137
+ return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
138
+ }
139
+ }
140
+ // runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
141
+ const interruptedBefore = process.exitCode === 130;
142
+ try {
143
+ await runHarnessConnect(harness, opts);
144
+ }
145
+ catch (e) {
146
+ return {
147
+ ...base,
148
+ outcome: "failed",
149
+ errorMessage: e instanceof Error ? e.message : String(e),
150
+ fixCommand: `tinyfish connect ${harness}`,
151
+ };
152
+ }
153
+ if (!interruptedBefore && process.exitCode === 130) {
154
+ return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
155
+ }
156
+ recordInstalledVersion();
157
+ const verify = await verifyHarness(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
158
+ return {
159
+ ...base,
160
+ installed: true,
161
+ outcome: "installed",
162
+ verifyDepth: verify.depth,
163
+ verifyOk: verify.ok,
164
+ verifyReason: verify.reason,
165
+ };
166
+ }
167
+ function summaryLine(result, uninstall) {
168
+ const name = DISPLAY_NAMES[result.harness];
169
+ if (!result.detected) {
170
+ const next = uninstall
171
+ ? "nothing to remove"
172
+ : `Install ${name}, then run: tinyfish connect ${result.harness}`;
173
+ return `- ${name} — not detected (searched ${result.configPath}). ${next}`;
174
+ }
175
+ if (result.outcome === "not_reached") {
176
+ return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
177
+ }
178
+ if (result.outcome === "dry_run") {
179
+ return `… ${name} — ${result.fixCommand}`;
180
+ }
181
+ if (result.outcome === "no_tty_auth_skip") {
182
+ return `… ${name} — skipped, no stored credentials and no terminal to sign in. Fix: ${result.fixCommand}`;
183
+ }
184
+ if (result.outcome === "uninstall_pointer") {
185
+ return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
186
+ }
187
+ if (result.outcome === "uninstall_noop") {
188
+ return `… ${name} — no tinyfish entry found, nothing removed`;
189
+ }
190
+ if (result.outcome === "uninstalled") {
191
+ return result.errorMessage
192
+ ? `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`
193
+ : `✓ ${name} — removed`;
194
+ }
195
+ if (result.outcome === "interrupted") {
196
+ return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
197
+ }
198
+ if (result.outcome === "failed") {
199
+ return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
200
+ }
201
+ const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness);
202
+ const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : "not verified";
203
+ if (authDeferred || result.verifyOk === false) {
204
+ const reason = authDeferred
205
+ ? "one step left: sign in when your agent first runs"
206
+ : `verify warning (${verifyLabel}): ${result.verifyReason ?? "unknown"}`;
207
+ return `⚠ ${name} — installed, ${reason}. Fix: run \`/tinyfish:doctor\` in ${name} once it's open.`;
208
+ }
209
+ return `✓ ${name} — installed, ${verifyLabel}`;
210
+ }
211
+ function computeExitCode(results) {
212
+ const detected = results.filter((r) => r.detected);
213
+ if (detected.length === 0)
214
+ return 1;
215
+ // Only real install attempts count toward the success/failure denominator.
216
+ const attempted = detected.filter((r) => r.outcome === "installed" || r.outcome === "failed" || r.outcome === "interrupted");
217
+ if (attempted.length === 0)
218
+ return 0;
219
+ return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
220
+ }
221
+ export async function runConnectAll(opts) {
222
+ const isTTY = detectHumanInitiated();
223
+ const rl = isTTY
224
+ ? readline.createInterface({ input: process.stdin, output: process.stderr })
225
+ : undefined;
226
+ let prompt;
227
+ if (rl) {
228
+ // Without a close-linked signal, question() never settles on Ctrl+D.
229
+ const closed = new AbortController();
230
+ rl.on("close", () => closed.abort());
231
+ prompt = (question) => rl.question(question, { signal: closed.signal });
232
+ }
233
+ const detections = detectInstalledHarnesses();
234
+ const results = [];
235
+ try {
236
+ for (const [index, detection] of detections.entries()) {
237
+ const { harness } = detection;
238
+ errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
239
+ let result;
240
+ try {
241
+ result = await processHarness(detection, opts, prompt);
242
+ }
243
+ catch (e) {
244
+ // Isolation: one harness throwing never stops the others.
245
+ result = {
246
+ harness,
247
+ detected: detection.detected,
248
+ configPath: detection.configPath,
249
+ installed: false,
250
+ outcome: "failed",
251
+ errorMessage: e instanceof Error ? e.message : String(e),
252
+ fixCommand: `tinyfish connect ${harness}`,
253
+ };
254
+ }
255
+ results.push(result);
256
+ // Ctrl+C means stop, not continue to the next harness.
257
+ if (result.outcome === "interrupted")
258
+ break;
259
+ }
260
+ }
261
+ finally {
262
+ rl?.close();
263
+ }
264
+ // The interrupt break truncates `results`; pad so the 5-entry telemetry schema still parses.
265
+ for (const detection of detections) {
266
+ if (results.some((r) => r.harness === detection.harness))
267
+ continue;
268
+ results.push({
269
+ ...detection,
270
+ installed: false,
271
+ outcome: detection.detected ? "not_reached" : "not_detected",
272
+ fixCommand: `tinyfish connect ${detection.harness}`,
273
+ });
274
+ }
275
+ outLine("");
276
+ outLine("TinyFish setup summary:");
277
+ for (const result of results)
278
+ outLine(summaryLine(result, opts.uninstall));
279
+ if (results.every((r) => !r.detected)) {
280
+ outLine("");
281
+ outLine("No supported harness found. Install one of: " + FIVE_HARNESSES.join(", "));
282
+ if (!opts.uninstall)
283
+ outLine("Then run: npx @tiny-fish/cli connect --all");
284
+ return 1;
285
+ }
286
+ if (!opts.dryRun && !opts.uninstall) {
287
+ const harnessTelemetry = results.map((r) => ({
288
+ harness: r.harness,
289
+ detected: r.detected,
290
+ installed: r.installed,
291
+ verify_depth: r.verifyDepth ?? null,
292
+ verify_ok: r.verifyOk ?? null,
293
+ }));
294
+ await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, resolvedApiKey(opts.apiKey));
295
+ await printUpdateNoticeIfDrifted();
296
+ }
297
+ const exitCode = computeExitCode(results);
298
+ const interrupted = results.some((r) => r.outcome === "interrupted");
299
+ if (exitCode === 0 && !interrupted && !opts.dryRun && !opts.uninstall && results.some((r) => r.installed)) {
300
+ outLine("");
301
+ if (isTTY && !opts.noLaunch && (await launchFirstTask(results))) {
302
+ return exitCode;
303
+ }
304
+ outLine('Now ask your agent: "use TinyFish to find today\'s top Hacker News story"');
305
+ }
306
+ return exitCode;
307
+ }
308
+ const FIRST_TASK_PROMPT = "My TinyFish setup just finished — verify it with a real run: use the TinyFish search tool to " +
309
+ "find today's top Hacker News story, then fetch that page with the TinyFish fetch tool and give " +
310
+ "me a two-line summary. Show which tools you call.";
311
+ // Launchable seeded agents, best-first — cursor-agent shares ~/.cursor/mcp.json with the IDE.
312
+ const LAUNCHERS = [
313
+ { harness: "cursor", command: "cursor-agent", args: [FIRST_TASK_PROMPT] },
314
+ { harness: "claude-code", command: "claude", args: [FIRST_TASK_PROMPT] },
315
+ ];
316
+ async function launchFirstTask(results) {
317
+ const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed));
318
+ if (candidates.length === 0)
319
+ return false;
320
+ // Several installs: ask rather than pick a favorite; plain enter skips.
321
+ let chosen = candidates[0];
322
+ if (candidates.length > 1) {
323
+ const menu = candidates.map((l, i) => `[${i + 1}] ${DISPLAY_NAMES[l.harness]}`).join(" ");
324
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
325
+ let answer;
326
+ try {
327
+ answer = await rl.question(`Try your first TinyFish task now? ${menu} [enter] skip: `);
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ finally {
333
+ rl.close();
334
+ }
335
+ const pick = Number.parseInt(answer.trim(), 10);
336
+ if (!pick || pick < 1 || pick > candidates.length)
337
+ return false;
338
+ chosen = candidates[pick - 1];
339
+ }
340
+ if (!chosen)
341
+ return false;
342
+ outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
343
+ const result = spawn.sync(chosen.command, chosen.args, { stdio: "inherit" });
344
+ return !result.error;
345
+ }
@@ -4,10 +4,14 @@ export interface CursorMcpWriteResult {
4
4
  status: "written" | "unchanged" | "corrupt_skip";
5
5
  backupPath?: string;
6
6
  error?: string;
7
+ /** A pre-existing tinyfish entry was replaced — a repair, not a fresh install. */
8
+ repaired?: boolean;
7
9
  }
8
10
  /** Dry-run description of the pending write; touches nothing. */
9
- export declare function planCursorWrite(mcpUrl: string): string;
11
+ export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
10
12
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
11
- export declare function writeCursorMcpConfig(mcpUrl: string): CursorMcpWriteResult;
13
+ export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
14
+ /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
15
+ export declare function cursorInstallDeeplink(mcpUrl: string): string;
12
16
  /** Removes only the `tinyfish` key. */
13
17
  export declare function removeCursorMcpServer(): CursorMcpWriteResult;
@@ -12,8 +12,9 @@ export function cursorMcpPath() {
12
12
  function isPlainRecord(value) {
13
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
14
14
  }
15
- function buildTinyfishServerEntry(mcpUrl) {
16
- return { url: mcpUrl };
15
+ // Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
16
+ function buildTinyfishServerEntry(mcpUrl, apiKey) {
17
+ return apiKey ? { url: mcpUrl, headers: { "X-API-Key": apiKey } } : { url: mcpUrl };
17
18
  }
18
19
  function parseMcpJson(raw) {
19
20
  try {
@@ -46,20 +47,23 @@ function readExisting() {
46
47
  return { raw, parsed: parsed.value };
47
48
  }
48
49
  /** Dry-run description of the pending write; touches nothing. */
49
- export function planCursorWrite(mcpUrl) {
50
+ export function planCursorWrite(mcpUrl, apiKey) {
50
51
  const existing = readExisting();
51
52
  const filePath = cursorMcpPath();
52
53
  if ("error" in existing) {
53
54
  return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
54
55
  }
56
+ const authNote = apiKey
57
+ ? " with an API-key header — the key is stored in plaintext in that file (value not shown here)"
58
+ : "";
55
59
  const servers = existing.parsed.mcpServers;
56
60
  const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
57
- if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl))) {
61
+ if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
58
62
  return `${filePath}: already has the tinyfish MCP entry — no change`;
59
63
  }
60
64
  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`;
65
+ ? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
66
+ : `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
63
67
  }
64
68
  // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
65
69
  function commitServers(filePath, existing, servers) {
@@ -82,7 +86,7 @@ function commitServers(filePath, existing, servers) {
82
86
  return { status: "written", backupPath };
83
87
  }
84
88
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
85
- export function writeCursorMcpConfig(mcpUrl) {
89
+ export function writeCursorMcpConfig(mcpUrl, apiKey) {
86
90
  const filePath = cursorMcpPath();
87
91
  const existing = readExisting();
88
92
  if ("error" in existing)
@@ -90,12 +94,18 @@ export function writeCursorMcpConfig(mcpUrl) {
90
94
  const servers = isPlainRecord(existing.parsed.mcpServers)
91
95
  ? { ...existing.parsed.mcpServers }
92
96
  : {};
93
- const nextEntry = buildTinyfishServerEntry(mcpUrl);
97
+ const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
94
98
  if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
95
99
  return { status: "unchanged" };
96
100
  }
101
+ const repaired = TINYFISH_SERVER_KEY in servers;
97
102
  servers[TINYFISH_SERVER_KEY] = nextEntry;
98
- return commitServers(filePath, existing, servers);
103
+ return { ...commitServers(filePath, existing, servers), repaired };
104
+ }
105
+ /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
106
+ export function cursorInstallDeeplink(mcpUrl) {
107
+ const config = Buffer.from(JSON.stringify(buildTinyfishServerEntry(mcpUrl))).toString("base64");
108
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${TINYFISH_SERVER_KEY}&config=${encodeURIComponent(config)}`;
99
109
  }
100
110
  /** Removes only the `tinyfish` key. */
101
111
  export function removeCursorMcpServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.12.1-next.138",
3
+ "version": "0.12.1-next.139",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {