@tiny-fish/cli 0.14.1-next.151 → 0.15.1-next.158

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.
@@ -1,5 +1,5 @@
1
1
  import { err, errLine, out } from "../lib/output.js";
2
- import { loadConfig, validateKeyFormat } from "../lib/auth.js";
2
+ import { validatedApiKey } from "../lib/auth.js";
3
3
  import { readSettingsJson, writeSettingsJson, readClaudeMd, writeClaudeMd, mergeSettings, removeFromSettings, mergeClaudeMd, removeFromClaudeMd, claudeSettingsPath, claudeMdPath, isTinyfishConfiguredInSettings, isTinyfishConfiguredInClaudeMd, } from "../lib/claude-config.js";
4
4
  function loadExistingConfig() {
5
5
  let settings;
@@ -25,11 +25,7 @@ function loadExistingConfig() {
25
25
  return { settings, claudeMd };
26
26
  }
27
27
  function isSignedIn() {
28
- const envKey = process.env.TINYFISH_API_KEY;
29
- if (envKey && validateKeyFormat(envKey))
30
- return true;
31
- const config = loadConfig();
32
- return !!config.api_key && validateKeyFormat(config.api_key);
28
+ return !!validatedApiKey();
33
29
  }
34
30
  function install(settings, claudeMd) {
35
31
  errLine("Configuring Claude Code to use TinyFish...\n");
@@ -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 {};
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import spawn from "cross-spawn";
4
- import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
4
+ import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, validatedApiKey, writeConfig, } from "../lib/auth.js";
5
5
  import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
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";
@@ -15,17 +16,29 @@ export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
15
16
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
16
17
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
17
18
  const HERMES_SEED_TIMEOUT_MS = 120_000;
18
- const MCP_LOGIN_UNAVAILABLE_MESSAGE = "This Claude Code installation does not expose `claude mcp login`, which is required for " +
19
- "one-command MCP authentication. Update Claude Code and retry, or authenticate TinyFish " +
20
- "manually from /mcp.";
21
- const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "This Codex installation does not support one-command MCP authentication. Update Codex and " +
22
- "retry, or add TinyFish manually with `codex mcp add`.";
23
- const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "This Hermes installation does not support one-command MCP authentication. Update Hermes and " +
24
- "retry, or add TinyFish manually with `hermes mcp add`.";
25
- const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "This OpenClaw installation does not support global skill installation. Update OpenClaw and " +
26
- "retry.";
27
- const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "This OpenCode installation does not support one-command MCP setup (`opencode mcp add --url`). " +
28
- "Update OpenCode and retry, or add TinyFish manually with `opencode mcp add`.";
19
+ // An old install is only one reason a flag can go unseen, so no message asserts the cause.
20
+ const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
21
+ const MCP_LOGIN_UNAVAILABLE_MESSAGE = "Could not confirm this Claude Code installation exposes `claude mcp login`: `claude mcp " +
22
+ "--help` did not list it. Update Claude Code and retry, or authenticate TinyFish manually " +
23
+ "from /mcp." +
24
+ SUPPORT_CHECK_DEBUG_HINT;
25
+ const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "Could not confirm this Codex installation supports one-command MCP authentication: `codex " +
26
+ "mcp add --help` did not list `--oauth-resource`. Update Codex and retry, or add TinyFish " +
27
+ "manually with `codex mcp add`." +
28
+ SUPPORT_CHECK_DEBUG_HINT;
29
+ const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "Could not confirm this Hermes installation supports one-command MCP authentication: `hermes " +
30
+ "mcp add --help` did not list `--auth oauth`. Update Hermes and retry, or add TinyFish " +
31
+ "manually with `hermes mcp add`." +
32
+ SUPPORT_CHECK_DEBUG_HINT;
33
+ const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "Could not confirm this OpenClaw installation supports global skill installation: `openclaw " +
34
+ "skills install --help` did not list `--global` and `--acknowledge-clawhub-risk`. Update " +
35
+ "OpenClaw and retry, or install manually with `openclaw skills install @tinyfish/tinyfish " +
36
+ "--global --acknowledge-clawhub-risk`." +
37
+ SUPPORT_CHECK_DEBUG_HINT;
38
+ const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp " +
39
+ "add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with " +
40
+ "`opencode mcp add`." +
41
+ SUPPORT_CHECK_DEBUG_HINT;
29
42
  // OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
30
43
  // model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
31
44
  const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
@@ -58,6 +71,15 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
58
71
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
59
72
  export class ConnectInterruptedError extends Error {
60
73
  }
74
+ class PrerequisiteError extends Error {
75
+ failureReason;
76
+ harnessVersion;
77
+ constructor(message, failureReason, opts) {
78
+ super(message, { cause: opts?.cause });
79
+ this.failureReason = failureReason;
80
+ this.harnessVersion = opts?.harnessVersion;
81
+ }
82
+ }
61
83
  function throwIfInterrupted(result) {
62
84
  // spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
63
85
  // network as the user walking away. A timeout is a failure and must report as one.
@@ -78,14 +100,15 @@ function settle(state, telemetry, stage, detail) {
78
100
  // (npm/npx/OAuth waits) land here.
79
101
  function installConnectSignalGuard(state, telemetry) {
80
102
  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.");
103
+ if (!state.settled) {
104
+ settle(state, telemetry, "aborted", { failedStage: state.stage });
105
+ errLine("Setup interrupted — run the command again to finish.");
106
+ }
107
+ // Settled spans the whole post-install phase; the `completed` POST may still be in flight.
85
108
  await telemetry.flush();
86
109
  });
87
110
  }
88
- /** Shared terminal handling: interrupted → aborted (or completed post-install), else failed. */
111
+ /** Shared terminal handling: interrupted → aborted, else failed. */
89
112
  async function runGuarded(state, telemetry, body) {
90
113
  const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
91
114
  try {
@@ -93,16 +116,17 @@ async function runGuarded(state, telemetry, body) {
93
116
  }
94
117
  catch (error) {
95
118
  if (error instanceof ConnectInterruptedError) {
96
- if (state.stage === "walkthrough_launch") {
97
- settle(state, telemetry, "completed");
98
- return;
99
- }
100
119
  settle(state, telemetry, "aborted", { failedStage: state.stage });
101
120
  errLine("Setup interrupted — run the command again to finish.");
102
121
  process.exitCode = 130;
103
122
  return;
104
123
  }
105
- settle(state, telemetry, "failed", { failedStage: state.stage });
124
+ settle(state, telemetry, "failed", {
125
+ failedStage: state.stage,
126
+ ...(error instanceof PrerequisiteError
127
+ ? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
128
+ : {}),
129
+ });
106
130
  throw error;
107
131
  }
108
132
  finally {
@@ -180,7 +204,8 @@ function launchHermesWalkthrough() {
180
204
  cause: seedResult.error,
181
205
  });
182
206
  }
183
- const sessionId = seedResult.stderr?.match(/session_id:\s*([^\s]+)/i)?.[1];
207
+ // A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
208
+ const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
184
209
  if (!sessionId) {
185
210
  throw new Error("Hermes did not return a walkthrough session ID");
186
211
  }
@@ -252,8 +277,9 @@ const OPENCLAW = {
252
277
  unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
253
278
  },
254
279
  };
255
- function createConnectTelemetry(mcpUrl, client, apiKey) {
256
- const attemptId = randomUUID();
280
+ function createConnectTelemetry(mcpUrl, client, opts) {
281
+ // A setup-page id (via --url) joins "copied the command" to this install attempt.
282
+ const attemptId = opts?.attemptId ?? randomUUID();
257
283
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
258
284
  const pending = [];
259
285
  async function deliver(body) {
@@ -265,7 +291,7 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
265
291
  endpoint,
266
292
  body,
267
293
  attempts: 2,
268
- apiKey: resolvedApiKey(apiKey),
294
+ apiKey: resolvedApiKey(opts?.apiKey),
269
295
  label: "connect",
270
296
  });
271
297
  }
@@ -279,9 +305,13 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
279
305
  stage,
280
306
  failed_stage: detail?.failedStage,
281
307
  phase: detail?.phase,
308
+ failure_reason: detail?.failureReason,
309
+ harness_version: detail?.harnessVersion,
282
310
  runtime_platform: process.platform,
283
311
  node_version: process.version,
284
312
  cli_version: CLI_VERSION,
313
+ // Same signal the usage events carry, so TTY and headless connects can be split.
314
+ is_human_initiated: detectHumanInitiated(),
285
315
  })));
286
316
  },
287
317
  // Awaited in finally so in-flight events land before exit.
@@ -293,22 +323,41 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
293
323
  function requireCommandSupport(client) {
294
324
  const result = spawn.sync(client.command, client.supportCheck.args, {
295
325
  encoding: "utf8",
326
+ // Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
327
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
296
328
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
297
329
  });
298
330
  if (commandNotFound(result.error)) {
299
- throw new Error(`${client.displayName} is not installed or not available on PATH.`, {
300
- cause: result.error,
301
- });
331
+ throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, "harness_not_installed", { cause: result.error });
302
332
  }
303
333
  if (result.error || result.status !== 0) {
304
334
  throwIfInterrupted(result);
305
- throw new Error(client.supportCheck.unavailableMessage, { cause: result.error });
335
+ throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
306
336
  }
307
- const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
337
+ // Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
338
+ const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
308
339
  if (!client.supportCheck.patterns.every((pattern) => pattern.test(output))) {
309
- throw new Error(client.supportCheck.unavailableMessage);
340
+ if (process.env["TINYFISH_DEBUG"]) {
341
+ errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
342
+ }
343
+ throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
344
+ harnessVersion: probeHarnessVersion(client.command),
345
+ });
310
346
  }
311
347
  }
348
+ /** Best-effort; the server rejects non-printable characters and >32 chars. */
349
+ function probeHarnessVersion(command) {
350
+ const result = spawn.sync(command, ["--version"], {
351
+ encoding: "utf8",
352
+ timeout: NON_INTERACTIVE_TIMEOUT_MS,
353
+ });
354
+ if (result.error || result.status !== 0)
355
+ return undefined;
356
+ const version = sanitizeLine(result.stdout ?? "")
357
+ .replace(/[^\x20-\x7E]/g, "")
358
+ .trim();
359
+ return version ? version.slice(0, 32) : undefined;
360
+ }
312
361
  function commandNotFound(error) {
313
362
  return error?.code === "ENOENT";
314
363
  }
@@ -530,7 +579,7 @@ function launchOpenClawWalkthrough() {
530
579
  return true;
531
580
  }
532
581
  async function connectNativeMcpClient(client, options) {
533
- const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options.apiKey);
582
+ const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
534
583
  const state = { stage: "prerequisite_check", settled: false };
535
584
  await runGuarded(state, telemetry, () => {
536
585
  telemetry.track("started");
@@ -544,8 +593,8 @@ async function connectNativeMcpClient(client, options) {
544
593
  mcpUrl.searchParams.set("client", client.connectClient);
545
594
  mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
546
595
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
547
- const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
548
- const useKeyAuth = client.headerAuthSupported === true && !!storedKey && validateKeyFormat(storedKey);
596
+ const storedKey = validatedApiKey(options.apiKey);
597
+ const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
549
598
  state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
550
599
  errLine(`Adding TinyFish to ${client.displayName}...`);
551
600
  const addResult = spawn.sync(client.command, [
@@ -580,6 +629,22 @@ async function connectNativeMcpClient(client, options) {
580
629
  }
581
630
  telemetry.track("checkpoint", { phase: "oauth_done" });
582
631
  }
632
+ // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
633
+ settle(state, telemetry, "completed");
634
+ runPostInstallSteps(client, options, state, telemetry, useKeyAuth);
635
+ });
636
+ }
637
+ function connectedLine(client, useKeyAuth) {
638
+ // Deferred OAuth (codex/hermes): the CLI never observes auth, so don't claim "connected".
639
+ if (!client.loginArgs && !useKeyAuth) {
640
+ return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
641
+ "to sign in to TinyFish the first time you use it.");
642
+ }
643
+ return `TinyFish is connected. Open ${client.displayName} to start using it.`;
644
+ }
645
+ // Runs after `completed` settled: failures warn and report post_install_failed, exit 0.
646
+ function runPostInstallSteps(client, options, state, telemetry, useKeyAuth) {
647
+ try {
583
648
  state.stage = "cli_install";
584
649
  installTinyFishCli();
585
650
  telemetry.track("checkpoint", { phase: "cli_installed" });
@@ -596,15 +661,24 @@ async function connectNativeMcpClient(client, options) {
596
661
  if (client.postConnectNote)
597
662
  errLine(client.postConnectNote);
598
663
  if (!options.launch) {
599
- errLine(`TinyFish is connected. Open ${client.displayName} to start using it.`);
600
- settle(state, telemetry, "completed");
664
+ errLine(connectedLine(client, useKeyAuth));
601
665
  return;
602
666
  }
603
667
  state.stage = "walkthrough_launch";
604
- // Install already done; walkthrough Ctrl+C still counts completed.
605
668
  launchNativeMcpClient(client);
606
- settle(state, telemetry, "completed");
607
- });
669
+ }
670
+ catch (error) {
671
+ reportPostInstallFailure(client.connectClient, client.displayName, state, telemetry, error);
672
+ }
673
+ }
674
+ /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
675
+ function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
676
+ if (!(error instanceof ConnectInterruptedError)) {
677
+ telemetry.track("post_install_failed", { failedStage: state.stage });
678
+ errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
679
+ `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
680
+ }
681
+ errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
608
682
  }
609
683
  export async function connectClaudeCode(options) {
610
684
  await connectNativeMcpClient(CLAUDE_CODE, options);
@@ -619,7 +693,7 @@ export async function connectOpencode(options) {
619
693
  await connectNativeMcpClient(OPENCODE, options);
620
694
  }
621
695
  export async function connectOpenClaw(options) {
622
- const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw");
696
+ const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
623
697
  const state = { stage: "prerequisite_check", settled: false };
624
698
  await runGuarded(state, telemetry, () => {
625
699
  telemetry.track("started");
@@ -668,20 +742,24 @@ export async function connectOpenClaw(options) {
668
742
  telemetry.track("checkpoint", { phase: "authenticated" });
669
743
  saveConnectContext("openclaw", telemetry.attemptId);
670
744
  errLine(UPGRADE_HINT);
745
+ // For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
746
+ settle(state, telemetry, "completed");
671
747
  if (!options.launch) {
672
748
  errLine("TinyFish is connected. Open OpenClaw to start using it.");
673
- settle(state, telemetry, "completed");
674
749
  return;
675
750
  }
676
751
  state.stage = "walkthrough_launch";
677
- // Install already done; walkthrough Ctrl+C still counts completed.
678
- launchOpenClawWalkthrough();
679
- settle(state, telemetry, "completed");
752
+ try {
753
+ launchOpenClawWalkthrough();
754
+ }
755
+ catch (error) {
756
+ reportPostInstallFailure("openclaw", "OpenClaw", state, telemetry, error);
757
+ }
680
758
  });
681
759
  }
682
760
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
683
761
  export async function connectCursor(options) {
684
- const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options.apiKey);
762
+ const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options);
685
763
  const state = { stage: "prerequisite_check", settled: false };
686
764
  await runGuarded(state, telemetry, async () => {
687
765
  telemetry.track("started");
@@ -690,9 +768,11 @@ export async function connectCursor(options) {
690
768
  state.stage = "authentication";
691
769
  ensureCliAuthenticated("cursor", options.apiKey);
692
770
  telemetry.track("checkpoint", { phase: "authenticated" });
693
- // loadConfig() is unvalidated on read — a hand-edited key must not reach mcp.json.
694
- const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
695
- const resolvedKey = storedKey && validateKeyFormat(storedKey) ? storedKey : undefined;
771
+ const resolvedKey = validatedApiKey(options.apiKey);
772
+ // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
773
+ if (!resolvedKey) {
774
+ errLine("Ignoring the stored API key: invalid format. Run: tinyfish auth login");
775
+ }
696
776
  state.stage = "registration";
697
777
  errLine("Adding TinyFish to Cursor...");
698
778
  const mcpUrl = new URL(options.mcpUrl);
@@ -784,7 +864,7 @@ export function registerConnect(program) {
784
864
  if (options.apiKey && !validateKeyFormat(options.apiKey)) {
785
865
  throw new Error("Invalid --api-key value");
786
866
  }
787
- const mcpUrl = options.url ?? DEFAULT_MCP_URL;
867
+ let mcpUrl = options.url ?? DEFAULT_MCP_URL;
788
868
  if (mcpUrl.startsWith("-")) {
789
869
  throw new Error(`Invalid --url value: ${mcpUrl}`);
790
870
  }
@@ -798,6 +878,16 @@ export function registerConnect(program) {
798
878
  if (!["http:", "https:"].includes(parsedUrl.protocol)) {
799
879
  throw new Error(`Invalid --url value: ${mcpUrl}`);
800
880
  }
881
+ // Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
882
+ // base — registration re-stamps it, and nothing else may carry it.
883
+ const pageAttemptId = parsedUrl.searchParams.get("connect_attempt_id");
884
+ let attemptId;
885
+ if (pageAttemptId !== null) {
886
+ parsedUrl.searchParams.delete("connect_attempt_id");
887
+ mcpUrl = parsedUrl.toString();
888
+ if (z.uuid().safeParse(pageAttemptId).success)
889
+ attemptId = pageAttemptId;
890
+ }
801
891
  if (options.all) {
802
892
  if (client) {
803
893
  throw new Error("Pass either a client or --all, not both.");
@@ -823,7 +913,12 @@ export function registerConnect(program) {
823
913
  if (!client) {
824
914
  throw new Error("Pass a client (claude-code, codex, cursor, hermes, openclaw) or --all.");
825
915
  }
826
- const connectOptions = { apiKey: options.apiKey, mcpUrl, launch: options.launch ?? false };
916
+ const connectOptions = {
917
+ apiKey: options.apiKey,
918
+ mcpUrl,
919
+ launch: options.launch ?? false,
920
+ attemptId,
921
+ };
827
922
  if (client === "claude-code") {
828
923
  await connectClaudeCode(connectOptions);
829
924
  }
@@ -837,12 +932,12 @@ export function registerConnect(program) {
837
932
  await connectOpencode(connectOptions);
838
933
  }
839
934
  else if (client === "openclaw") {
840
- await connectOpenClaw({ mcpUrl: connectOptions.mcpUrl, launch: connectOptions.launch });
935
+ await connectOpenClaw(connectOptions);
841
936
  }
842
937
  else if (client === "cursor") {
843
938
  // `connect <client> --launch` is the shape every doc uses; connect anyway, then say
844
939
  // why nothing launched rather than throwing away a working install.
845
- await connectCursor({ apiKey: connectOptions.apiKey, mcpUrl: connectOptions.mcpUrl });
940
+ await connectCursor(connectOptions);
846
941
  if (connectOptions.launch) {
847
942
  errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
848
943
  }
@@ -20,8 +20,26 @@ interface EnvironmentOptions {
20
20
  }
21
21
  export declare function persistApiKeyToEnvironment(apiKey: string, options?: EnvironmentOptions): void;
22
22
  export declare function clearConfig(): boolean;
23
+ /** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
24
+ export declare function isHeaderSafe(key: string): boolean;
23
25
  export declare function validateKeyFormat(key: string): boolean;
24
26
  export declare function maskKey(key: string): string;
27
+ export type KeySource = "explicit" | "env" | "config" | "none";
28
+ export interface ResolvedKey {
29
+ key: string;
30
+ source: Exclude<KeySource, "none">;
31
+ }
32
+ export interface UnresolvedKey {
33
+ error: string;
34
+ source: KeySource;
35
+ }
25
36
  /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
26
37
  export declare function resolvedApiKey(explicitApiKey?: string): string | undefined;
38
+ /** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
39
+ export declare function validatedApiKey(explicitApiKey?: string): string | undefined;
40
+ /**
41
+ * The diagnosis, not the key: doctor must report a bad credential and say where it came from
42
+ * rather than exit like getApiKey does.
43
+ */
44
+ export declare function apiKeyStatus(): ResolvedKey | UnresolvedKey;
27
45
  export declare function getApiKey(): string;
package/dist/lib/auth.js CHANGED
@@ -132,52 +132,62 @@ export function clearConfig() {
132
132
  return false;
133
133
  }
134
134
  }
135
- export function validateKeyFormat(key) {
136
- // Reject control characters (covers CR, LF, and others) — guards against header injection
135
+ /** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
136
+ export function isHeaderSafe(key) {
137
137
  for (let i = 0; i < key.length; i += 1) {
138
138
  if (key.charCodeAt(i) < 0x20)
139
139
  return false;
140
140
  }
141
- return KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
141
+ return true;
142
+ }
143
+ export function validateKeyFormat(key) {
144
+ return isHeaderSafe(key) && KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
142
145
  }
143
146
  export function maskKey(key) {
144
147
  if (key.length <= 8)
145
148
  return "***";
146
149
  return key.slice(0, 12) + "..." + key.slice(-4);
147
150
  }
148
- /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
149
- export function resolvedApiKey(explicitApiKey) {
151
+ // The one flag/env/config precedence chain; `source` says which place a caller must name.
152
+ function lookupApiKey(explicitApiKey) {
150
153
  if (explicitApiKey)
151
- return explicitApiKey;
154
+ return { value: explicitApiKey, source: "explicit" };
152
155
  const envKey = process.env["TINYFISH_API_KEY"];
153
156
  if (envKey)
154
- return envKey;
155
- try {
156
- return loadConfig().api_key;
157
+ return { value: envKey, source: "env" };
158
+ // No try/catch: loadConfig swallows its own errors and returns {}.
159
+ const stored = loadConfig().api_key;
160
+ return stored ? { value: stored, source: "config" } : { source: "none" };
161
+ }
162
+ /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
163
+ export function resolvedApiKey(explicitApiKey) {
164
+ return lookupApiKey(explicitApiKey).value;
165
+ }
166
+ /** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
167
+ export function validatedApiKey(explicitApiKey) {
168
+ const key = resolvedApiKey(explicitApiKey);
169
+ return key && validateKeyFormat(key) ? key : undefined;
170
+ }
171
+ /**
172
+ * The diagnosis, not the key: doctor must report a bad credential and say where it came from
173
+ * rather than exit like getApiKey does.
174
+ */
175
+ export function apiKeyStatus() {
176
+ const { value, source } = lookupApiKey();
177
+ if (!value || source === "none") {
178
+ return { error: "No API key found. Run: tinyfish auth login", source: "none" };
157
179
  }
158
- catch {
159
- return undefined;
180
+ // Shape is the server's call. A local prefix rule turns a key format we have not shipped
181
+ // support for yet into a hard failure the user cannot work around, and the 401 says more.
182
+ if (!isHeaderSafe(value)) {
183
+ return { error: "The API key contains control characters and cannot be sent", source };
160
184
  }
185
+ return { key: value, source };
161
186
  }
162
187
  export function getApiKey() {
163
- const envKey = process.env.TINYFISH_API_KEY;
164
- if (envKey) {
165
- if (!validateKeyFormat(envKey)) {
166
- err({
167
- error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-...",
168
- });
169
- process.exit(1);
170
- }
171
- return envKey;
172
- }
173
- const config = loadConfig();
174
- if (config.api_key) {
175
- if (!validateKeyFormat(config.api_key)) {
176
- err({ error: "Stored API key has invalid format. Run: tinyfish auth login" });
177
- process.exit(1);
178
- }
179
- return config.api_key;
180
- }
181
- err({ error: "No API key found. Run: tinyfish auth login" });
188
+ const resolved = apiKeyStatus();
189
+ if ("key" in resolved)
190
+ return resolved.key;
191
+ err({ error: resolved.error });
182
192
  process.exit(1);
183
193
  }
@@ -1,7 +1,7 @@
1
1
  import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
4
- import { resolvedApiKey, validateKeyFormat } from "./auth.js";
4
+ import { validatedApiKey } from "./auth.js";
5
5
  import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
6
6
  import { detectHumanInitiated } from "./harness.js";
7
7
  import { emitNotice } from "./notice.js";
@@ -29,10 +29,6 @@ const UNINSTALL_POINTERS = {
29
29
  hermes: "hermes mcp remove tinyfish",
30
30
  openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
31
31
  };
32
- function hasStoredAuth(explicitApiKey) {
33
- const key = resolvedApiKey(explicitApiKey);
34
- return !!key && validateKeyFormat(key);
35
- }
36
32
  async function runHarnessConnect(harness, opts) {
37
33
  const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
38
34
  if (harness === "claude-code")
@@ -102,7 +98,7 @@ async function processHarness(detection, opts, prompt) {
102
98
  if (opts.dryRun) {
103
99
  const plan = opts.uninstall
104
100
  ? uninstallPlanText(harness)
105
- : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
101
+ : planText(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
106
102
  return { ...base, outcome: "dry_run", fixCommand: plan };
107
103
  }
108
104
  if (opts.uninstall)
@@ -110,7 +106,7 @@ async function processHarness(detection, opts, prompt) {
110
106
  const isTTY = detectHumanInitiated();
111
107
  // claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
112
108
  // X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
113
- const keyed = hasStoredAuth(opts.apiKey);
109
+ const keyed = !!validatedApiKey(opts.apiKey);
114
110
  const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
115
111
  const alreadyAuthed = !ownAuthHarness && keyed;
116
112
  if (!isTTY && !alreadyAuthed) {
@@ -142,7 +138,7 @@ async function processHarness(detection, opts, prompt) {
142
138
  if (!interruptedBefore && process.exitCode === 130) {
143
139
  return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
144
140
  }
145
- const verify = await verifyHarness(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
141
+ const verify = await verifyHarness(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
146
142
  return {
147
143
  ...base,
148
144
  installed: true,
@@ -279,7 +275,7 @@ export async function runConnectAll(opts) {
279
275
  verify_depth: r.verifyDepth ?? null,
280
276
  verify_ok: r.verifyOk ?? null,
281
277
  }));
282
- await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, resolvedApiKey(opts.apiKey));
278
+ await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey));
283
279
  // After the summary and before the launch, which is where the user is reading.
284
280
  emitNotice();
285
281
  }
@@ -9,6 +9,15 @@ export interface CursorMcpWriteResult {
9
9
  }
10
10
  /** Dry-run description of the pending write; touches nothing. */
11
11
  export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
12
+ export interface CursorTinyfishEntry {
13
+ present: boolean;
14
+ hasApiKeyHeader: boolean;
15
+ /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
16
+ url?: string;
17
+ error?: string;
18
+ }
19
+ /** Read-only probe for doctor — reports shape, never the header value. */
20
+ export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
12
21
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
13
22
  export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
14
23
  /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
@@ -65,6 +65,25 @@ export function planCursorWrite(mcpUrl, apiKey) {
65
65
  ? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
66
66
  : `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
67
67
  }
68
+ /** Read-only probe for doctor — reports shape, never the header value. */
69
+ export function readCursorTinyfishEntry() {
70
+ const existing = readExisting();
71
+ if ("error" in existing)
72
+ return { present: false, hasApiKeyHeader: false, error: existing.error };
73
+ const servers = existing.parsed.mcpServers;
74
+ const entry = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
75
+ if (!isPlainRecord(entry))
76
+ return { present: false, hasApiKeyHeader: false };
77
+ const headers = entry.headers;
78
+ // Header names are case-insensitive, and this file is hand-editable — matching only the
79
+ // casing we write would understate auth mode for a user who typed it differently.
80
+ return {
81
+ present: true,
82
+ hasApiKeyHeader: isPlainRecord(headers) &&
83
+ Object.entries(headers).some(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string"),
84
+ ...(typeof entry.url === "string" ? { url: entry.url } : {}),
85
+ };
86
+ }
68
87
  // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
69
88
  function commitServers(filePath, existing, servers) {
70
89
  fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
@@ -1,6 +1,8 @@
1
1
  export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
2
2
  export type FiveHarness = (typeof FIVE_HARNESSES)[number];
3
3
  export declare function harnessConfigPath(harness: FiveHarness): string;
4
+ /** For reason strings that name a location; keeps them in sync with the table above. */
5
+ export declare function harnessDisplayPath(harness: FiveHarness): string;
4
6
  export interface HarnessDetection {
5
7
  harness: FiveHarness;
6
8
  detected: boolean;
@@ -13,6 +13,10 @@ const CONFIG_DIRS = {
13
13
  export function harnessConfigPath(harness) {
14
14
  return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
15
15
  }
16
+ /** For reason strings that name a location; keeps them in sync with the table above. */
17
+ export function harnessDisplayPath(harness) {
18
+ return `~/${CONFIG_DIRS[harness]}`;
19
+ }
16
20
  export function detectInstalledHarnesses() {
17
21
  return FIVE_HARNESSES.map((harness) => {
18
22
  const configPath = harnessConfigPath(harness);
@@ -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",
@@ -0,0 +1,15 @@
1
+ import { type FiveHarness } from "./harness-detect.js";
2
+ export type Registered = "yes" | "no" | "unknown";
3
+ export type AuthMode = "api-key" | "oauth" | "unknown";
4
+ export interface RegistrationStatus {
5
+ harness: FiveHarness;
6
+ detected: boolean;
7
+ registered: Registered;
8
+ authMode: AuthMode;
9
+ connectedBefore: boolean;
10
+ /** The endpoint the harness is actually pointed at, when the probe exposes it. */
11
+ registeredUrl?: string;
12
+ reason?: string;
13
+ }
14
+ /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
15
+ export declare function detectRegistrations(harnesses?: FiveHarness[]): RegistrationStatus[];
@@ -0,0 +1,239 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import spawn from "cross-spawn";
4
+ import { z } from "zod";
5
+ import { loadConfig } from "./auth.js";
6
+ import { errLine } from "./output.js";
7
+ import { readCursorTinyfishEntry } from "./cursor-config.js";
8
+ import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
9
+ // 2s tripped on a cold `hermes mcp list` (0.3s warm), reporting `unknown` on a healthy install.
10
+ const PROBE_TIMEOUT_MS = 6_000;
11
+ // A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
12
+ // field is optional, so that arm parsed *any* object-of-objects: a wrapper like
13
+ // `{"servers": {...}}` decoded as one entry named `servers` and reported TinyFish absent.
14
+ const codexEntrySchema = z.looseObject({
15
+ name: z.string().optional(),
16
+ auth_status: z.string().optional(),
17
+ transport: z
18
+ .looseObject({ url: z.string().optional(), bearer_token_env_var: z.string().nullish() })
19
+ .optional(),
20
+ });
21
+ const codexListSchema = z.array(codexEntrySchema);
22
+ // An old harness CLI without the subcommand exits non-zero too; that is unknown, not absent.
23
+ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid subcommand/i;
24
+ // `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
25
+ const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
26
+ const API_KEY_HEADER = /x-api-key/i;
27
+ // The report is pasted in public, so every reason is authored here. A spawn error's own
28
+ // message quotes absolute paths, which is how a home directory reaches the payload.
29
+ function spawnFailureReason(command, code) {
30
+ if (code === "ENOENT")
31
+ return `\`${command}\` is not on PATH`;
32
+ if (code === "EACCES")
33
+ return `\`${command}\` is not executable`;
34
+ return `could not run \`${command}\``;
35
+ }
36
+ function runProbe(command, args) {
37
+ const result = spawn.sync(command, args, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
38
+ if (result.error) {
39
+ const code = result.error.code;
40
+ // A timeout arrives as ETIMEDOUT here, not only as a signal, so it must be named before
41
+ // the generic spawn failure; otherwise a timeout reports "could not run".
42
+ if (code === "ETIMEDOUT") {
43
+ return { outcome: "unavailable", reason: `\`${command} ${args.join(" ")}\` timed out` };
44
+ }
45
+ return { outcome: "unavailable", reason: spawnFailureReason(command, code) };
46
+ }
47
+ // Reached only when something else killed the child, so this is a crash, not the timeout.
48
+ if (result.signal) {
49
+ return { outcome: "unavailable", reason: `\`${command}\` was killed by ${result.signal}` };
50
+ }
51
+ const stdout = result.stdout ?? "";
52
+ return {
53
+ outcome: "ran",
54
+ exitCode: result.status ?? 1,
55
+ stdout,
56
+ output: `${stdout}${result.stderr ?? ""}`,
57
+ };
58
+ }
59
+ // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
60
+ function fromMcpGet(command, keyAuthPattern) {
61
+ const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
62
+ if (probe.outcome === "unavailable") {
63
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
64
+ }
65
+ if (probe.exitCode !== 0) {
66
+ if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
67
+ return {
68
+ registered: "unknown",
69
+ authMode: "unknown",
70
+ reason: `\`${command} mcp get\` is unsupported by this version`,
71
+ };
72
+ }
73
+ // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
74
+ if (!NO_SUCH_SERVER.test(probe.output)) {
75
+ return {
76
+ registered: "unknown",
77
+ authMode: "unknown",
78
+ reason: `\`${command} mcp get\` failed without reporting the server as absent`,
79
+ };
80
+ }
81
+ return { registered: "no", authMode: "unknown" };
82
+ }
83
+ // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
84
+ // of the pattern is `unknown`, never proof of OAuth.
85
+ const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
86
+ return {
87
+ registered: "yes",
88
+ authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
89
+ ...(url ? { registeredUrl: url } : {}),
90
+ };
91
+ }
92
+ // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
93
+ // its exit code; `mcp list --json` is the only trustworthy read-back.
94
+ function probeCodex() {
95
+ const probe = runProbe("codex", ["mcp", "list", "--json"]);
96
+ if (probe.outcome === "unavailable") {
97
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
98
+ }
99
+ // Presence is never inferred from exit code 0, but a nonzero exit means the output cannot
100
+ // be trusted either — that is `unknown`, not "no server".
101
+ if (probe.exitCode !== 0) {
102
+ return {
103
+ registered: "unknown",
104
+ authMode: "unknown",
105
+ reason: `\`codex mcp list --json\` exited ${probe.exitCode}`,
106
+ };
107
+ }
108
+ let servers;
109
+ try {
110
+ // stdout only: an update banner on stderr would otherwise break the parse and report a
111
+ // healthy install as `unknown`.
112
+ servers = JSON.parse(probe.stdout);
113
+ }
114
+ catch {
115
+ return {
116
+ registered: "unknown",
117
+ authMode: "unknown",
118
+ reason: "`codex mcp list --json` returned unparseable output",
119
+ };
120
+ }
121
+ // A scalar or a wrapper object decodes fine and then silently reports "no server", which
122
+ // would suggest a spurious repair. Anything but a list is unknown.
123
+ const shape = codexListSchema.safeParse(servers);
124
+ if (!shape.success) {
125
+ return {
126
+ registered: "unknown",
127
+ authMode: "unknown",
128
+ reason: "`codex mcp list --json` returned an unexpected shape",
129
+ };
130
+ }
131
+ const entry = shape.data.find((s) => s.name === "tinyfish");
132
+ if (!entry)
133
+ return { registered: "no", authMode: "unknown" };
134
+ // auth_status is the configured auth type, not whether a login succeeded — exactly auth_mode.
135
+ const codexUrl = entry.transport?.url;
136
+ return {
137
+ registered: "yes",
138
+ // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
139
+ // the whole payload reports api-key for TinyFish because some other server carries a key.
140
+ authMode: entry.auth_status === "o_auth"
141
+ ? "oauth"
142
+ : entry.transport?.bearer_token_env_var
143
+ ? "api-key"
144
+ : "unknown",
145
+ ...(codexUrl ? { registeredUrl: codexUrl } : {}),
146
+ };
147
+ }
148
+ function probeCursor() {
149
+ const entry = readCursorTinyfishEntry();
150
+ if (entry.error) {
151
+ return {
152
+ registered: "unknown",
153
+ authMode: "unknown",
154
+ reason: "mcp.json exists but could not be read or parsed",
155
+ };
156
+ }
157
+ if (!entry.present)
158
+ return { registered: "no", authMode: "unknown" };
159
+ return {
160
+ registered: "yes",
161
+ authMode: entry.hasApiKeyHeader ? "api-key" : "unknown",
162
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
163
+ };
164
+ }
165
+ // Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
166
+ function probeHermes() {
167
+ const probe = runProbe("hermes", ["mcp", "list"]);
168
+ if (probe.outcome === "unavailable") {
169
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
170
+ }
171
+ if (probe.exitCode !== 0) {
172
+ return {
173
+ registered: "unknown",
174
+ authMode: "unknown",
175
+ reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
176
+ };
177
+ }
178
+ return probe.output.includes("tinyfish")
179
+ ? { registered: "yes", authMode: "oauth" }
180
+ : { registered: "no", authMode: "unknown" };
181
+ }
182
+ // OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
183
+ function probeOpenClaw() {
184
+ const skillsDir = path.join(harnessConfigPath("openclaw"), "skills");
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(skillsDir);
188
+ }
189
+ catch {
190
+ return {
191
+ registered: "unknown",
192
+ authMode: "unknown",
193
+ reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
194
+ };
195
+ }
196
+ return entries.some((name) => /tinyfish/i.test(name))
197
+ ? { registered: "yes", authMode: "api-key" }
198
+ : { registered: "no", authMode: "unknown" };
199
+ }
200
+ const PROBES = {
201
+ "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
202
+ codex: probeCodex,
203
+ cursor: probeCursor,
204
+ hermes: probeHermes,
205
+ openclaw: probeOpenClaw,
206
+ };
207
+ /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
208
+ export function detectRegistrations(harnesses) {
209
+ const connect = loadConfig().connect ?? {};
210
+ return detectInstalledHarnesses()
211
+ .filter((detection) => !harnesses || harnesses.includes(detection.harness))
212
+ .map((detection) => {
213
+ const base = {
214
+ harness: detection.harness,
215
+ detected: detection.detected,
216
+ connectedBefore: detection.harness in connect,
217
+ };
218
+ // Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
219
+ if (!detection.detected) {
220
+ return { ...base, registered: "no", authMode: "unknown" };
221
+ }
222
+ try {
223
+ return { ...base, ...PROBES[detection.harness]() };
224
+ }
225
+ catch (e) {
226
+ // An arbitrary throw is the one message we cannot author, so it never ships. Full
227
+ // fidelity stays on stderr behind --debug, which is not the pasted artifact.
228
+ if (process.env["TINYFISH_DEBUG"]) {
229
+ errLine(`probe ${detection.harness} threw: ${e instanceof Error ? e.message : String(e)}`);
230
+ }
231
+ return {
232
+ ...base,
233
+ registered: "unknown",
234
+ authMode: "unknown",
235
+ reason: "the probe failed unexpectedly",
236
+ };
237
+ }
238
+ });
239
+ }
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.15.1-next.158",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {