@tiny-fish/cli 0.20.2-next.190 → 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.
@@ -2,9 +2,10 @@ import spawn from "cross-spawn";
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
3
3
  import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
4
4
  import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
5
- import { ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
5
+ import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
7
7
  import { runConnectAll } from "../lib/connect-all.js";
8
+ import { AuthMode } from "../lib/harness-detect.js";
8
9
  import { detectHumanInitiated } from "../lib/harness.js";
9
10
  import { emitNotice } from "../lib/notice.js";
10
11
  import { errLine } from "../lib/output.js";
@@ -22,12 +23,7 @@ function removeExistingRegistration(client, removal) {
22
23
  const details = result.stderr?.trim() || result.error?.message || "unknown error";
23
24
  if (/No MCP server named "tinyfish"/i.test(details))
24
25
  return;
25
- if (result.error) {
26
- throw new Error(`Could not remove existing ${removal.label}: ${details}`, {
27
- cause: result.error,
28
- });
29
- }
30
- throw new Error(`Could not remove existing ${removal.label}: ${details}`);
26
+ throw spawnStepError(`Could not remove existing ${removal.label}: ${details}`, result);
31
27
  }
32
28
  /** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
33
29
  function exportApiKey(apiKey, displayName) {
@@ -49,7 +45,8 @@ async function connectNativeMcpClient(client, options) {
49
45
  const state = { stage: "prerequisite_check", settled: false };
50
46
  await runGuarded(state, telemetry, () => {
51
47
  telemetry.track("started");
52
- const { optionalSupported, keyAuthSupported } = requireCommandSupport(client);
48
+ const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
49
+ telemetry.setHarnessVersion(harnessVersion);
53
50
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
54
51
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
55
52
  const storedKey = validatedApiKey(options.apiKey);
@@ -92,10 +89,7 @@ async function connectNativeMcpClient(client, options) {
92
89
  stdio: "inherit",
93
90
  });
94
91
  if (addResult.error || addResult.status !== 0) {
95
- throwIfInterrupted(addResult);
96
- throw new Error(`Could not add TinyFish to ${client.displayName}`, {
97
- cause: addResult.error,
98
- });
92
+ throw spawnStepError(`Could not add TinyFish to ${client.displayName}`, addResult);
99
93
  }
100
94
  telemetry.track("checkpoint", { phase: "registered" });
101
95
  // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
@@ -121,19 +115,16 @@ async function connectNativeMcpClient(client, options) {
121
115
  stdio: "inherit",
122
116
  });
123
117
  if (loginResult.error || loginResult.status !== 0) {
124
- throwIfInterrupted(loginResult);
125
- throw new Error(`Could not authenticate TinyFish in ${client.displayName}`, {
126
- cause: loginResult.error,
127
- });
118
+ throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
128
119
  }
129
120
  telemetry.track("checkpoint", { phase: "oauth_done" });
130
121
  }
131
122
  const signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
132
123
  const authMode = useKeyAuth
133
- ? "api-key"
124
+ ? AuthMode.ApiKey
134
125
  : signInDeferred
135
126
  ? "deferred"
136
- : "oauth";
127
+ : AuthMode.OAuth;
137
128
  state.authMode = authMode;
138
129
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
139
130
  settle(state, telemetry, "completed", { authMode });
@@ -181,7 +172,10 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
181
172
  /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
182
173
  function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
183
174
  if (!(error instanceof ConnectInterruptedError)) {
184
- telemetry.track("post_install_failed", { failedStage: state.stage });
175
+ telemetry.track("post_install_failed", {
176
+ failedStage: state.stage,
177
+ ...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
178
+ });
185
179
  errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
186
180
  `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
187
181
  }
@@ -209,7 +203,7 @@ export async function connectOpenClaw(options) {
209
203
  };
210
204
  await runGuarded(state, telemetry, () => {
211
205
  telemetry.track("started");
212
- requireCommandSupport(OPENCLAW);
206
+ telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
213
207
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
214
208
  if (options.installCli !== false) {
215
209
  state.stage = "cli_install";
@@ -223,10 +217,7 @@ export async function connectOpenClaw(options) {
223
217
  timeout: SKILL_INSTALL_TIMEOUT_MS,
224
218
  });
225
219
  if (skillInstallResult.error || skillInstallResult.status !== 0) {
226
- throwIfInterrupted(skillInstallResult);
227
- throw new Error("Could not install the TinyFish skill in OpenClaw", {
228
- cause: skillInstallResult.error,
229
- });
220
+ throw spawnStepError("Could not install the TinyFish skill in OpenClaw", skillInstallResult);
230
221
  }
231
222
  telemetry.track("checkpoint", { phase: "skill_installed" });
232
223
  // Stores the key before probing, so a passed --api-key never reaches an interactive login.
@@ -280,8 +271,8 @@ export async function connectCursor(options) {
280
271
  // Attempt id rides telemetry only; persisting it would break idempotency.
281
272
  const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
282
273
  if (result.status === "corrupt_skip") {
283
- throw new Error(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
284
- `Fix the file, then re-run: tinyfish connect cursor`);
274
+ throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
275
+ `Fix the file, then re-run: tinyfish connect cursor`, "invalid_config");
285
276
  }
286
277
  if (result.backupPath) {
287
278
  errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
@@ -308,7 +299,7 @@ export async function connectCursor(options) {
308
299
  else {
309
300
  errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
310
301
  }
311
- settle(state, telemetry, "completed", { authMode: resolvedKey ? "api-key" : "deferred" });
302
+ settle(state, telemetry, "completed", { authMode: resolvedKey ? AuthMode.ApiKey : "deferred" });
312
303
  });
313
304
  }
314
305
  /** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
@@ -2,20 +2,22 @@ import spawn from "cross-spawn";
2
2
  import { apiKeyStatus } from "../lib/auth.js";
3
3
  import { CLI_VERSION } from "../lib/constants.js";
4
4
  import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
5
- import { ALL_HARNESSES } from "../lib/harness-detect.js";
5
+ import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
6
6
  import { detectHumanInitiated } from "../lib/harness.js";
7
7
  import { err, errLine, out, outLine } from "../lib/output.js";
8
8
  import { detectRegistrations } from "../lib/registration-detect.js";
9
9
  import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
10
10
  import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
11
11
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
12
- function provesHarnessReach(status, authCallPassed, healthOk, mcpUrl) {
12
+ function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
13
13
  // An endpoint doctor could not reach proves nothing about a harness pointed at it.
14
14
  if (!healthOk)
15
15
  return false;
16
16
  if (pointsElsewhere(status.registeredUrl, mcpUrl))
17
17
  return false;
18
- return status.registered === "yes" && status.authMode === "api-key" && authCallPassed;
18
+ return (status.registered === Registered.Yes &&
19
+ status.authMode === AuthMode.ApiKey &&
20
+ keyAuth?.ok === true);
19
21
  }
20
22
  // Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
21
23
  function checkCliVersion() {
@@ -55,13 +57,31 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
55
57
  return "an unparseable endpoint";
56
58
  }
57
59
  }
58
- function registrationVerdict(status, mcpUrl) {
60
+ function keyedVerdict(keyAuth) {
61
+ if (!keyAuth)
62
+ return { status: "warn", detail: "registered, API key present but unverified" };
63
+ if (keyAuth.ok) {
64
+ return {
65
+ status: "pass",
66
+ detail: "registered, auth mode api-key (key verified against the TinyFish API)",
67
+ };
68
+ }
69
+ if (keyAuth.status === 401) {
70
+ return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
71
+ }
72
+ // Only 401 blames the key; connect would loop on the rest.
73
+ if (keyAuth.status === 403) {
74
+ return { status: "warn", detail: "registered, but its API key is not allowed to list runs" };
75
+ }
76
+ return { status: "warn", detail: "registered, key could not be verified" };
77
+ }
78
+ function registrationVerdict(status, mcpUrl, keyAuth) {
59
79
  if (!status.detected)
60
80
  return { status: "skip", detail: "harness not installed" };
61
- if (status.registered === "unknown") {
81
+ if (status.registered === Registered.Unknown) {
62
82
  return { status: "warn", detail: status.reason ?? "could not determine registration" };
63
83
  }
64
- if (status.registered === "yes") {
84
+ if (status.registered === Registered.Yes) {
65
85
  // A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
66
86
  const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
67
87
  if (elsewhere) {
@@ -70,6 +90,9 @@ function registrationVerdict(status, mcpUrl) {
70
90
  detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
71
91
  };
72
92
  }
93
+ // Widening past api-key would warn every healthy oauth install.
94
+ if (status.authMode === AuthMode.ApiKey)
95
+ return keyedVerdict(keyAuth);
73
96
  // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
74
97
  const because = status.reason ? ` (${status.reason})` : "";
75
98
  return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
@@ -79,12 +102,12 @@ function registrationVerdict(status, mcpUrl) {
79
102
  ? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
80
103
  : { status: "warn", detail: "installed but TinyFish was never connected" };
81
104
  }
82
- function checkRegistration(status, mcpUrl) {
105
+ function checkRegistration(status, mcpUrl, keyAuth) {
83
106
  return {
84
107
  id: "harness-registration",
85
108
  title: `${status.harness} registration`,
86
109
  harness: status.harness,
87
- ...registrationVerdict(status, mcpUrl),
110
+ ...registrationVerdict(status, mcpUrl, keyAuth),
88
111
  };
89
112
  }
90
113
  function checkCredential() {
@@ -98,8 +121,8 @@ function checkCredential() {
98
121
  };
99
122
  return "key" in resolved ? { check, key: resolved.key } : { check };
100
123
  }
101
- async function checkAuthCall(key) {
102
- if (!key) {
124
+ function checkAuthCall(auth) {
125
+ if (!auth) {
103
126
  return {
104
127
  id: "cli-auth-call",
105
128
  title: "Authenticated call",
@@ -108,7 +131,6 @@ async function checkAuthCall(key) {
108
131
  harness: null,
109
132
  };
110
133
  }
111
- const auth = await verifyMcpAuth(key);
112
134
  return {
113
135
  id: "cli-auth-call",
114
136
  title: "Authenticated call",
@@ -117,32 +139,37 @@ async function checkAuthCall(key) {
117
139
  harness: null,
118
140
  };
119
141
  }
142
+ // `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
143
+ function isDefaultEndpoint(mcpUrl) {
144
+ try {
145
+ return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
152
+ if (!isDefaultEndpoint(mcpUrl))
153
+ return undefined;
154
+ // `pointsElsewhere` already fails; a second reason only misleads.
155
+ if (pointsElsewhere(status.registeredUrl, mcpUrl))
156
+ return undefined;
157
+ // OpenClaw's skill shells this CLI, so its verdict stands in.
158
+ if (status.harness === "openclaw")
159
+ return cliAuth;
160
+ if (!status.apiKey)
161
+ return undefined;
162
+ if (status.apiKey === cliKey && cliAuth)
163
+ return cliAuth;
164
+ return verifyMcpAuth(status.apiKey);
165
+ }
120
166
  // Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
121
167
  const UNATTENDED_SAFE = new Set(["cursor"]);
122
168
  function repairsFor(checks, statuses) {
123
169
  const repairs = [];
124
- const credentialResolves = checks.some((c) => c.id === "cli-credential" && c.status === "pass");
125
- for (const status of statuses) {
126
- // `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
127
- // A registered-but-misaimed entry does earn one, since connect rewrites the URL.
128
- const misaimed = checks.some((c) => c.harness === status.harness && c.status === "fail");
129
- if (!status.detected || (status.registered !== "no" && !misaimed))
130
- continue;
131
- repairs.push({
132
- for: status.registered === "yes"
133
- ? `${status.harness}-points-elsewhere`
134
- : status.connectedBefore
135
- ? `${status.harness}-registration-lost`
136
- : `${status.harness}-not-connected`,
137
- action: "connect",
138
- harness: status.harness,
139
- command: `tinyfish connect ${status.harness}`,
140
- // Without a credential even Cursor's repair shells an interactive `auth login`.
141
- unattended_safe: UNATTENDED_SAFE.has(status.harness) && credentialResolves,
142
- });
143
- }
144
170
  // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
145
171
  const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
172
+ // connect writes the stored key, so replace a dead one first.
146
173
  if (credentialBroken) {
147
174
  repairs.push({
148
175
  for: "cli-credential-invalid",
@@ -152,26 +179,51 @@ function repairsFor(checks, statuses) {
152
179
  unattended_safe: false,
153
180
  });
154
181
  }
182
+ const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
183
+ for (const status of statuses) {
184
+ // `unknown` earns nothing: connect would contradict the check's detail.
185
+ // A registered entry that still fails earns one too.
186
+ const brokenRegistration = checks.some((c) => c.harness === status.harness && c.status === "fail");
187
+ if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
188
+ continue;
189
+ repairs.push({
190
+ for: status.registered === Registered.Yes
191
+ ? `${status.harness}-registration-broken`
192
+ : status.connectedBefore
193
+ ? `${status.harness}-registration-lost`
194
+ : `${status.harness}-not-connected`,
195
+ action: "connect",
196
+ harness: status.harness,
197
+ command: `tinyfish connect ${status.harness}`,
198
+ // A dead well-formed key passes the format check.
199
+ unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
200
+ });
201
+ }
155
202
  return repairs;
156
203
  }
157
204
  export async function runDoctor(options) {
158
205
  const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
159
206
  const credential = checkCredential();
207
+ const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
208
+ const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
209
+ checkConnectivity(options.mcpUrl),
210
+ cliAuth,
211
+ Promise.all(statuses.map((status) => verifyHarnessKey(status, credential.key, cliAuth, options.mcpUrl))),
212
+ ]);
160
213
  const checks = [
161
214
  checkCliVersion(),
162
- await checkConnectivity(options.mcpUrl),
163
- ...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
215
+ connectivity,
216
+ ...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
164
217
  credential.check,
165
- await checkAuthCall(credential.key),
218
+ checkAuthCall(cliAuthResult),
166
219
  ];
167
- const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
168
220
  const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
169
- const harnesses = statuses.map((status) => ({
221
+ const harnesses = statuses.map((status, i) => ({
170
222
  harness: status.harness,
171
223
  detected: status.detected,
172
224
  registered: status.registered,
173
225
  auth_mode: status.authMode,
174
- proves_harness_reach: provesHarnessReach(status, authCallPassed, healthOk, options.mcpUrl),
226
+ proves_harness_reach: provesHarnessReach(status, keyAuths[i], healthOk, options.mcpUrl),
175
227
  }));
176
228
  const body = {
177
229
  schema_version: DOCTOR_SCHEMA_VERSION,
@@ -36,6 +36,8 @@ export declare function persistApiKeyToEnvironment(apiKey: string, options?: Env
36
36
  export declare function clearConfig(): boolean;
37
37
  /** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
38
38
  export declare function isHeaderSafe(key: string): boolean;
39
+ /** No length rule: short real keys must survive. */
40
+ export declare function hasKeyPrefix(key: string): boolean;
39
41
  export declare function validateKeyFormat(key: string): boolean;
40
42
  export declare function maskKey(key: string): string;
41
43
  export type KeySource = "explicit" | "env" | "config" | "none";
package/dist/lib/auth.js CHANGED
@@ -192,8 +192,12 @@ export function isHeaderSafe(key) {
192
192
  }
193
193
  return true;
194
194
  }
195
+ /** No length rule: short real keys must survive. */
196
+ export function hasKeyPrefix(key) {
197
+ return KEY_PREFIXES.some((p) => key.startsWith(p));
198
+ }
195
199
  export function validateKeyFormat(key) {
196
- return isHeaderSafe(key) && KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
200
+ return isHeaderSafe(key) && hasKeyPrefix(key) && key.length > MIN_KEY_LENGTH;
197
201
  }
198
202
  export function maskKey(key) {
199
203
  if (key.length <= 8)
@@ -1,6 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { errLine, sanitizeLine } from "./output.js";
3
- import { throwIfInterrupted } from "./connect-runtime.js";
3
+ import { spawnStepError, } from "./connect-runtime.js";
4
4
  import { codexOauthCompleted } from "./registration-detect.js";
5
5
  const HERMES_SEED_TIMEOUT_MS = 120_000;
6
6
  // An old install is only one reason a flag can go unseen, so no message asserts the cause.
@@ -122,7 +122,7 @@ function launchCodexWalkthrough() {
122
122
  deepLink.searchParams.set("path", process.cwd());
123
123
  const result = openExternalUrl(deepLink.toString());
124
124
  if (result.error || result.status !== 0) {
125
- throw new Error("Could not open Codex", { cause: result.error });
125
+ throw spawnStepError("Could not open Codex", result);
126
126
  }
127
127
  errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
128
128
  }
@@ -156,10 +156,7 @@ function launchHermesWalkthrough() {
156
156
  timeout: HERMES_SEED_TIMEOUT_MS,
157
157
  });
158
158
  if (seedResult.error || seedResult.status !== 0) {
159
- throwIfInterrupted(seedResult);
160
- throw new Error("Could not start the TinyFish walkthrough in Hermes", {
161
- cause: seedResult.error,
162
- });
159
+ throw spawnStepError("Could not start the TinyFish walkthrough in Hermes", seedResult);
163
160
  }
164
161
  // A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
165
162
  const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
@@ -168,12 +165,12 @@ function launchHermesWalkthrough() {
168
165
  }
169
166
  const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
170
167
  if (result.error) {
171
- throw new Error("Could not launch Hermes", { cause: result.error });
168
+ throw spawnStepError("Could not launch Hermes", result);
172
169
  }
173
170
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
174
171
  return;
175
172
  if (result.status !== 0) {
176
- throw new Error(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`);
173
+ throw spawnStepError(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`, result);
177
174
  }
178
175
  }
179
176
  export const HERMES = {
@@ -200,12 +197,12 @@ function launchOpencode() {
200
197
  stdio: "inherit",
201
198
  });
202
199
  if (result.error) {
203
- throw new Error("Could not launch OpenCode", { cause: result.error });
200
+ throw spawnStepError("Could not launch OpenCode", result);
204
201
  }
205
202
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
206
203
  return;
207
204
  if (result.status !== 0) {
208
- throw new Error(`OpenCode exited with status ${result.status ?? "unknown"}`);
205
+ throw spawnStepError(`OpenCode exited with status ${result.status ?? "unknown"}`, result);
209
206
  }
210
207
  }
211
208
  export const OPENCODE = {
@@ -252,12 +249,12 @@ export function launchNativeMcpClient(client) {
252
249
  }
253
250
  const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
254
251
  if (result.error) {
255
- throw new Error(`Could not launch ${client.displayName}`, { cause: result.error });
252
+ throw spawnStepError(`Could not launch ${client.displayName}`, result);
256
253
  }
257
254
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
258
255
  return false;
259
256
  if (result.status !== 0) {
260
- throw new Error(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`);
257
+ throw spawnStepError(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`, result);
261
258
  }
262
259
  return true;
263
260
  }
@@ -267,12 +264,12 @@ export function launchOpenClawWalkthrough() {
267
264
  stdio: "inherit",
268
265
  });
269
266
  if (result.error) {
270
- throw new Error("Could not launch OpenClaw", { cause: result.error });
267
+ throw spawnStepError("Could not launch OpenClaw", result);
271
268
  }
272
269
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
273
270
  return false;
274
271
  if (result.status !== 0) {
275
- throw new Error(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`);
272
+ throw spawnStepError(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`, result);
276
273
  }
277
274
  return true;
278
275
  }
@@ -2,7 +2,7 @@ import * as path from "node:path";
2
2
  import spawn from "cross-spawn";
3
3
  import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
4
4
  import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
5
- import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
5
+ import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
6
6
  import { TINYFISH_CLI_PACKAGE } from "./constants.js";
7
7
  import { installRoot } from "./install-root.js";
8
8
  import { errLine } from "./output.js";
@@ -24,8 +24,7 @@ export function installTinyFishCli() {
24
24
  timeout: SKILL_INSTALL_TIMEOUT_MS,
25
25
  });
26
26
  if (result.error || result.status !== 0) {
27
- throwIfInterrupted(result);
28
- throw new Error("Could not install the TinyFish CLI", { cause: result.error });
27
+ throw spawnStepError("Could not install the TinyFish CLI", result);
29
28
  }
30
29
  }
31
30
  /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
@@ -72,10 +71,7 @@ export function installWebSkill(client) {
72
71
  if (output.trim())
73
72
  errLine(output.trimEnd());
74
73
  if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
75
- throwIfInterrupted(result);
76
- throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
77
- cause: result.error,
78
- });
74
+ throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
79
75
  }
80
76
  }
81
77
  /**
@@ -164,7 +160,7 @@ export function ensureCliAuthenticated(source, apiKey) {
164
160
  const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
165
161
  if (envKey) {
166
162
  if (!validateKeyFormat(envKey)) {
167
- throw new Error("TINYFISH_API_KEY has invalid format");
163
+ throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
168
164
  }
169
165
  // Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
170
166
  writeConfig(envKey);
@@ -176,7 +172,7 @@ export function ensureCliAuthenticated(source, apiKey) {
176
172
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
177
173
  });
178
174
  if (commandNotFound(authStatus.error)) {
179
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: authStatus.error });
175
+ throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
180
176
  }
181
177
  if (authStatus.status === 0)
182
178
  return;
@@ -184,17 +180,16 @@ export function ensureCliAuthenticated(source, apiKey) {
184
180
  // "not signed in" and drop the user into an interactive login they never asked for.
185
181
  throwIfInterrupted(authStatus);
186
182
  if (authStatus.error) {
187
- throw new Error("Could not read TinyFish CLI auth status", { cause: authStatus.error });
183
+ throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
188
184
  }
189
185
  errLine("Signing in to the TinyFish CLI...");
190
186
  const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
191
187
  stdio: "inherit",
192
188
  });
193
189
  if (commandNotFound(loginResult.error)) {
194
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
190
+ throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
195
191
  }
196
192
  if (loginResult.error || loginResult.status !== 0) {
197
- throwIfInterrupted(loginResult);
198
- throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
193
+ throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
199
194
  }
200
195
  }
@@ -1,18 +1,35 @@
1
- import { type AuthMode } from "./registration-detect.js";
1
+ import { AuthMode } from "./harness-detect.js";
2
2
  export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
3
3
  export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
4
4
  type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
5
5
  type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
6
- type ConnectFailureReason = "harness_not_installed" | "harness_command_unsupported" | "harness_too_old";
7
- export type ConnectAuthMode = Extract<AuthMode, "oauth" | "api-key"> | "deferred";
6
+ export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
7
+ type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
8
+ export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
8
9
  type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
9
10
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
10
11
  export declare class ConnectInterruptedError extends Error {
11
12
  }
13
+ /** A classified step failure; runGuarded forwards the reason. */
14
+ export declare class ConnectStepError extends Error {
15
+ readonly failureReason: ConnectFailureReason;
16
+ readonly harnessVersion?: string;
17
+ constructor(message: string, failureReason: ConnectFailureReason, opts?: {
18
+ harnessVersion?: string;
19
+ cause?: unknown;
20
+ });
21
+ }
12
22
  export declare function throwIfInterrupted(result: {
13
23
  signal?: string | null;
14
24
  error?: Error;
15
25
  }): void;
26
+ interface SpawnStepResult {
27
+ status?: number | null;
28
+ signal?: string | null;
29
+ error?: Error;
30
+ }
31
+ /** Classifies a spawn failure; interrupts throw instead. */
32
+ export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
16
33
  export declare function commandNotFound(error: unknown): boolean;
17
34
  export interface ConnectRunState {
18
35
  stage: ConnectFailureStage;
@@ -34,6 +51,7 @@ interface ConnectStageDetail {
34
51
  export interface ConnectTelemetry {
35
52
  attemptId: string;
36
53
  track(stage: ConnectStage, detail?: ConnectStageDetail): void;
54
+ setHarnessVersion(version?: string): void;
37
55
  flush(): Promise<void>;
38
56
  }
39
57
  /** Terminal events exactly once, whichever of flow or signal handler wins. */
@@ -57,6 +75,7 @@ export interface SupportedCommand {
57
75
  export declare function requireCommandSupport(client: SupportedCommand): {
58
76
  optionalSupported: boolean;
59
77
  keyAuthSupported: boolean;
78
+ harnessVersion?: string;
60
79
  };
61
80
  export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
62
81
  apiKey?: string;
@@ -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,7 +115,14 @@ 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 ?? ""}`);
@@ -105,7 +139,7 @@ export function requireCommandSupport(client) {
105
139
  harnessVersion: probeHarnessVersion(client.command),
106
140
  });
107
141
  }
108
- return { optionalSupported, keyAuthSupported };
142
+ return { optionalSupported, keyAuthSupported, harnessVersion: probeHarnessVersion(client.command) };
109
143
  }
110
144
  /** Best-effort; the server rejects non-printable characters and >32 chars. */
111
145
  function probeHarnessVersion(command) {
@@ -115,7 +149,9 @@ function probeHarnessVersion(command) {
115
149
  });
116
150
  if (result.error || result.status !== 0)
117
151
  return undefined;
152
+ // First line only: sanitizeLine keeps newlines, so stripping splices lines.
118
153
  const version = sanitizeLine(result.stdout ?? "")
154
+ .split("\n")[0]
119
155
  .replace(/[^\x20-\x7E]/g, "")
120
156
  .trim();
121
157
  return version ? version.slice(0, 32) : undefined;
@@ -140,6 +176,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
140
176
  const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
141
177
  const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
142
178
  const pending = [];
179
+ let lastTrackAt;
180
+ let harnessVersion;
143
181
  async function deliver(body) {
144
182
  // Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
145
183
  if (telemetryDisabled())
@@ -156,7 +194,14 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
156
194
  return {
157
195
  attemptId,
158
196
  // Fire-and-forget; awaiting each event stalls setup when telemetry down.
197
+ setHarnessVersion(version) {
198
+ harnessVersion = version;
199
+ },
159
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;
160
205
  // Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
161
206
  // replace the error the flow is already reporting.
162
207
  pending.push(deliver(JSON.stringify({
@@ -166,9 +211,10 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
166
211
  failed_stage: detail?.failedStage,
167
212
  phase: detail?.phase,
168
213
  failure_reason: detail?.failureReason,
169
- harness_version: detail?.harnessVersion,
214
+ harness_version: detail?.harnessVersion ?? harnessVersion,
170
215
  auth_mode: detail?.authMode,
171
216
  harness_degraded: detail?.harnessDegraded,
217
+ stage_duration_ms: stageDurationMs,
172
218
  runtime_platform: process.platform,
173
219
  node_version: process.version,
174
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",
@@ -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
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
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, "unknown", 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.2-next.190",
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": {