@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.
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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+)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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() {
|