@tiny-fish/cli 0.23.1-next.205 → 0.23.1-next.206
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 +10 -19
- package/dist/lib/auth.d.ts +2 -2
- package/dist/lib/auth.js +5 -1
- package/dist/lib/cursor-config.d.ts +2 -2
- package/dist/lib/cursor-config.js +4 -3
- package/dist/lib/registration-detect.d.ts +2 -4
- package/dist/lib/registration-detect.js +18 -33
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -63,10 +63,14 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
|
63
63
|
return "an unparseable endpoint";
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
/** Either it reads the CLI's key at runtime or it holds a copy: the CLI's own verdict covers both. */
|
|
67
|
+
function runsOnCliCredential(status) {
|
|
68
|
+
return status.usesCliKey === true || status.keyMatchesCliKey === true;
|
|
69
|
+
}
|
|
66
70
|
function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
67
71
|
if (!keyAuth) {
|
|
68
72
|
// Its auth is the CLI credential; a warn would exit 0.
|
|
69
|
-
if (status
|
|
73
|
+
if (runsOnCliCredential(status)) {
|
|
70
74
|
return cliKeyMissing
|
|
71
75
|
? {
|
|
72
76
|
status: "fail",
|
|
@@ -79,7 +83,7 @@ function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
|
79
83
|
};
|
|
80
84
|
}
|
|
81
85
|
// A bare warn beside exit 0 reads as a pass.
|
|
82
|
-
const because = status.keyReason ?? "
|
|
86
|
+
const because = status.keyReason ?? "the CLI cannot verify a key it does not hold";
|
|
83
87
|
return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
|
|
84
88
|
}
|
|
85
89
|
if (keyAuth.ok) {
|
|
@@ -89,13 +93,6 @@ function keyedVerdict(status, keyAuth, cliKeyMissing) {
|
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
if (keyAuth.status === 401) {
|
|
92
|
-
// Resolved in doctor's shell, not codex's, so a 401 proves nothing.
|
|
93
|
-
if (status.keyIsIndirect) {
|
|
94
|
-
return {
|
|
95
|
-
status: "warn",
|
|
96
|
-
detail: "registered, but the key doctor resolved for it is rejected",
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
96
|
return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
|
|
100
97
|
}
|
|
101
98
|
// Only 401 blames the key; connect would loop on the rest.
|
|
@@ -192,20 +189,14 @@ function isDefaultEndpoint(mcpUrl) {
|
|
|
192
189
|
return false;
|
|
193
190
|
}
|
|
194
191
|
}
|
|
195
|
-
function verifyHarnessKey(status,
|
|
192
|
+
function verifyHarnessKey(status, cliAuth, mcpUrl) {
|
|
196
193
|
if (!isDefaultEndpoint(mcpUrl))
|
|
197
194
|
return undefined;
|
|
198
195
|
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
199
196
|
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
200
197
|
return undefined;
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
return cliAuth;
|
|
204
|
-
if (!status.apiKey)
|
|
205
|
-
return undefined;
|
|
206
|
-
if (status.apiKey === cliKey && cliAuth)
|
|
207
|
-
return cliAuth;
|
|
208
|
-
return verifyMcpAuth(status.apiKey);
|
|
198
|
+
// The CLI never holds a harness's key, so only its own credential can be verified.
|
|
199
|
+
return runsOnCliCredential(status) ? cliAuth : undefined;
|
|
209
200
|
}
|
|
210
201
|
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
211
202
|
const UNATTENDED_SAFE = new Set(["cursor"]);
|
|
@@ -252,7 +243,7 @@ export async function runDoctor(options) {
|
|
|
252
243
|
const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
|
|
253
244
|
checkConnectivity(options.mcpUrl),
|
|
254
245
|
cliAuth,
|
|
255
|
-
Promise.all(statuses.map((status) => verifyHarnessKey(status,
|
|
246
|
+
Promise.all(statuses.map((status) => verifyHarnessKey(status, cliAuth, options.mcpUrl))),
|
|
256
247
|
]);
|
|
257
248
|
const checks = [
|
|
258
249
|
checkCliVersion(),
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -36,8 +36,6 @@ 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;
|
|
41
39
|
export declare function validateKeyFormat(key: string): boolean;
|
|
42
40
|
export declare function maskKey(key: string): string;
|
|
43
41
|
export type KeySource = "explicit" | "env" | "config" | "none";
|
|
@@ -51,6 +49,8 @@ export interface UnresolvedKey {
|
|
|
51
49
|
}
|
|
52
50
|
/** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
|
|
53
51
|
export declare function resolvedApiKey(explicitApiKey?: string): string | undefined;
|
|
52
|
+
/** Answers "is this the CLI's own key" without the value leaving the caller; see sdk/cli/CLAUDE.md. */
|
|
53
|
+
export declare function matchesCliKey(value: string | undefined): boolean;
|
|
54
54
|
/** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
|
|
55
55
|
export declare function validatedApiKey(explicitApiKey?: string): string | undefined;
|
|
56
56
|
/**
|
package/dist/lib/auth.js
CHANGED
|
@@ -193,7 +193,7 @@ export function isHeaderSafe(key) {
|
|
|
193
193
|
return true;
|
|
194
194
|
}
|
|
195
195
|
/** No length rule: short real keys must survive. */
|
|
196
|
-
|
|
196
|
+
function hasKeyPrefix(key) {
|
|
197
197
|
return KEY_PREFIXES.some((p) => key.startsWith(p));
|
|
198
198
|
}
|
|
199
199
|
export function validateKeyFormat(key) {
|
|
@@ -219,6 +219,10 @@ function lookupApiKey(explicitApiKey) {
|
|
|
219
219
|
export function resolvedApiKey(explicitApiKey) {
|
|
220
220
|
return lookupApiKey(explicitApiKey).value;
|
|
221
221
|
}
|
|
222
|
+
/** Answers "is this the CLI's own key" without the value leaving the caller; see sdk/cli/CLAUDE.md. */
|
|
223
|
+
export function matchesCliKey(value) {
|
|
224
|
+
return value !== undefined && value === resolvedApiKey();
|
|
225
|
+
}
|
|
222
226
|
/** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
|
|
223
227
|
export function validatedApiKey(explicitApiKey) {
|
|
224
228
|
const key = resolvedApiKey(explicitApiKey);
|
|
@@ -14,10 +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
|
-
|
|
17
|
+
keyMatchesCliKey?: boolean;
|
|
18
18
|
error?: string;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/** Reports the header's shape, never its value. */
|
|
21
21
|
export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
|
|
22
22
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
23
23
|
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
|
+
import { matchesCliKey } from "./auth.js";
|
|
4
5
|
// The one config file the CLI writes directly — hence backup/merge rigor.
|
|
5
6
|
const TINYFISH_SERVER_KEY = "tinyfish";
|
|
6
7
|
export function cursorConfigDir() {
|
|
@@ -65,7 +66,7 @@ export function planCursorWrite(mcpUrl, apiKey) {
|
|
|
65
66
|
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
66
67
|
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
67
68
|
}
|
|
68
|
-
/**
|
|
69
|
+
/** Reports the header's shape, never its value. */
|
|
69
70
|
export function readCursorTinyfishEntry() {
|
|
70
71
|
const existing = readExisting();
|
|
71
72
|
if ("error" in existing)
|
|
@@ -78,12 +79,12 @@ export function readCursorTinyfishEntry() {
|
|
|
78
79
|
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
79
80
|
// casing we write would understate auth mode for a user who typed it differently.
|
|
80
81
|
const keyHeader = isPlainRecord(headers)
|
|
81
|
-
? Object.entries(headers).find((
|
|
82
|
+
? Object.entries(headers).find((entry) => entry[0].toLowerCase() === "x-api-key" && typeof entry[1] === "string")
|
|
82
83
|
: undefined;
|
|
83
84
|
return {
|
|
84
85
|
present: true,
|
|
85
86
|
hasApiKeyHeader: keyHeader !== undefined,
|
|
86
|
-
...(keyHeader ? {
|
|
87
|
+
...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
|
|
87
88
|
...(typeof entry.url === "string" ? { url: entry.url } : {}),
|
|
88
89
|
};
|
|
89
90
|
}
|
|
@@ -7,10 +7,8 @@ export interface RegistrationStatus {
|
|
|
7
7
|
connectedBefore: boolean;
|
|
8
8
|
/** The endpoint the harness is actually pointed at, when the probe exposes it. */
|
|
9
9
|
registeredUrl?: string;
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/** From the environment, not the config, so a 401 is ambiguous. */
|
|
13
|
-
keyIsIndirect?: boolean;
|
|
10
|
+
/** Its key is a copy of the CLI's, so the CLI's own verification covers it. */
|
|
11
|
+
keyMatchesCliKey?: boolean;
|
|
14
12
|
/** Holds no key; reads the CLI's at runtime. */
|
|
15
13
|
usesCliKey?: boolean;
|
|
16
14
|
connected?: boolean;
|
|
@@ -2,7 +2,7 @@ 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 {
|
|
5
|
+
import { loadConfig, matchesCliKey } from "./auth.js";
|
|
6
6
|
import { errLine } from "./output.js";
|
|
7
7
|
import { readCursorTinyfishEntry } from "./cursor-config.js";
|
|
8
8
|
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from "./harness-detect.js";
|
|
@@ -26,10 +26,6 @@ const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
|
|
|
26
26
|
const API_KEY_HEADER = /x-api-key/i;
|
|
27
27
|
// `claude mcp get` prints the value; `mcp add` masks it.
|
|
28
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
|
-
}
|
|
33
29
|
// OpenCode colours its list even through a pipe, putting `\x1b[34m` between the line start and
|
|
34
30
|
// the server name, so an unstripped probe reads a registered harness as absent.
|
|
35
31
|
// eslint-disable-next-line no-control-regex
|
|
@@ -119,23 +115,23 @@ function tinyfishServerName(output) {
|
|
|
119
115
|
.map((name) => name.trim())
|
|
120
116
|
.find((name) => SERVER_NAMED_TINYFISH.test(name));
|
|
121
117
|
}
|
|
122
|
-
function parseMcpGet(output
|
|
118
|
+
function parseMcpGet(output) {
|
|
123
119
|
// Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
|
|
124
120
|
// of the pattern is `unknown`, never proof of OAuth.
|
|
125
121
|
const url = /^\s*URL:\s*(\S+)/m.exec(output)?.[1];
|
|
126
|
-
const
|
|
122
|
+
const keyMatchesCliKey = matchesCliKey(API_KEY_HEADER_VALUE.exec(output)?.[1]);
|
|
127
123
|
const status = MCP_GET_STATUS.exec(output)?.[1];
|
|
128
124
|
const connected = status === undefined ? undefined : connectionState(status);
|
|
129
125
|
return {
|
|
130
126
|
registered: Registered.Yes,
|
|
131
|
-
authMode:
|
|
127
|
+
authMode: API_KEY_HEADER.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
132
128
|
...(url ? { registeredUrl: url } : {}),
|
|
133
|
-
...(
|
|
129
|
+
...(keyMatchesCliKey ? { keyMatchesCliKey } : {}),
|
|
134
130
|
...(connected === undefined ? {} : { connected }),
|
|
135
131
|
};
|
|
136
132
|
}
|
|
137
133
|
// claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
|
|
138
|
-
function fromMcpGet(command
|
|
134
|
+
function fromMcpGet(command) {
|
|
139
135
|
const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
|
|
140
136
|
if (probe.outcome === "unavailable") {
|
|
141
137
|
return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
|
|
@@ -154,7 +150,7 @@ function fromMcpGet(command, keyAuthPattern) {
|
|
|
154
150
|
const rescoped = runProbe(command, ["mcp", "get", scoped]);
|
|
155
151
|
if (rescoped.outcome === "ran" && rescoped.exitCode === 0) {
|
|
156
152
|
return {
|
|
157
|
-
...parseMcpGet(rescoped.output
|
|
153
|
+
...parseMcpGet(rescoped.output),
|
|
158
154
|
reason: `registered as ${scoped}`,
|
|
159
155
|
};
|
|
160
156
|
}
|
|
@@ -173,7 +169,7 @@ function fromMcpGet(command, keyAuthPattern) {
|
|
|
173
169
|
}
|
|
174
170
|
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
175
171
|
}
|
|
176
|
-
return parseMcpGet(probe.output
|
|
172
|
+
return parseMcpGet(probe.output);
|
|
177
173
|
}
|
|
178
174
|
// `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
|
|
179
175
|
// its exit code; `mcp list --json` is the only trustworthy read-back.
|
|
@@ -217,17 +213,16 @@ export function codexOauthCompleted() {
|
|
|
217
213
|
}
|
|
218
214
|
// This reaches the pasted report, so its shape is checked first.
|
|
219
215
|
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
220
|
-
/** Codex stores a variable name
|
|
216
|
+
/** Codex stores a variable name; resolving it only answers whether it is the CLI's key. */
|
|
221
217
|
function codexEnvKey(envVar) {
|
|
222
218
|
if (!ENV_VAR_NAME.test(envVar)) {
|
|
223
219
|
return { keyReason: "its key comes from an unusable variable name" };
|
|
224
220
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return { apiKey: key, keyIsIndirect: true };
|
|
221
|
+
if (matchesCliKey(process.env[envVar]))
|
|
222
|
+
return { keyMatchesCliKey: true };
|
|
228
223
|
return {
|
|
229
224
|
keyReason: process.env[envVar]
|
|
230
|
-
? `$${envVar}
|
|
225
|
+
? `$${envVar} holds a key this CLI cannot verify`
|
|
231
226
|
: `$${envVar} is not set here`,
|
|
232
227
|
};
|
|
233
228
|
}
|
|
@@ -264,12 +259,11 @@ function probeCursor() {
|
|
|
264
259
|
}
|
|
265
260
|
if (!entry.present)
|
|
266
261
|
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
267
|
-
const key = extractableKey(entry.apiKey);
|
|
268
262
|
return {
|
|
269
263
|
registered: Registered.Yes,
|
|
270
264
|
authMode: entry.hasApiKeyHeader ? AuthMode.ApiKey : AuthMode.Unknown,
|
|
271
265
|
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
272
|
-
...(
|
|
266
|
+
...(entry.keyMatchesCliKey ? { keyMatchesCliKey: true } : {}),
|
|
273
267
|
};
|
|
274
268
|
}
|
|
275
269
|
// Both list commands print this header in every state, including "no servers configured".
|
|
@@ -342,15 +336,13 @@ function probeOpencode() {
|
|
|
342
336
|
}
|
|
343
337
|
return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
|
|
344
338
|
}
|
|
345
|
-
// Verified against grok 0.0.60: a list
|
|
339
|
+
// Verified against grok 0.0.60: a list. Header values stay `unknown`; only names are read.
|
|
346
340
|
const grokEntrySchema = z.looseObject({
|
|
347
341
|
name: z.string().optional(),
|
|
348
342
|
url: z.string().optional(),
|
|
349
|
-
headers: z.record(z.string(), z.
|
|
343
|
+
headers: z.record(z.string(), z.unknown()).optional(),
|
|
350
344
|
});
|
|
351
345
|
const grokListSchema = z.array(grokEntrySchema);
|
|
352
|
-
// Grok drops X-API-Key when the server advertises OAuth metadata, so keys ride Authorization.
|
|
353
|
-
const BEARER_VALUE = /^Bearer\s+(\S+)$/i;
|
|
354
346
|
// Grok has no `mcp get`; `mcp list --json` is the only machine-readable view.
|
|
355
347
|
function probeGrok() {
|
|
356
348
|
const probe = runProbe("grok", ["mcp", "list", "--json"]);
|
|
@@ -385,23 +377,16 @@ function probeGrok() {
|
|
|
385
377
|
const entry = entries.find((server) => server.name === "tinyfish");
|
|
386
378
|
if (!entry)
|
|
387
379
|
return { registered: Registered.No, authMode: AuthMode.Unknown };
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const hasAuthorization = authorization !== undefined;
|
|
391
|
-
const key = extractableKey(BEARER_VALUE.exec(authorization ?? "")?.[1]);
|
|
380
|
+
// Grok drops X-API-Key when the server advertises OAuth metadata, so keys ride Authorization.
|
|
381
|
+
const hasAuthorization = Object.keys(entry.headers ?? {}).some((name) => name.toLowerCase() === "authorization");
|
|
392
382
|
return {
|
|
393
383
|
registered: Registered.Yes,
|
|
394
384
|
authMode: hasAuthorization ? AuthMode.ApiKey : AuthMode.OAuth,
|
|
395
385
|
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
396
|
-
...(key ? { apiKey: key } : {}),
|
|
397
|
-
// A keyed Grok entry suppresses OAuth discovery, so an unreadable key cannot fall back.
|
|
398
|
-
...(hasAuthorization && !key
|
|
399
|
-
? { keyReason: "its Authorization header is not a readable TinyFish key" }
|
|
400
|
-
: {}),
|
|
401
386
|
};
|
|
402
387
|
}
|
|
403
388
|
const PROBES = {
|
|
404
|
-
"claude-code": () => fromMcpGet("claude"
|
|
389
|
+
"claude-code": () => fromMcpGet("claude"),
|
|
405
390
|
codex: probeCodex,
|
|
406
391
|
cursor: probeCursor,
|
|
407
392
|
grok: probeGrok,
|