@tiny-fish/cli 0.21.1-next.194 → 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.
@@ -15,9 +15,12 @@ function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
15
15
  return false;
16
16
  if (pointsElsewhere(status.registeredUrl, mcpUrl))
17
17
  return false;
18
- return (status.registered === Registered.Yes &&
19
- status.authMode === AuthMode.ApiKey &&
20
- keyAuth?.ok === true);
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;
21
24
  }
22
25
  // Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
23
26
  function checkCliVersion() {
@@ -57,9 +60,12 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
57
60
  return "an unparseable endpoint";
58
61
  }
59
62
  }
60
- function keyedVerdict(keyAuth) {
61
- if (!keyAuth)
62
- return { status: "warn", detail: "registered, API key present but unverified" };
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
+ }
63
69
  if (keyAuth.ok) {
64
70
  return {
65
71
  status: "pass",
@@ -67,6 +73,13 @@ function keyedVerdict(keyAuth) {
67
73
  };
68
74
  }
69
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
+ }
70
83
  return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
71
84
  }
72
85
  // Only 401 blames the key; connect would loop on the rest.
@@ -90,9 +103,20 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
90
103
  detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
91
104
  };
92
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
+ }
93
117
  // Widening past api-key would warn every healthy oauth install.
94
118
  if (status.authMode === AuthMode.ApiKey)
95
- return keyedVerdict(keyAuth);
119
+ return keyedVerdict(status, keyAuth);
96
120
  // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
97
121
  const because = status.reason ? ` (${status.reason})` : "";
98
122
  return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
@@ -9,6 +9,11 @@ export interface RegistrationStatus {
9
9
  registeredUrl?: string;
10
10
  /** For doctor to verify; never for the report. */
11
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;
@@ -76,10 +76,23 @@ function runProbe(command, args) {
76
76
  }
77
77
  // A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
78
78
  // The optional prefix must end in `:`, so `not-tinyfish` is not a match.
79
- 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$/;
80
81
  // A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
81
82
  // is required to be the first word on its line, after whatever status glyph the CLI prints.
82
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
+ }
83
96
  /** The plugin ships its own MCP server, so a working install can carry no `tinyfish` entry. */
84
97
  function fromPluginRegistration(command) {
85
98
  const probe = runProbe(command, ["mcp", "list"]);
@@ -88,12 +101,37 @@ function fromPluginRegistration(command) {
88
101
  const match = MCP_LIST_TINYFISH_LINE.exec(probe.output);
89
102
  if (!match)
90
103
  return undefined;
104
+ const connected = connectionState(match[3] ?? "");
91
105
  // `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
92
106
  return {
93
107
  registered: Registered.Yes,
94
108
  authMode: AuthMode.Unknown,
95
109
  registeredUrl: match[2],
96
- 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 }),
97
135
  };
98
136
  }
99
137
  // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
@@ -110,6 +148,17 @@ function fromMcpGet(command, keyAuthPattern) {
110
148
  reason: `\`${command} mcp get\` is unsupported by this version`,
111
149
  };
112
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
+ }
113
162
  // Positive evidence from `mcp list` outranks anything inferred from a failed `mcp get`.
114
163
  const plugin = fromPluginRegistration(command);
115
164
  if (plugin)
@@ -124,16 +173,7 @@ function fromMcpGet(command, keyAuthPattern) {
124
173
  }
125
174
  return { registered: Registered.No, authMode: AuthMode.Unknown };
126
175
  }
127
- // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
128
- // of the pattern is `unknown`, never proof of OAuth.
129
- const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
130
- const key = extractableKey(API_KEY_HEADER_VALUE.exec(probe.output)?.[1]);
131
- return {
132
- registered: Registered.Yes,
133
- authMode: keyAuthPattern.test(probe.output) ? AuthMode.ApiKey : AuthMode.Unknown,
134
- ...(url ? { registeredUrl: url } : {}),
135
- ...(key ? { apiKey: key } : {}),
136
- };
176
+ return parseMcpGet(probe.output, keyAuthPattern);
137
177
  }
138
178
  // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
139
179
  // its exit code; `mcp list --json` is the only trustworthy read-back.
@@ -175,6 +215,22 @@ export function codexOauthCompleted() {
175
215
  return false;
176
216
  return undefined;
177
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
+ }
178
234
  function probeCodex() {
179
235
  const read = readCodexEntry();
180
236
  if ("reason" in read)
@@ -183,16 +239,18 @@ function probeCodex() {
183
239
  if (!entry)
184
240
  return { registered: Registered.No, authMode: AuthMode.Unknown };
185
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;
186
245
  return {
187
246
  registered: Registered.Yes,
188
- // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
189
- // the whole payload reports api-key for TinyFish because some other server carries a key.
190
247
  authMode: entry.auth_status === "o_auth"
191
248
  ? AuthMode.OAuth
192
- : entry.transport?.bearer_token_env_var
249
+ : envVar
193
250
  ? AuthMode.ApiKey
194
251
  : AuthMode.Unknown,
195
252
  ...(codexUrl ? { registeredUrl: codexUrl } : {}),
253
+ ...(envVar ? codexEnvKey(envVar) : {}),
196
254
  };
197
255
  }
198
256
  function probeCursor() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.21.1-next.194",
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": {