@tiny-fish/cli 0.23.1-next.205 → 0.23.1-next.207

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.
@@ -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.usesCliKey) {
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 ?? "its key is not readable here";
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, cliKey, cliAuth, mcpUrl) {
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
- // A harness with no key of its own reads the CLI's.
202
- if (status.usesCliKey)
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, credential.key, cliAuth, options.mcpUrl))),
246
+ Promise.all(statuses.map((status) => verifyHarnessKey(status, cliAuth, options.mcpUrl))),
256
247
  ]);
257
248
  const checks = [
258
249
  checkCliVersion(),
@@ -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
- export function hasKeyPrefix(key) {
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);
@@ -1,6 +1,6 @@
1
1
  import { APIStatusError, agentRunAsyncResponseSchema, agentRunResponseSchema, agentRunWithStreamingResponseSchema, fetchResponseSchema, browserSessionSchema, searchQueryResponseSchema, TinyFish, runSchema, runStatusSchema, } from "@tiny-fish/sdk";
2
2
  import { CONNECT_SOURCE, loadConfig } from "./auth.js";
3
- import { API_URL_OVERRIDE, CLI_VERSION } from "./constants.js";
3
+ import { API_URL_OVERRIDE, CLI_AGENT_IDENTITY, CLI_VERSION } from "./constants.js";
4
4
  import { detectHarness, detectHumanInitiated } from "./harness.js";
5
5
  import { captureNotice } from "./notice.js";
6
6
  import { ApiError } from "./output.js";
@@ -34,7 +34,7 @@ class TinyFishCliClient extends TinyFish {
34
34
  const base = super._buildHeaders();
35
35
  const headers = {
36
36
  ...base,
37
- "X-TF-Request-Origin": "tinyfish-cli",
37
+ "X-TF-Request-Origin": CLI_AGENT_IDENTITY,
38
38
  "User-Agent": `${base["User-Agent"] ?? ""} @tiny-fish/cli/${CLI_VERSION}`.trim(),
39
39
  };
40
40
  // Forwarded explicitly: the pinned published SDK may predate TF_CLIENT_* support.
@@ -3,7 +3,7 @@ import spawn from "cross-spawn";
3
3
  import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
4
4
  import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
5
5
  import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
6
- import { TINYFISH_CLI_PACKAGE } from "./constants.js";
6
+ import { CLI_AGENT_IDENTITY, TINYFISH_CLI_PACKAGE } from "./constants.js";
7
7
  import { installRoot } from "./install-root.js";
8
8
  import { errLine } from "./output.js";
9
9
  export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
@@ -66,6 +66,8 @@ function skillAddArgs(skillAgents) {
66
66
  function skillSpawnEnv() {
67
67
  return {
68
68
  ...process.env,
69
+ // `skills` gates its logo art on agent detection.
70
+ AI_AGENT: CLI_AGENT_IDENTITY,
69
71
  PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`,
70
72
  };
71
73
  }
@@ -1,6 +1,7 @@
1
1
  export declare const CLI_VERSION: string;
2
2
  /** Unversioned: the install site appends `@latest`, the version read must not. */
3
3
  export declare const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
4
+ export declare const CLI_AGENT_IDENTITY = "tinyfish-cli";
4
5
  export declare const BASE_URL: string;
5
6
  export declare const API_URL_OVERRIDE: string | undefined;
6
7
  /** URL where users can create and manage API keys */
@@ -4,6 +4,8 @@ const _pkg = _require("../../package.json");
4
4
  export const CLI_VERSION = _pkg.version;
5
5
  /** Unversioned: the install site appends `@latest`, the version read must not. */
6
6
  export const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
7
+ // How we identify ourselves to other tools; `skills` echoes it back.
8
+ export const CLI_AGENT_IDENTITY = "tinyfish-cli";
7
9
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
8
10
  const apiUrlOverride = process.env["TINYFISH_API_URL"]?.trim() || undefined;
9
11
  if (apiUrlOverride) {
@@ -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
- apiKey?: string;
17
+ keyMatchesCliKey?: boolean;
18
18
  error?: string;
19
19
  }
20
- /** Carries the header value; callers keep it off the report. */
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
- /** Carries the header value; callers keep it off the report. */
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(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string")
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 ? { apiKey: keyHeader[1] } : {}),
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
- /** 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;
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 { hasKeyPrefix, loadConfig } from "./auth.js";
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, keyAuthPattern) {
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 key = extractableKey(API_KEY_HEADER_VALUE.exec(output)?.[1]);
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: keyAuthPattern.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
127
+ authMode: API_KEY_HEADER.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
132
128
  ...(url ? { registeredUrl: url } : {}),
133
- ...(key ? { apiKey: key } : {}),
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, keyAuthPattern) {
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, keyAuthPattern),
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, keyAuthPattern);
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, not a value, so resolve it. */
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
- const key = extractableKey(process.env[envVar]);
226
- if (key)
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} does not hold a TinyFish key here`
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
- ...(key ? { apiKey: key } : {}),
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, with the header value in the clear.
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.string()).optional(),
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
- const authorization = Object.entries(entry.headers ?? {}).find(([name]) => name.toLowerCase() === "authorization")?.[1];
389
- // `${VAR}` expands to "" when the env var is unset: a present-but-empty header is a broken key.
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", API_KEY_HEADER),
389
+ "claude-code": () => fromMcpGet("claude"),
405
390
  codex: probeCodex,
406
391
  cursor: probeCursor,
407
392
  grok: probeGrok,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.23.1-next.205",
3
+ "version": "0.23.1-next.207",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {