@tiny-fish/cli 0.20.2-next.190 → 0.21.1-next.195

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,25 @@ 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
+ if (status.registered !== Registered.Yes)
19
+ return false;
20
+ if (status.connected === true)
21
+ return true;
22
+ // The harness's own key must have verified; the CLI's verdict says nothing about it.
23
+ return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
19
24
  }
20
25
  // Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
21
26
  function checkCliVersion() {
@@ -55,13 +60,41 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
55
60
  return "an unparseable endpoint";
56
61
  }
57
62
  }
58
- function registrationVerdict(status, mcpUrl) {
63
+ function keyedVerdict(status, keyAuth) {
64
+ if (!keyAuth) {
65
+ // A bare warn beside exit 0 reads as a pass.
66
+ const because = status.keyReason ?? "its key is not readable here";
67
+ return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
68
+ }
69
+ if (keyAuth.ok) {
70
+ return {
71
+ status: "pass",
72
+ detail: "registered, auth mode api-key (key verified against the TinyFish API)",
73
+ };
74
+ }
75
+ if (keyAuth.status === 401) {
76
+ // Resolved in doctor's shell, not codex's, so a 401 proves nothing.
77
+ if (status.keyIsIndirect) {
78
+ return {
79
+ status: "warn",
80
+ detail: "registered, but the key doctor resolved for it is rejected",
81
+ };
82
+ }
83
+ return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
84
+ }
85
+ // Only 401 blames the key; connect would loop on the rest.
86
+ if (keyAuth.status === 403) {
87
+ return { status: "warn", detail: "registered, but its API key is not allowed to list runs" };
88
+ }
89
+ return { status: "warn", detail: "registered, key could not be verified" };
90
+ }
91
+ function registrationVerdict(status, mcpUrl, keyAuth) {
59
92
  if (!status.detected)
60
93
  return { status: "skip", detail: "harness not installed" };
61
- if (status.registered === "unknown") {
94
+ if (status.registered === Registered.Unknown) {
62
95
  return { status: "warn", detail: status.reason ?? "could not determine registration" };
63
96
  }
64
- if (status.registered === "yes") {
97
+ if (status.registered === Registered.Yes) {
65
98
  // A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
66
99
  const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
67
100
  if (elsewhere) {
@@ -70,6 +103,20 @@ function registrationVerdict(status, mcpUrl) {
70
103
  detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
71
104
  };
72
105
  }
106
+ // Ordered before the key check: the harness's own verdict wins.
107
+ if (status.connected === false) {
108
+ return {
109
+ status: "warn",
110
+ detail: "registered, but the harness reports it cannot reach TinyFish; sign in again there",
111
+ };
112
+ }
113
+ // Symmetric with provesHarnessReach, which already reads a handshake as decisive.
114
+ if (status.connected === true) {
115
+ return { status: "pass", detail: "registered, and the harness reports it reaches TinyFish" };
116
+ }
117
+ // Widening past api-key would warn every healthy oauth install.
118
+ if (status.authMode === AuthMode.ApiKey)
119
+ return keyedVerdict(status, keyAuth);
73
120
  // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
74
121
  const because = status.reason ? ` (${status.reason})` : "";
75
122
  return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
@@ -79,12 +126,12 @@ function registrationVerdict(status, mcpUrl) {
79
126
  ? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
80
127
  : { status: "warn", detail: "installed but TinyFish was never connected" };
81
128
  }
82
- function checkRegistration(status, mcpUrl) {
129
+ function checkRegistration(status, mcpUrl, keyAuth) {
83
130
  return {
84
131
  id: "harness-registration",
85
132
  title: `${status.harness} registration`,
86
133
  harness: status.harness,
87
- ...registrationVerdict(status, mcpUrl),
134
+ ...registrationVerdict(status, mcpUrl, keyAuth),
88
135
  };
89
136
  }
90
137
  function checkCredential() {
@@ -98,8 +145,8 @@ function checkCredential() {
98
145
  };
99
146
  return "key" in resolved ? { check, key: resolved.key } : { check };
100
147
  }
101
- async function checkAuthCall(key) {
102
- if (!key) {
148
+ function checkAuthCall(auth) {
149
+ if (!auth) {
103
150
  return {
104
151
  id: "cli-auth-call",
105
152
  title: "Authenticated call",
@@ -108,7 +155,6 @@ async function checkAuthCall(key) {
108
155
  harness: null,
109
156
  };
110
157
  }
111
- const auth = await verifyMcpAuth(key);
112
158
  return {
113
159
  id: "cli-auth-call",
114
160
  title: "Authenticated call",
@@ -117,32 +163,37 @@ async function checkAuthCall(key) {
117
163
  harness: null,
118
164
  };
119
165
  }
166
+ // `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
167
+ function isDefaultEndpoint(mcpUrl) {
168
+ try {
169
+ return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
176
+ if (!isDefaultEndpoint(mcpUrl))
177
+ return undefined;
178
+ // `pointsElsewhere` already fails; a second reason only misleads.
179
+ if (pointsElsewhere(status.registeredUrl, mcpUrl))
180
+ return undefined;
181
+ // OpenClaw's skill shells this CLI, so its verdict stands in.
182
+ if (status.harness === "openclaw")
183
+ return cliAuth;
184
+ if (!status.apiKey)
185
+ return undefined;
186
+ if (status.apiKey === cliKey && cliAuth)
187
+ return cliAuth;
188
+ return verifyMcpAuth(status.apiKey);
189
+ }
120
190
  // Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
121
191
  const UNATTENDED_SAFE = new Set(["cursor"]);
122
192
  function repairsFor(checks, statuses) {
123
193
  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
194
  // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
145
195
  const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
196
+ // connect writes the stored key, so replace a dead one first.
146
197
  if (credentialBroken) {
147
198
  repairs.push({
148
199
  for: "cli-credential-invalid",
@@ -152,26 +203,51 @@ function repairsFor(checks, statuses) {
152
203
  unattended_safe: false,
153
204
  });
154
205
  }
206
+ const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
207
+ for (const status of statuses) {
208
+ // `unknown` earns nothing: connect would contradict the check's detail.
209
+ // A registered entry that still fails earns one too.
210
+ const brokenRegistration = checks.some((c) => c.harness === status.harness && c.status === "fail");
211
+ if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
212
+ continue;
213
+ repairs.push({
214
+ for: status.registered === Registered.Yes
215
+ ? `${status.harness}-registration-broken`
216
+ : status.connectedBefore
217
+ ? `${status.harness}-registration-lost`
218
+ : `${status.harness}-not-connected`,
219
+ action: "connect",
220
+ harness: status.harness,
221
+ command: `tinyfish connect ${status.harness}`,
222
+ // A dead well-formed key passes the format check.
223
+ unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
224
+ });
225
+ }
155
226
  return repairs;
156
227
  }
157
228
  export async function runDoctor(options) {
158
229
  const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
159
230
  const credential = checkCredential();
231
+ const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
232
+ const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
233
+ checkConnectivity(options.mcpUrl),
234
+ cliAuth,
235
+ Promise.all(statuses.map((status) => verifyHarnessKey(status, credential.key, cliAuth, options.mcpUrl))),
236
+ ]);
160
237
  const checks = [
161
238
  checkCliVersion(),
162
- await checkConnectivity(options.mcpUrl),
163
- ...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
239
+ connectivity,
240
+ ...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
164
241
  credential.check,
165
- await checkAuthCall(credential.key),
242
+ checkAuthCall(cliAuthResult),
166
243
  ];
167
- const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
168
244
  const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
169
- const harnesses = statuses.map((status) => ({
245
+ const harnesses = statuses.map((status, i) => ({
170
246
  harness: status.harness,
171
247
  detected: status.detected,
172
248
  registered: status.registered,
173
249
  auth_mode: status.authMode,
174
- proves_harness_reach: provesHarnessReach(status, authCallPassed, healthOk, options.mcpUrl),
250
+ proves_harness_reach: provesHarnessReach(status, keyAuths[i], healthOk, options.mcpUrl),
175
251
  }));
176
252
  const body = {
177
253
  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,13 @@ 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
+ /** From the environment, not the config, so a 401 is ambiguous. */
13
+ keyIsIndirect?: boolean;
14
+ connected?: boolean;
15
+ /** Why a keyed registration's key could not be read; not `reason`. */
16
+ keyReason?: string;
12
17
  reason?: string;
13
18
  }
14
19
  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
@@ -70,10 +76,23 @@ function runProbe(command, args) {
70
76
  }
71
77
  // A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
72
78
  // The optional prefix must end in `:`, so `not-tinyfish` is not a match.
73
- const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)/m;
79
+ const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
80
+ const SERVER_NAMED_TINYFISH = /^(?:\S*:)?tinyfish$/;
74
81
  // A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
75
82
  // is required to be the first word on its line, after whatever status glyph the CLI prints.
76
83
  const LISTED_AS_TINYFISH = /^[^\w]*tinyfish(?![\w-])/m;
84
+ // Claude Code reports its own live handshake; a config file cannot.
85
+ const MCP_GET_STATUS = /^\s*Status:\s*(.+)$/m;
86
+ // `mcp get <missing>` names every configured server, with no health check.
87
+ const CONFIGURED_SERVERS = /Configured servers:\s*(.+)$/m;
88
+ /** Ordered: "Connected · tools fetch failed" carries both words; the failure wins. */
89
+ function connectionState(status) {
90
+ if (/fail|error|needs auth|not configured/i.test(status))
91
+ return false;
92
+ if (/\bconnected\b/i.test(status))
93
+ return true;
94
+ return undefined;
95
+ }
77
96
  /** The plugin ships its own MCP server, so a working install can carry no `tinyfish` entry. */
78
97
  function fromPluginRegistration(command) {
79
98
  const probe = runProbe(command, ["mcp", "list"]);
@@ -82,28 +101,64 @@ function fromPluginRegistration(command) {
82
101
  const match = MCP_LIST_TINYFISH_LINE.exec(probe.output);
83
102
  if (!match)
84
103
  return undefined;
104
+ const connected = connectionState(match[3] ?? "");
85
105
  // `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
86
106
  return {
87
- registered: "yes",
88
- authMode: "unknown",
107
+ registered: Registered.Yes,
108
+ authMode: AuthMode.Unknown,
89
109
  registeredUrl: match[2],
90
- reason: `registered as ${match[1]}, which \`mcp get tinyfish\` does not resolve`,
110
+ ...(connected === undefined ? {} : { connected }),
111
+ reason: `registered as ${match[1]}`,
112
+ };
113
+ }
114
+ /** Names can contain spaces, so each candidate is matched whole. */
115
+ function tinyfishServerName(output) {
116
+ const listed = CONFIGURED_SERVERS.exec(output)?.[1];
117
+ return listed
118
+ ?.split(",")
119
+ .map((name) => name.trim())
120
+ .find((name) => SERVER_NAMED_TINYFISH.test(name));
121
+ }
122
+ function parseMcpGet(output, keyAuthPattern) {
123
+ // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
124
+ // of the pattern is `unknown`, never proof of OAuth.
125
+ const url = /^\s*URL:\s*(\S+)/m.exec(output)?.[1];
126
+ const key = extractableKey(API_KEY_HEADER_VALUE.exec(output)?.[1]);
127
+ const status = MCP_GET_STATUS.exec(output)?.[1];
128
+ const connected = status === undefined ? undefined : connectionState(status);
129
+ return {
130
+ registered: Registered.Yes,
131
+ authMode: keyAuthPattern.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
132
+ ...(url ? { registeredUrl: url } : {}),
133
+ ...(key ? { apiKey: key } : {}),
134
+ ...(connected === undefined ? {} : { connected }),
91
135
  };
92
136
  }
93
137
  // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
94
138
  function fromMcpGet(command, keyAuthPattern) {
95
139
  const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
96
140
  if (probe.outcome === "unavailable") {
97
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
141
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
98
142
  }
99
143
  if (probe.exitCode !== 0) {
100
144
  if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
101
145
  return {
102
- registered: "unknown",
103
- authMode: "unknown",
146
+ registered: Registered.Unknown,
147
+ authMode: AuthMode.Unknown,
104
148
  reason: `\`${command} mcp get\` is unsupported by this version`,
105
149
  };
106
150
  }
151
+ // The roster is free; `mcp list` health-checks every server.
152
+ const scoped = tinyfishServerName(probe.output);
153
+ if (scoped) {
154
+ const rescoped = runProbe(command, ["mcp", "get", scoped]);
155
+ if (rescoped.outcome === "ran" && rescoped.exitCode === 0) {
156
+ return {
157
+ ...parseMcpGet(rescoped.output, keyAuthPattern),
158
+ reason: `registered as ${scoped}`,
159
+ };
160
+ }
161
+ }
107
162
  // Positive evidence from `mcp list` outranks anything inferred from a failed `mcp get`.
108
163
  const plugin = fromPluginRegistration(command);
109
164
  if (plugin)
@@ -111,21 +166,14 @@ function fromMcpGet(command, keyAuthPattern) {
111
166
  // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
112
167
  if (!NO_SUCH_SERVER.test(probe.output)) {
113
168
  return {
114
- registered: "unknown",
115
- authMode: "unknown",
169
+ registered: Registered.Unknown,
170
+ authMode: AuthMode.Unknown,
116
171
  reason: `\`${command} mcp get\` failed without reporting the server as absent`,
117
172
  };
118
173
  }
119
- return { registered: "no", authMode: "unknown" };
174
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
120
175
  }
121
- // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
122
- // of the pattern is `unknown`, never proof of OAuth.
123
- const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
124
- return {
125
- registered: "yes",
126
- authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
127
- ...(url ? { registeredUrl: url } : {}),
128
- };
176
+ return parseMcpGet(probe.output, keyAuthPattern);
129
177
  }
130
178
  // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
131
179
  // its exit code; `mcp list --json` is the only trustworthy read-back.
@@ -167,41 +215,61 @@ export function codexOauthCompleted() {
167
215
  return false;
168
216
  return undefined;
169
217
  }
218
+ // This reaches the pasted report, so its shape is checked first.
219
+ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
220
+ /** Codex stores a variable name, not a value, so resolve it. */
221
+ function codexEnvKey(envVar) {
222
+ if (!ENV_VAR_NAME.test(envVar)) {
223
+ return { keyReason: "its key comes from an unusable variable name" };
224
+ }
225
+ const key = extractableKey(process.env[envVar]);
226
+ if (key)
227
+ return { apiKey: key, keyIsIndirect: true };
228
+ return {
229
+ keyReason: process.env[envVar]
230
+ ? `$${envVar} does not hold a TinyFish key here`
231
+ : `$${envVar} is not set here`,
232
+ };
233
+ }
170
234
  function probeCodex() {
171
235
  const read = readCodexEntry();
172
236
  if ("reason" in read)
173
- return { registered: "unknown", authMode: "unknown", reason: read.reason };
237
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: read.reason };
174
238
  const entry = read.entry;
175
239
  if (!entry)
176
- return { registered: "no", authMode: "unknown" };
240
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
177
241
  const codexUrl = entry.transport?.url;
242
+ // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
243
+ // the whole payload reports api-key for TinyFish because some other server carries a key.
244
+ const envVar = entry.auth_status === "o_auth" ? undefined : entry.transport?.bearer_token_env_var;
178
245
  return {
179
- registered: "yes",
180
- // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
181
- // the whole payload reports api-key for TinyFish because some other server carries a key.
246
+ registered: Registered.Yes,
182
247
  authMode: entry.auth_status === "o_auth"
183
- ? "oauth"
184
- : entry.transport?.bearer_token_env_var
185
- ? "api-key"
186
- : "unknown",
248
+ ? AuthMode.OAuth
249
+ : envVar
250
+ ? AuthMode.ApiKey
251
+ : AuthMode.Unknown,
187
252
  ...(codexUrl ? { registeredUrl: codexUrl } : {}),
253
+ ...(envVar ? codexEnvKey(envVar) : {}),
188
254
  };
189
255
  }
190
256
  function probeCursor() {
191
257
  const entry = readCursorTinyfishEntry();
192
258
  if (entry.error) {
193
259
  return {
194
- registered: "unknown",
195
- authMode: "unknown",
260
+ registered: Registered.Unknown,
261
+ authMode: AuthMode.Unknown,
196
262
  reason: "mcp.json exists but could not be read or parsed",
197
263
  };
198
264
  }
199
265
  if (!entry.present)
200
- return { registered: "no", authMode: "unknown" };
266
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
267
+ const key = extractableKey(entry.apiKey);
201
268
  return {
202
- registered: "yes",
203
- authMode: entry.hasApiKeyHeader ? "api-key" : "unknown",
269
+ registered: Registered.Yes,
270
+ authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
204
271
  ...(entry.url ? { registeredUrl: entry.url } : {}),
272
+ ...(key ? { apiKey: key } : {}),
205
273
  };
206
274
  }
207
275
  // Both list commands print this header in every state, including "no servers configured".
@@ -213,31 +281,31 @@ const OPENCODE_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
213
281
  function fromMcpList(command, output, registeredMode, urlPattern) {
214
282
  if (LISTED_AS_TINYFISH.test(output)) {
215
283
  const url = urlPattern?.exec(output)?.[1];
216
- return { registered: "yes", authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
284
+ return { registered: Registered.Yes, authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
217
285
  }
218
286
  if (!MCP_LIST_HEADER.test(output)) {
219
287
  return {
220
- registered: "unknown",
221
- authMode: "unknown",
288
+ registered: Registered.Unknown,
289
+ authMode: AuthMode.Unknown,
222
290
  reason: `could not interpret \`${command} mcp list\` output`,
223
291
  };
224
292
  }
225
- return { registered: "no", authMode: "unknown" };
293
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
226
294
  }
227
295
  // connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
228
296
  function probeHermes() {
229
297
  const probe = runProbe("hermes", ["mcp", "list"]);
230
298
  if (probe.outcome === "unavailable") {
231
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
299
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
232
300
  }
233
301
  if (probe.exitCode !== 0) {
234
302
  return {
235
- registered: "unknown",
236
- authMode: "unknown",
303
+ registered: Registered.Unknown,
304
+ authMode: AuthMode.Unknown,
237
305
  reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
238
306
  };
239
307
  }
240
- return fromMcpList("hermes", probe.output, "oauth");
308
+ return fromMcpList("hermes", probe.output, AuthMode.OAuth);
241
309
  }
242
310
  const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
243
311
  // OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
@@ -249,30 +317,30 @@ function probeOpenClaw() {
249
317
  }
250
318
  catch {
251
319
  return {
252
- registered: "unknown",
253
- authMode: "unknown",
320
+ registered: Registered.Unknown,
321
+ authMode: AuthMode.Unknown,
254
322
  reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
255
323
  };
256
324
  }
257
325
  // A bare substring also matched a skill merely named `not-tinyfish-thing`.
258
326
  return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
259
- ? { registered: "yes", authMode: "api-key" }
260
- : { registered: "no", authMode: "unknown" };
327
+ ? { registered: Registered.Yes, authMode: AuthMode.ApiKey }
328
+ : { registered: Registered.No, authMode: AuthMode.Unknown };
261
329
  }
262
330
  // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
263
331
  function probeOpencode() {
264
332
  const probe = runProbe("opencode", ["mcp", "list"]);
265
333
  if (probe.outcome === "unavailable") {
266
- return { registered: "unknown", authMode: "unknown", reason: probe.reason };
334
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
267
335
  }
268
336
  if (probe.exitCode !== 0) {
269
337
  return {
270
- registered: "unknown",
271
- authMode: "unknown",
338
+ registered: Registered.Unknown,
339
+ authMode: AuthMode.Unknown,
272
340
  reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
273
341
  };
274
342
  }
275
- return fromMcpList("opencode", probe.output, "unknown", OPENCODE_ROW_URL);
343
+ return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
276
344
  }
277
345
  const PROBES = {
278
346
  "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
@@ -295,7 +363,7 @@ export function detectRegistrations(harnesses) {
295
363
  };
296
364
  // Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
297
365
  if (!detection.detected) {
298
- return { ...base, registered: "no", authMode: "unknown" };
366
+ return { ...base, registered: Registered.No, authMode: AuthMode.Unknown };
299
367
  }
300
368
  try {
301
369
  return { ...base, ...PROBES[detection.harness]() };
@@ -308,8 +376,8 @@ export function detectRegistrations(harnesses) {
308
376
  }
309
377
  return {
310
378
  ...base,
311
- registered: "unknown",
312
- authMode: "unknown",
379
+ registered: Registered.Unknown,
380
+ authMode: AuthMode.Unknown,
313
381
  reason: "the probe failed unexpectedly",
314
382
  };
315
383
  }
@@ -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.195",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {