@tiny-fish/cli 0.20.1 → 0.21.1-next.194

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,22 @@ import { errLine, sanitizeLine } from "./output.js";
7
7
  import { postConnectEvent, telemetryDisabled } from "./setup-telemetry.js";
8
8
  import { installSignalGuard } from "./signals.js";
9
9
  export const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
10
+ // Mirrors the route's Zod enum; tests both sides pin it.
11
+ export const CONNECT_FAILURE_REASONS = [
12
+ "harness_not_installed",
13
+ "harness_command_unsupported",
14
+ "harness_too_old",
15
+ "command_not_found",
16
+ "timeout",
17
+ "spawn_error",
18
+ "nonzero_exit",
19
+ "invalid_config",
20
+ ];
10
21
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
11
22
  export class ConnectInterruptedError extends Error {
12
23
  }
13
- class PrerequisiteError extends Error {
24
+ /** A classified step failure; runGuarded forwards the reason. */
25
+ export class ConnectStepError extends Error {
14
26
  failureReason;
15
27
  harnessVersion;
16
28
  constructor(message, failureReason, opts) {
@@ -19,6 +31,8 @@ class PrerequisiteError extends Error {
19
31
  this.harnessVersion = opts?.harnessVersion;
20
32
  }
21
33
  }
34
+ class PrerequisiteError extends ConnectStepError {
35
+ }
22
36
  export function throwIfInterrupted(result) {
23
37
  // spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
24
38
  // network as the user walking away. A timeout is a failure and must report as one.
@@ -28,6 +42,19 @@ export function throwIfInterrupted(result) {
28
42
  throw new ConnectInterruptedError("Setup interrupted");
29
43
  }
30
44
  }
45
+ /** Classifies a spawn failure; interrupts throw instead. */
46
+ export function spawnStepError(message, result) {
47
+ throwIfInterrupted(result);
48
+ const code = result.error?.code;
49
+ const reason = !result.error
50
+ ? "nonzero_exit"
51
+ : code === "ENOENT"
52
+ ? "command_not_found"
53
+ : code === "ETIMEDOUT"
54
+ ? "timeout"
55
+ : "spawn_error";
56
+ return new ConnectStepError(message, reason, { cause: result.error });
57
+ }
31
58
  export function commandNotFound(error) {
32
59
  return error?.code === "ENOENT";
33
60
  }
@@ -65,7 +92,7 @@ export async function runGuarded(state, telemetry, body) {
65
92
  }
66
93
  settle(state, telemetry, "failed", {
67
94
  failedStage: state.stage,
68
- ...(error instanceof PrerequisiteError
95
+ ...(error instanceof ConnectStepError
69
96
  ? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
70
97
  : {}),
71
98
  });
@@ -88,14 +115,23 @@ export function requireCommandSupport(client) {
88
115
  }
89
116
  if (result.error || result.status !== 0) {
90
117
  throwIfInterrupted(result);
91
- throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
118
+ // A hung probe is a timeout, not a missing capability.
119
+ if (result.error?.code === "ETIMEDOUT") {
120
+ throw new ConnectStepError(client.supportCheck.unavailableMessage, "timeout", {
121
+ cause: result.error,
122
+ });
123
+ }
124
+ // It ran and exited non-zero, so the version is still answerable.
125
+ throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error, harnessVersion: probeHarnessVersion(client.command) });
92
126
  }
93
127
  // Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
94
128
  const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
95
129
  const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
96
130
  const essential = listed(client.supportCheck.patterns);
97
131
  const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
98
- if ((!essential || !optionalSupported) && process.env["TINYFISH_DEBUG"]) {
132
+ const keyAuthPattern = client.supportCheck.keyAuthPattern;
133
+ const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
134
+ if ((!essential || !optionalSupported || !keyAuthSupported) && process.env["TINYFISH_DEBUG"]) {
99
135
  errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
100
136
  }
101
137
  if (!essential) {
@@ -103,7 +139,7 @@ export function requireCommandSupport(client) {
103
139
  harnessVersion: probeHarnessVersion(client.command),
104
140
  });
105
141
  }
106
- return { optionalSupported };
142
+ return { optionalSupported, keyAuthSupported, harnessVersion: probeHarnessVersion(client.command) };
107
143
  }
108
144
  /** Best-effort; the server rejects non-printable characters and >32 chars. */
109
145
  function probeHarnessVersion(command) {
@@ -113,7 +149,9 @@ function probeHarnessVersion(command) {
113
149
  });
114
150
  if (result.error || result.status !== 0)
115
151
  return undefined;
152
+ // First line only: sanitizeLine keeps newlines, so stripping splices lines.
116
153
  const version = sanitizeLine(result.stdout ?? "")
154
+ .split("\n")[0]
117
155
  .replace(/[^\x20-\x7E]/g, "")
118
156
  .trim();
119
157
  return version ? version.slice(0, 32) : undefined;
@@ -138,6 +176,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
138
176
  const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
139
177
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
140
178
  const pending = [];
179
+ let lastTrackAt;
180
+ let harnessVersion;
141
181
  async function deliver(body) {
142
182
  // Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
143
183
  if (telemetryDisabled())
@@ -154,7 +194,14 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
154
194
  return {
155
195
  attemptId,
156
196
  // Fire-and-forget; awaiting each event stalls setup when telemetry down.
197
+ setHarnessVersion(version) {
198
+ harnessVersion = version;
199
+ },
157
200
  track(stage, detail) {
201
+ const now = Date.now();
202
+ // Clamped: a backward clock step would send a route-rejected negative.
203
+ const stageDurationMs = lastTrackAt === undefined ? undefined : Math.max(0, now - lastTrackAt);
204
+ lastTrackAt = now;
158
205
  // Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
159
206
  // replace the error the flow is already reporting.
160
207
  pending.push(deliver(JSON.stringify({
@@ -164,9 +211,10 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
164
211
  failed_stage: detail?.failedStage,
165
212
  phase: detail?.phase,
166
213
  failure_reason: detail?.failureReason,
167
- harness_version: detail?.harnessVersion,
214
+ harness_version: detail?.harnessVersion ?? harnessVersion,
168
215
  auth_mode: detail?.authMode,
169
216
  harness_degraded: detail?.harnessDegraded,
217
+ stage_duration_ms: stageDurationMs,
170
218
  runtime_platform: process.platform,
171
219
  node_version: process.version,
172
220
  cli_version: CLI_VERSION,
@@ -14,9 +14,10 @@ export interface CursorTinyfishEntry {
14
14
  hasApiKeyHeader: boolean;
15
15
  /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
16
16
  url?: string;
17
+ apiKey?: string;
17
18
  error?: string;
18
19
  }
19
- /** Read-only probe for doctor reports shape, never the header value. */
20
+ /** Carries the header value; callers keep it off the report. */
20
21
  export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
21
22
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
22
23
  export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
@@ -65,7 +65,7 @@ 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. */
68
+ /** Carries the header value; callers keep it off the report. */
69
69
  export function readCursorTinyfishEntry() {
70
70
  const existing = readExisting();
71
71
  if ("error" in existing)
@@ -77,10 +77,13 @@ export function readCursorTinyfishEntry() {
77
77
  const headers = entry.headers;
78
78
  // Header names are case-insensitive, and this file is hand-editable — matching only the
79
79
  // casing we write would understate auth mode for a user who typed it differently.
80
+ const keyHeader = isPlainRecord(headers)
81
+ ? Object.entries(headers).find(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string")
82
+ : undefined;
80
83
  return {
81
84
  present: true,
82
- hasApiKeyHeader: isPlainRecord(headers) &&
83
- Object.entries(headers).some(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string"),
85
+ hasApiKeyHeader: keyHeader !== undefined,
86
+ ...(keyHeader ? { apiKey: keyHeader[1] } : {}),
84
87
  ...(typeof entry.url === "string" ? { url: entry.url } : {}),
85
88
  };
86
89
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { AuthMode, Registered } from "./harness-detect.js";
2
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
3
- export declare const DOCTOR_SCHEMA_VERSION = 1;
4
+ export declare const DOCTOR_SCHEMA_VERSION = 2;
4
5
  declare const checkStatusSchema: z.ZodEnum<{
5
6
  pass: "pass";
6
7
  fail: "fail";
@@ -36,16 +37,8 @@ declare const doctorHarnessSchema: z.ZodObject<{
36
37
  "claude-code": "claude-code";
37
38
  }>;
38
39
  detected: z.ZodBoolean;
39
- registered: z.ZodEnum<{
40
- unknown: "unknown";
41
- yes: "yes";
42
- no: "no";
43
- }>;
44
- auth_mode: z.ZodEnum<{
45
- unknown: "unknown";
46
- "api-key": "api-key";
47
- oauth: "oauth";
48
- }>;
40
+ registered: z.ZodEnum<typeof Registered>;
41
+ auth_mode: z.ZodEnum<typeof AuthMode>;
49
42
  proves_harness_reach: z.ZodBoolean;
50
43
  }, z.core.$strip>;
51
44
  declare const doctorRepairSchema: z.ZodObject<{
@@ -98,16 +91,8 @@ export declare const doctorReportSchema: z.ZodObject<{
98
91
  "claude-code": "claude-code";
99
92
  }>;
100
93
  detected: z.ZodBoolean;
101
- registered: z.ZodEnum<{
102
- unknown: "unknown";
103
- yes: "yes";
104
- no: "no";
105
- }>;
106
- auth_mode: z.ZodEnum<{
107
- unknown: "unknown";
108
- "api-key": "api-key";
109
- oauth: "oauth";
110
- }>;
94
+ registered: z.ZodEnum<typeof Registered>;
95
+ auth_mode: z.ZodEnum<typeof AuthMode>;
111
96
  proves_harness_reach: z.ZodBoolean;
112
97
  }, z.core.$strip>>;
113
98
  repairs: z.ZodArray<z.ZodObject<{
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { ALL_HARNESSES } from "./harness-detect.js";
2
+ import { ALL_HARNESSES, AuthMode, Registered } from "./harness-detect.js";
3
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
- export const DOCTOR_SCHEMA_VERSION = 1;
4
+ export const DOCTOR_SCHEMA_VERSION = 2;
5
5
  const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
6
6
  const harnessSchema = z.enum(ALL_HARNESSES);
7
7
  const doctorCheckSchema = z.object({
@@ -14,8 +14,8 @@ const doctorCheckSchema = z.object({
14
14
  const doctorHarnessSchema = z.object({
15
15
  harness: harnessSchema,
16
16
  detected: z.boolean(),
17
- registered: z.enum(["yes", "no", "unknown"]),
18
- auth_mode: z.enum(["api-key", "oauth", "unknown"]),
17
+ registered: z.enum(Registered),
18
+ auth_mode: z.enum(AuthMode),
19
19
  proves_harness_reach: z.boolean(),
20
20
  });
21
21
  // `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
@@ -1,5 +1,15 @@
1
1
  export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw", "opencode"];
2
2
  export type Harness = (typeof ALL_HARNESSES)[number];
3
+ export declare enum Registered {
4
+ Yes = "yes",
5
+ No = "no",
6
+ Unknown = "unknown"
7
+ }
8
+ export declare enum AuthMode {
9
+ ApiKey = "api-key",
10
+ OAuth = "oauth",
11
+ Unknown = "unknown"
12
+ }
3
13
  export declare function harnessConfigPath(harness: Harness): string;
4
14
  /** For reason strings that name a location; keeps them in sync with the table above. */
5
15
  export declare function harnessDisplayPath(harness: Harness): string;
@@ -9,6 +9,19 @@ export const ALL_HARNESSES = [
9
9
  "openclaw",
10
10
  "opencode",
11
11
  ];
12
+ // Values are the wire contract; the report schema derives from these.
13
+ export var Registered;
14
+ (function (Registered) {
15
+ Registered["Yes"] = "yes";
16
+ Registered["No"] = "no";
17
+ Registered["Unknown"] = "unknown";
18
+ })(Registered || (Registered = {}));
19
+ export var AuthMode;
20
+ (function (AuthMode) {
21
+ AuthMode["ApiKey"] = "api-key";
22
+ AuthMode["OAuth"] = "oauth";
23
+ AuthMode["Unknown"] = "unknown";
24
+ })(AuthMode || (AuthMode = {}));
12
25
  // Presence detection only — dir existence means "installed", nothing more.
13
26
  const CONFIG_DIRS = {
14
27
  "claude-code": ".claude",
@@ -0,0 +1,15 @@
1
+ /** Where this CLI is installed: `prefix` for npm, `nodeModules` for reading the version back. */
2
+ export type InstallRoot = {
3
+ prefix: string;
4
+ nodeModules: string;
5
+ };
6
+ /**
7
+ * The prefix to upgrade in place, or null when the layout is not npm's.
8
+ *
9
+ * Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
10
+ * installing into their trees would report a success the PATH binary never sees.
11
+ */
12
+ export declare function resolveInstallRoot(packageRoot: string, platform: typeof process.platform, onDisk?: (candidate: string) => boolean): InstallRoot | null;
13
+ export declare function installRoot(): InstallRoot | null;
14
+ /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
15
+ export declare function readInstalledVersion(nodeModules: string): string | null;
@@ -0,0 +1,49 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { TINYFISH_CLI_PACKAGE } from "./constants.js";
5
+ function isDirectory(candidate) {
6
+ try {
7
+ return fs.statSync(candidate).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * The prefix to upgrade in place, or null when the layout is not npm's.
15
+ *
16
+ * Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
17
+ * installing into their trees would report a success the PATH binary never sees.
18
+ */
19
+ export function resolveInstallRoot(packageRoot, platform, onDisk = isDirectory) {
20
+ const p = platform === "win32" ? path.win32 : path.posix;
21
+ // npx's win32 cache uses the same bare layout as a global install.
22
+ if (packageRoot.split(p.sep).includes("_npx"))
23
+ return null;
24
+ const nodeModules = p.resolve(packageRoot, "..", "..");
25
+ if (p.basename(nodeModules) !== "node_modules")
26
+ return null;
27
+ const container = p.dirname(nodeModules);
28
+ // Only win32 puts node_modules directly under the prefix.
29
+ if (platform !== "win32" && p.basename(container) !== "lib")
30
+ return null;
31
+ if (!onDisk(nodeModules))
32
+ return null;
33
+ return { prefix: platform === "win32" ? container : p.dirname(container), nodeModules };
34
+ }
35
+ export function installRoot() {
36
+ // Two levels up from dist/lib/ is the package root, and tsc mirrors src/ into dist/.
37
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
38
+ return resolveInstallRoot(packageRoot, process.platform);
39
+ }
40
+ /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
41
+ export function readInstalledVersion(nodeModules) {
42
+ try {
43
+ const manifest = fs.readFileSync(path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split("/"), "package.json"), "utf8");
44
+ return JSON.parse(manifest).version ?? null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
@@ -1,6 +1,4 @@
1
- import { type Harness } from "./harness-detect.js";
2
- export type Registered = "yes" | "no" | "unknown";
3
- export type AuthMode = "api-key" | "oauth" | "unknown";
1
+ import { AuthMode, type Harness, Registered } from "./harness-detect.js";
4
2
  export interface RegistrationStatus {
5
3
  harness: Harness;
6
4
  detected: boolean;
@@ -9,6 +7,8 @@ export interface RegistrationStatus {
9
7
  connectedBefore: boolean;
10
8
  /** The endpoint the harness is actually pointed at, when the probe exposes it. */
11
9
  registeredUrl?: string;
10
+ /** For doctor to verify; never for the report. */
11
+ apiKey?: string;
12
12
  reason?: string;
13
13
  }
14
14
  export declare function codexOauthCompleted(): boolean | undefined;
@@ -2,10 +2,10 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import spawn from "cross-spawn";
4
4
  import { z } from "zod";
5
- import { loadConfig } from "./auth.js";
5
+ import { hasKeyPrefix, loadConfig } from "./auth.js";
6
6
  import { errLine } from "./output.js";
7
7
  import { readCursorTinyfishEntry } from "./cursor-config.js";
8
- import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
8
+ import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from "./harness-detect.js";
9
9
  // 6s tripped on a cold `hermes mcp list`, measured at 6.34s.
10
10
  const PROBE_TIMEOUT_MS = 15_000;
11
11
  // A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
@@ -24,6 +24,12 @@ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid
24
24
  // `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
25
25
  const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
26
26
  const API_KEY_HEADER = /x-api-key/i;
27
+ // `claude mcp get` prints the value; `mcp add` masks it.
28
+ const API_KEY_HEADER_VALUE = /^\s*X-API-Key:\s*(\S+)\s*$/im;
29
+ // Charset is the redaction guard: `***`, `[REDACTED]`, dotted masks.
30
+ function extractableKey(value) {
31
+ return value && hasKeyPrefix(value) && /^[A-Za-z0-9_-]+$/.test(value) ? value : undefined;
32
+ }
27
33
  // OpenCode colours its list even through a pipe, putting `\x1b[34m` between the line start and
28
34
  // the server name, so an unstripped probe reads a registered harness as absent.
29
35
  // eslint-disable-next-line no-control-regex
@@ -84,8 +90,8 @@ function fromPluginRegistration(command) {
84
90
  return undefined;
85
91
  // `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
86
92
  return {
87
- registered: "yes",
88
- authMode: "unknown",
93
+ registered: Registered.Yes,
94
+ authMode: AuthMode.Unknown,
89
95
  registeredUrl: match[2],
90
96
  reason: `registered as ${match[1]}, which \`mcp get tinyfish\` does not resolve`,
91
97
  };
@@ -94,13 +100,13 @@ function fromPluginRegistration(command) {
94
100
  function fromMcpGet(command, keyAuthPattern) {
95
101
  const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
96
102
  if (probe.outcome === "unavailable") {
97
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
103
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
98
104
  }
99
105
  if (probe.exitCode !== 0) {
100
106
  if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
101
107
  return {
102
- registered: "unknown",
103
- authMode: "unknown",
108
+ registered: Registered.Unknown,
109
+ authMode: AuthMode.Unknown,
104
110
  reason: `\`${command} mcp get\` is unsupported by this version`,
105
111
  };
106
112
  }
@@ -111,20 +117,22 @@ function fromMcpGet(command, keyAuthPattern) {
111
117
  // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
112
118
  if (!NO_SUCH_SERVER.test(probe.output)) {
113
119
  return {
114
- registered: "unknown",
115
- authMode: "unknown",
120
+ registered: Registered.Unknown,
121
+ authMode: AuthMode.Unknown,
116
122
  reason: `\`${command} mcp get\` failed without reporting the server as absent`,
117
123
  };
118
124
  }
119
- return { registered: "no", authMode: "unknown" };
125
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
120
126
  }
121
127
  // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
122
128
  // of the pattern is `unknown`, never proof of OAuth.
123
129
  const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
130
+ const key = extractableKey(API_KEY_HEADER_VALUE.exec(probe.output)?.[1]);
124
131
  return {
125
- registered: "yes",
126
- authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
132
+ registered: Registered.Yes,
133
+ authMode: keyAuthPattern.test(probe.output) ? AuthMode.ApiKey : AuthMode.Unknown,
127
134
  ...(url ? { registeredUrl: url } : {}),
135
+ ...(key ? { apiKey: key } : {}),
128
136
  };
129
137
  }
130
138
  // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
@@ -170,20 +178,20 @@ export function codexOauthCompleted() {
170
178
  function probeCodex() {
171
179
  const read = readCodexEntry();
172
180
  if ("reason" in read)
173
- return { registered: "unknown", authMode: "unknown", reason: read.reason };
181
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: read.reason };
174
182
  const entry = read.entry;
175
183
  if (!entry)
176
- return { registered: "no", authMode: "unknown" };
184
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
177
185
  const codexUrl = entry.transport?.url;
178
186
  return {
179
- registered: "yes",
187
+ registered: Registered.Yes,
180
188
  // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
181
189
  // the whole payload reports api-key for TinyFish because some other server carries a key.
182
190
  authMode: entry.auth_status === "o_auth"
183
- ? "oauth"
191
+ ? AuthMode.OAuth
184
192
  : entry.transport?.bearer_token_env_var
185
- ? "api-key"
186
- : "unknown",
193
+ ? AuthMode.ApiKey
194
+ : AuthMode.Unknown,
187
195
  ...(codexUrl ? { registeredUrl: codexUrl } : {}),
188
196
  };
189
197
  }
@@ -191,17 +199,19 @@ function probeCursor() {
191
199
  const entry = readCursorTinyfishEntry();
192
200
  if (entry.error) {
193
201
  return {
194
- registered: "unknown",
195
- authMode: "unknown",
202
+ registered: Registered.Unknown,
203
+ authMode: AuthMode.Unknown,
196
204
  reason: "mcp.json exists but could not be read or parsed",
197
205
  };
198
206
  }
199
207
  if (!entry.present)
200
- return { registered: "no", authMode: "unknown" };
208
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
209
+ const key = extractableKey(entry.apiKey);
201
210
  return {
202
- registered: "yes",
203
- authMode: entry.hasApiKeyHeader ? "api-key" : "unknown",
211
+ registered: Registered.Yes,
212
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
204
213
  ...(entry.url ? { registeredUrl: entry.url } : {}),
214
+ ...(key ? { apiKey: key } : {}),
205
215
  };
206
216
  }
207
217
  // Both list commands print this header in every state, including "no servers configured".
@@ -213,31 +223,31 @@ const OPENCODE_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
213
223
  function fromMcpList(command, output, registeredMode, urlPattern) {
214
224
  if (LISTED_AS_TINYFISH.test(output)) {
215
225
  const url = urlPattern?.exec(output)?.[1];
216
- return { registered: "yes", authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
226
+ return { registered: Registered.Yes, authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
217
227
  }
218
228
  if (!MCP_LIST_HEADER.test(output)) {
219
229
  return {
220
- registered: "unknown",
221
- authMode: "unknown",
230
+ registered: Registered.Unknown,
231
+ authMode: AuthMode.Unknown,
222
232
  reason: `could not interpret \`${command} mcp list\` output`,
223
233
  };
224
234
  }
225
- return { registered: "no", authMode: "unknown" };
235
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
226
236
  }
227
- // Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
237
+ // connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
228
238
  function probeHermes() {
229
239
  const probe = runProbe("hermes", ["mcp", "list"]);
230
240
  if (probe.outcome === "unavailable") {
231
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
241
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
232
242
  }
233
243
  if (probe.exitCode !== 0) {
234
244
  return {
235
- registered: "unknown",
236
- authMode: "unknown",
245
+ registered: Registered.Unknown,
246
+ authMode: AuthMode.Unknown,
237
247
  reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
238
248
  };
239
249
  }
240
- return fromMcpList("hermes", probe.output, "oauth");
250
+ return fromMcpList("hermes", probe.output, AuthMode.OAuth);
241
251
  }
242
252
  const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
243
253
  // OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
@@ -249,30 +259,30 @@ function probeOpenClaw() {
249
259
  }
250
260
  catch {
251
261
  return {
252
- registered: "unknown",
253
- authMode: "unknown",
262
+ registered: Registered.Unknown,
263
+ authMode: AuthMode.Unknown,
254
264
  reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
255
265
  };
256
266
  }
257
267
  // A bare substring also matched a skill merely named `not-tinyfish-thing`.
258
268
  return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
259
- ? { registered: "yes", authMode: "api-key" }
260
- : { registered: "no", authMode: "unknown" };
269
+ ? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
270
+ : { registered: Registered.No, authMode: AuthMode.Unknown };
261
271
  }
262
- // Connect registers with `mcp add --url` then `mcp auth`, so there is no key path today.
272
+ // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
263
273
  function probeOpencode() {
264
274
  const probe = runProbe("opencode", ["mcp", "list"]);
265
275
  if (probe.outcome === "unavailable") {
266
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
276
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
267
277
  }
268
278
  if (probe.exitCode !== 0) {
269
279
  return {
270
- registered: "unknown",
271
- authMode: "unknown",
280
+ registered: Registered.Unknown,
281
+ authMode: AuthMode.Unknown,
272
282
  reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
273
283
  };
274
284
  }
275
- return fromMcpList("opencode", probe.output, "oauth", OPENCODE_ROW_URL);
285
+ return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
276
286
  }
277
287
  const PROBES = {
278
288
  "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
@@ -295,7 +305,7 @@ export function detectRegistrations(harnesses) {
295
305
  };
296
306
  // Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
297
307
  if (!detection.detected) {
298
- return { ...base, registered: "no", authMode: "unknown" };
308
+ return { ...base, registered: Registered.No, authMode: AuthMode.Unknown };
299
309
  }
300
310
  try {
301
311
  return { ...base, ...PROBES[detection.harness]() };
@@ -308,8 +318,8 @@ export function detectRegistrations(harnesses) {
308
318
  }
309
319
  return {
310
320
  ...base,
311
- registered: "unknown",
312
- authMode: "unknown",
321
+ registered: Registered.Unknown,
322
+ authMode: AuthMode.Unknown,
313
323
  reason: "the probe failed unexpectedly",
314
324
  };
315
325
  }
@@ -5,6 +5,8 @@ export interface VerifyResult {
5
5
  reason?: string;
6
6
  /** Authored summary of `reason`, safe to publish. Additive: `connect` still prints `reason`. */
7
7
  code?: string;
8
+ /** Lets doctor tell 401 from 403 without matching prose. */
9
+ status?: number;
8
10
  }
9
11
  /** Reachability check. Verify failure is a warning, never install failure. */
10
12
  export declare function verifyMcpHealth(mcpUrl: string): Promise<VerifyResult>;
@@ -74,6 +74,7 @@ export async function verifyMcpAuth(apiKey) {
74
74
  ok: false,
75
75
  reason: e instanceof Error ? e.message : String(e),
76
76
  code: authCode(e),
77
+ ...(e instanceof ApiError ? { status: e.status } : {}),
77
78
  };
78
79
  }
79
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.20.1",
3
+ "version": "0.21.1-next.194",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {