@tiny-fish/cli 0.27.1-next.230 → 0.28.1-next.233

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,6 +2,7 @@ import { spawn } from "child_process";
2
2
  import * as readline from "readline";
3
3
  import { Option } from "commander";
4
4
  import { CONNECT_ATTEMPT_ENV, clearConfig, getDashboardUrl, loadConfig, maskKey, savePendingConnectAttempt, saveConfig, validateKeyFormat, } from "../lib/auth.js";
5
+ import { TINYFISH_API_KEY_VAR } from "../lib/constants.js";
5
6
  import { err, errLine, out, outLine } from "../lib/output.js";
6
7
  /**
7
8
  * Read a key from stdin without echoing it to the terminal.
@@ -143,7 +144,7 @@ export function registerAuth(program) {
143
144
  .description("Show which API key is active and its source")
144
145
  .option("--pretty", "Human-readable output")
145
146
  .action((opts) => {
146
- const envKey = process.env.TINYFISH_API_KEY;
147
+ const envKey = process.env[TINYFISH_API_KEY_VAR];
147
148
  if (envKey) {
148
149
  const authenticated = validateKeyFormat(envKey);
149
150
  const result = { source: "env", key_preview: maskKey(envKey), authenticated };
@@ -9,7 +9,9 @@ import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCur
9
9
  import { runConnectAll } from "../lib/connect-all.js";
10
10
  import { AuthMode } from "../lib/harness-detect.js";
11
11
  import { detectHumanInitiated } from "../lib/harness.js";
12
+ import { TINYFISH_API_KEY_VAR } from "../lib/constants.js";
12
13
  import { emitNotice } from "../lib/notice.js";
14
+ import { SIGN_IN_PROBES } from "../lib/registration-detect.js";
13
15
  import { errLine } from "../lib/output.js";
14
16
  import { z } from "zod";
15
17
  import { verifyMcpAuth } from "../lib/verify.js";
@@ -38,7 +40,7 @@ function exportApiKey(apiKey, displayName, deferOutro) {
38
40
  "signing in instead.");
39
41
  return false;
40
42
  }
41
- process.env["TINYFISH_API_KEY"] = apiKey;
43
+ process.env[TINYFISH_API_KEY_VAR] = apiKey;
42
44
  if (deferOutro) {
43
45
  deferOutro("Exported TINYFISH_API_KEY to your shell profile — restart your agents (or open a new " +
44
46
  "terminal) before use.");
@@ -52,6 +54,7 @@ function exportApiKey(apiKey, displayName, deferOutro) {
52
54
  async function connectNativeMcpClient(client, options) {
53
55
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
54
56
  const state = { stage: "prerequisite_check", settled: false };
57
+ const probeAuthenticated = SIGN_IN_PROBES[client.connectClient];
55
58
  await runGuarded(state, telemetry, () => {
56
59
  telemetry.track("started");
57
60
  const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
@@ -63,7 +66,7 @@ async function connectNativeMcpClient(client, options) {
63
66
  ? { key: storedKey, spec: client.keyAuth }
64
67
  : undefined;
65
68
  // Before the removals: a failed export must not strand the old registration.
66
- if (keyedAdd?.spec.viaEnv &&
69
+ if (keyedAdd?.spec.envVar &&
67
70
  !exportApiKey(keyedAdd.key, client.displayName, options.deferOutro)) {
68
71
  keyedAdd = undefined;
69
72
  }
@@ -90,7 +93,7 @@ async function connectNativeMcpClient(client, options) {
90
93
  const loginArgs = loginDegraded ? undefined : client.loginArgs;
91
94
  // A probed client authenticates inside `mcp add` too, so a failure there is either.
92
95
  state.stage =
93
- loginArgs && !client.probeAuthenticated ? "registration" : "registration_or_authentication";
96
+ loginArgs && !probeAuthenticated ? "registration" : "registration_or_authentication";
94
97
  errLine(`Adding TinyFish to ${client.displayName}...`);
95
98
  // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
96
99
  const addArgs = keyedAdd
@@ -109,7 +112,7 @@ async function connectNativeMcpClient(client, options) {
109
112
  telemetry.track("checkpoint", { phase: "registered" });
110
113
  // Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
111
114
  // whether the login step below still runs. Unknown keeps the pre-probe behaviour.
112
- const addSignedIn = client.probeAuthenticated?.();
115
+ const addSignedIn = probeAuthenticated?.();
113
116
  if (addSignedIn)
114
117
  telemetry.track("checkpoint", { phase: "oauth_done" });
115
118
  const degradedNotes = loginDegraded ? client.degradedAuthNotes : undefined;
@@ -121,7 +124,7 @@ async function connectNativeMcpClient(client, options) {
121
124
  errLine(degradedNotes.deferred);
122
125
  }
123
126
  // A probed client signs in again only when its inline OAuth is known to have been abandoned.
124
- const retryLogin = client.probeAuthenticated ? addSignedIn === false : true;
127
+ const retryLogin = probeAuthenticated ? addSignedIn === false : true;
125
128
  const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
126
129
  let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
127
130
  if (pendingLogin) {
@@ -131,7 +134,7 @@ async function connectNativeMcpClient(client, options) {
131
134
  if (loginResult.error || loginResult.status !== 0) {
132
135
  throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
133
136
  }
134
- if (client.probeAuthenticated?.() === false)
137
+ if (probeAuthenticated?.() === false)
135
138
  signInDeferred = true;
136
139
  else
137
140
  telemetry.track("checkpoint", { phase: "oauth_done" });
@@ -1,7 +1,7 @@
1
1
  import * as readline from "readline/promises";
2
2
  import { getApiKey, saveConfig } from "../lib/auth.js";
3
3
  import { fetchContentGet, searchQuery } from "../lib/client.js";
4
- import { BASE_URL } from "../lib/constants.js";
4
+ import { BASE_URL, TINYFISH_API_KEY_VAR } from "../lib/constants.js";
5
5
  import { errLine, handleApiError, outLine } from "../lib/output.js";
6
6
  import { connectClaudeCode, connectCodex, connectHermes, connectOpenClaw, connectOpencode, launchAgent, } from "./connect.js";
7
7
  const EXAMPLE_QUERIES = [
@@ -213,8 +213,8 @@ export function registerOnboard(program) {
213
213
  "Run `tinyfish connect <client> --launch` later to add one directly.")
214
214
  .action(async (query) => {
215
215
  const apiKey = getApiKey();
216
- const persistEnvKey = Boolean(process.env["TINYFISH_API_KEY"]);
217
- delete process.env["TINYFISH_API_KEY"];
216
+ const persistEnvKey = Boolean(process.env[TINYFISH_API_KEY_VAR]);
217
+ delete process.env[TINYFISH_API_KEY_VAR];
218
218
  try {
219
219
  const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
220
220
  const selectedQuery = await chooseQuery(query, interactive);
package/dist/lib/auth.js CHANGED
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import spawn from "cross-spawn";
5
5
  import { err, errLine } from "./output.js";
6
+ import { TINYFISH_API_KEY_VAR } from "./constants.js";
6
7
  const KEY_PREFIXES = ["sk-tinyfish-", "sk-mino-"];
7
8
  const MIN_KEY_LENGTH = 20;
8
9
  export { DASHBOARD_URL, getDashboardUrl } from "./constants.js";
@@ -133,7 +134,7 @@ export function saveConnectContext(client, attemptId) {
133
134
  export function persistApiKeyToEnvironment(apiKey, options = {}) {
134
135
  const platform = options.platform ?? process.platform;
135
136
  if (platform === "win32") {
136
- const result = spawn.sync("setx", ["TINYFISH_API_KEY", apiKey], { stdio: "ignore" });
137
+ const result = spawn.sync("setx", [TINYFISH_API_KEY_VAR, apiKey], { stdio: "ignore" });
137
138
  if (result.error || result.status !== 0) {
138
139
  throw new Error("Could not set TINYFISH_API_KEY in the Windows user environment", {
139
140
  cause: result.error,
@@ -157,14 +158,14 @@ export function persistApiKeyToEnvironment(apiKey, options = {}) {
157
158
  ? path.join(home, ".config", "fish", "config.fish")
158
159
  : path.join(home, ".profile");
159
160
  const quotedKey = `'${apiKey.replaceAll("'", "'\\''")}'`;
160
- const assignment = fish
161
- ? `set -gx TINYFISH_API_KEY ${quotedKey}`
162
- : `export TINYFISH_API_KEY=${quotedKey}`;
163
- const matcher = fish
164
- ? /^set -gx TINYFISH_API_KEY .*(?:\r?\n|$)/gm
165
- : /^export TINYFISH_API_KEY=.*(?:\r?\n|$)/gm;
161
+ const prefix = fish ? `set -gx ${TINYFISH_API_KEY_VAR} ` : `export ${TINYFISH_API_KEY_VAR}=`;
162
+ const assignment = `${prefix}${quotedKey}`;
166
163
  const existing = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
167
- const cleaned = existing.replace(matcher, "");
164
+ // Drops prior assignments, so repeated logins do not stack stale keys.
165
+ const cleaned = existing
166
+ .split("\n")
167
+ .filter((line) => !line.startsWith(prefix))
168
+ .join("\n");
168
169
  const updated = `${cleaned}${cleaned && !cleaned.endsWith("\n") ? "\n" : ""}${assignment}\n`;
169
170
  fs.mkdirSync(path.dirname(profile), { recursive: true });
170
171
  fs.writeFileSync(profile, updated, { mode: 0o600 });
@@ -208,7 +209,7 @@ export function maskKey(key) {
208
209
  function lookupApiKey(explicitApiKey) {
209
210
  if (explicitApiKey)
210
211
  return { value: explicitApiKey, source: "explicit" };
211
- const envKey = process.env["TINYFISH_API_KEY"];
212
+ const envKey = process.env[TINYFISH_API_KEY_VAR];
212
213
  if (envKey)
213
214
  return { value: envKey, source: "env" };
214
215
  // No try/catch: loadConfig swallows its own errors and returns {}.
@@ -2,6 +2,7 @@ import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
4
  import { loadConfig, validatedApiKey } from "./auth.js";
5
+ import { TINYFISH_API_KEY_VAR } from "./constants.js";
5
6
  import { installTinyFishCli, } from "./cli-install.js";
6
7
  import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
7
8
  import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
@@ -330,7 +331,7 @@ async function runCliFallback(opts, prompt) {
330
331
  outLine("Retry: npx @tiny-fish/cli connect --all");
331
332
  return 1;
332
333
  }
333
- const apiKey = opts.apiKey ?? process.env["TINYFISH_API_KEY"];
334
+ const apiKey = opts.apiKey ?? process.env[TINYFISH_API_KEY_VAR];
334
335
  if (!prompt && !apiKey) {
335
336
  outLine("Retry in a terminal: npx @tiny-fish/cli connect --all");
336
337
  return 1;
@@ -1,10 +1,11 @@
1
1
  import spawn from "cross-spawn";
2
+ import { TINYFISH_API_KEY_VAR } from "./constants.js";
2
3
  import { persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
3
4
  import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
4
5
  import { errLine } from "./output.js";
5
6
  export const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
6
7
  export function ensureCliAuthenticated(source, apiKey) {
7
- const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
8
+ const envKey = apiKey ?? process.env[TINYFISH_API_KEY_VAR];
8
9
  if (envKey) {
9
10
  if (!validateKeyFormat(envKey)) {
10
11
  throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
@@ -18,8 +18,8 @@ export interface NativeMcpClient extends SupportedCommand {
18
18
  nonInteractiveAdd?: true;
19
19
  /** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
20
20
  keyAuth?: {
21
- /** Key reaches the client through TINYFISH_API_KEY, not argv. */
22
- viaEnv?: boolean;
21
+ /** Which variable the client reads the key from. */
22
+ envVar?: string;
23
23
  addArgs: (mcpUrl: string, apiKey: string) => string[];
24
24
  };
25
25
  /** Shown when a keyless install leaves sign-in to the harness. */
@@ -30,8 +30,6 @@ export interface NativeMcpClient extends SupportedCommand {
30
30
  keyed: string;
31
31
  deferred: string;
32
32
  };
33
- /** Did the OAuth inside `mcp add` finish? undefined when the client cannot say. */
34
- probeAuthenticated?: () => boolean | undefined;
35
33
  removals: {
36
34
  args: string[];
37
35
  label: string;
@@ -1,7 +1,7 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { errLine, sanitizeLine } from "./output.js";
3
3
  import { spawnStepError, } from "./connect-runtime.js";
4
- import { codexOauthCompleted } from "./registration-detect.js";
4
+ import { TINYFISH_API_KEY_VAR } from "./constants.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.
7
7
  const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
@@ -151,11 +151,10 @@ export const CODEX = {
151
151
  // Codex has no `--header`; the MCP server takes a key as `Bearer sk-…`.
152
152
  // Omitting `--oauth-resource` is what keeps `mcp add` from running OAuth.
153
153
  keyAuth: {
154
- viaEnv: true,
155
- addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--bearer-token-env-var", "TINYFISH_API_KEY"],
154
+ envVar: TINYFISH_API_KEY_VAR,
155
+ addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--bearer-token-env-var", TINYFISH_API_KEY_VAR],
156
156
  },
157
157
  launchWalkthrough: launchCodexWalkthrough,
158
- probeAuthenticated: codexOauthCompleted,
159
158
  // Retry only: running it after a finished inline OAuth is a second browser hop.
160
159
  loginArgs: ["mcp", "login", "tinyfish"],
161
160
  removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
@@ -183,11 +182,11 @@ export const GROK = {
183
182
  // X-API-Key is dropped whenever the server advertises OAuth metadata; Bearer is not.
184
183
  // Grok expands `${VAR}` in headers at load, so the key stays out of argv and config.toml.
185
184
  keyAuth: {
186
- viaEnv: true,
185
+ envVar: TINYFISH_API_KEY_VAR,
187
186
  addArgs: (mcpUrl) => [
188
187
  ...grokAddArgs(mcpUrl),
189
188
  "--header",
190
- "Authorization: Bearer ${TINYFISH_API_KEY}",
189
+ `Authorization: Bearer \${${TINYFISH_API_KEY_VAR}}`,
191
190
  ],
192
191
  },
193
192
  signInHint: GROK_SIGN_IN_HINT,
@@ -2,6 +2,8 @@ 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
4
  export declare const CLI_AGENT_IDENTITY = "tinyfish-cli";
5
+ /** The descriptor hands this name to harnesses; the probes resolve it. */
6
+ export declare const TINYFISH_API_KEY_VAR = "TINYFISH_API_KEY";
5
7
  export declare const BASE_URL: string;
6
8
  export declare const API_URL_OVERRIDE: string | undefined;
7
9
  /** URL where users can create and manage API keys */
@@ -6,6 +6,8 @@ export const CLI_VERSION = _pkg.version;
6
6
  export const TINYFISH_CLI_PACKAGE = "@tiny-fish/cli";
7
7
  // How we identify ourselves to other tools; `skills` echoes it back.
8
8
  export const CLI_AGENT_IDENTITY = "tinyfish-cli";
9
+ /** The descriptor hands this name to harnesses; the probes resolve it. */
10
+ export const TINYFISH_API_KEY_VAR = "TINYFISH_API_KEY";
9
11
  /** Base URL for the TinyFish API. Override with TINYFISH_API_URL for staging/self-hosted. */
10
12
  const apiUrlOverride = process.env["TINYFISH_API_URL"]?.trim() || undefined;
11
13
  if (apiUrlOverride) {
@@ -1,3 +1,4 @@
1
+ import type { AgentClient } from "./connect-runtime.js";
1
2
  import { AuthMode, type Harness, Registered } from "./harness-detect.js";
2
3
  export interface RegistrationStatus {
3
4
  harness: Harness;
@@ -17,5 +18,7 @@ export interface RegistrationStatus {
17
18
  reason?: string;
18
19
  }
19
20
  export declare function codexOauthCompleted(): boolean | undefined;
21
+ /** Per-harness sign-in probes; connect composes these rather than the descriptor carrying them. */
22
+ export declare const SIGN_IN_PROBES: Partial<Record<AgentClient, () => boolean | undefined>>;
20
23
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
21
24
  export declare function detectRegistrations(harnesses?: Harness[]): RegistrationStatus[];
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import spawn from "cross-spawn";
4
4
  import { z } from "zod";
5
5
  import { loadConfig, matchesCliKey } from "./auth.js";
6
+ import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
6
7
  import { errLine } from "./output.js";
7
8
  import { readCursorTinyfishEntry } from "./cursor-config.js";
8
9
  import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, AuthMode, Registered, } from "./harness-detect.js";
@@ -70,6 +71,14 @@ function runProbe(command, args) {
70
71
  output: `${stdout}${(result.stderr ?? "").replace(ANSI_SGR, "")}`,
71
72
  };
72
73
  }
74
+ /** Undefined when the probe ran and exited zero. */
75
+ function probeReason(command, args, probe) {
76
+ if (probe.outcome === "unavailable")
77
+ return probe.reason;
78
+ if (probe.exitCode === 0)
79
+ return undefined;
80
+ return `\`${command} ${args.join(" ")}\` exited ${probe.exitCode}`;
81
+ }
73
82
  // A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
74
83
  // The optional prefix must end in `:`, so `not-tinyfish` is not a match.
75
84
  const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
@@ -174,14 +183,15 @@ function fromMcpGet(command) {
174
183
  // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
175
184
  // its exit code; `mcp list --json` is the only trustworthy read-back.
176
185
  function readCodexEntry() {
177
- const probe = runProbe("codex", ["mcp", "list", "--json"]);
186
+ const args = ["mcp", "list", "--json"];
187
+ const probe = runProbe("codex", args);
178
188
  if (probe.outcome === "unavailable")
179
189
  return { reason: probe.reason };
180
190
  // Presence is never inferred from exit code 0, but a nonzero exit means the output cannot
181
191
  // be trusted either — that is `unknown`, not "no server".
182
- if (probe.exitCode !== 0) {
183
- return { reason: `\`codex mcp list --json\` exited ${probe.exitCode}` };
184
- }
192
+ const failure = probeReason("codex", args, probe);
193
+ if (failure)
194
+ return { reason: failure };
185
195
  let servers;
186
196
  try {
187
197
  // stdout only: an update banner on stderr would otherwise break the parse and report a
@@ -211,10 +221,19 @@ export function codexOauthCompleted() {
211
221
  return false;
212
222
  return undefined;
213
223
  }
224
+ /** Per-harness sign-in probes; connect composes these rather than the descriptor carrying them. */
225
+ export const SIGN_IN_PROBES = {
226
+ codex: codexOauthCompleted,
227
+ };
214
228
  // This reaches the pasted report, so its shape is checked first.
215
229
  const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
230
+ const NATIVE_BY_HARNESS = new Map(NATIVE_MCP_CLIENTS.map((client) => [client.connectClient, client]));
231
+ /** Undefined unless the descriptor routes this harness's key through a variable. */
232
+ function declaredKeyEnvVar(harness) {
233
+ return NATIVE_BY_HARNESS.get(harness)?.keyAuth?.envVar;
234
+ }
216
235
  /** Codex stores a variable name; resolving it only answers whether it is the CLI's key. */
217
- function codexEnvKey(envVar) {
236
+ function envKeyVerdict(envVar) {
218
237
  if (!ENV_VAR_NAME.test(envVar)) {
219
238
  return { keyReason: "its key comes from an unusable variable name" };
220
239
  }
@@ -245,7 +264,7 @@ function probeCodex() {
245
264
  ? AuthMode.ApiKey
246
265
  : AuthMode.Unknown,
247
266
  ...(codexUrl ? { registeredUrl: codexUrl } : {}),
248
- ...(envVar ? codexEnvKey(envVar) : {}),
267
+ ...(envVar ? envKeyVerdict(envVar) : {}),
249
268
  };
250
269
  }
251
270
  function probeCursor() {
@@ -288,16 +307,11 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
288
307
  }
289
308
  // connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
290
309
  function probeHermes() {
291
- const probe = runProbe("hermes", ["mcp", "list"]);
292
- if (probe.outcome === "unavailable") {
293
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
294
- }
295
- if (probe.exitCode !== 0) {
296
- return {
297
- registered: Registered.Unknown,
298
- authMode: AuthMode.Unknown,
299
- reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
300
- };
310
+ const args = ["mcp", "list"];
311
+ const probe = runProbe("hermes", args);
312
+ if (probe.outcome !== "ran" || probe.exitCode !== 0) {
313
+ const reason = probeReason("hermes", args, probe);
314
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason };
301
315
  }
302
316
  return fromMcpList("hermes", probe.output, AuthMode.OAuth);
303
317
  }
@@ -323,16 +337,11 @@ function probeOpenClaw() {
323
337
  }
324
338
  // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
325
339
  function probeOpencode() {
326
- const probe = runProbe("opencode", ["mcp", "list"]);
327
- if (probe.outcome === "unavailable") {
328
- return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
329
- }
330
- if (probe.exitCode !== 0) {
331
- return {
332
- registered: Registered.Unknown,
333
- authMode: AuthMode.Unknown,
334
- reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
335
- };
340
+ const args = ["mcp", "list"];
341
+ const probe = runProbe("opencode", args);
342
+ if (probe.outcome !== "ran" || probe.exitCode !== 0) {
343
+ const reason = probeReason("opencode", args, probe);
344
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason };
336
345
  }
337
346
  return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
338
347
  }
@@ -377,9 +386,27 @@ function fromGrokPlugin() {
377
386
  reason: "registered by the TinyFish Grok plugin",
378
387
  };
379
388
  }
389
+ // Grok inherits our env, so a header we wrote round-trips to our own value.
390
+ function grokWroteIt(value, declared) {
391
+ const ours = process.env[declared];
392
+ return ours ? value === `Bearer ${ours}` : value === `Bearer \${${declared}}`;
393
+ }
394
+ /** The descriptor names the variable; envKeyVerdict owns every wording for it. */
395
+ function grokKeyAuth(headers) {
396
+ const authorization = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization");
397
+ if (!authorization)
398
+ return { authMode: AuthMode.OAuth };
399
+ const declared = declaredKeyEnvVar("grok");
400
+ const value = typeof authorization[1] === "string" ? authorization[1] : "";
401
+ if (!declared || !grokWroteIt(value, declared)) {
402
+ return { authMode: AuthMode.ApiKey, keyReason: "it holds a key this CLI cannot verify" };
403
+ }
404
+ return { authMode: AuthMode.ApiKey, ...envKeyVerdict(declared) };
405
+ }
380
406
  // Grok has no `mcp get`; `mcp list --json` is the only machine-readable view.
381
407
  function probeGrok() {
382
- const probe = runProbe("grok", ["mcp", "list", "--json"]);
408
+ const args = ["mcp", "list", "--json"];
409
+ const probe = runProbe("grok", args);
383
410
  if (probe.outcome === "unavailable") {
384
411
  return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
385
412
  }
@@ -394,7 +421,7 @@ function probeGrok() {
394
421
  return {
395
422
  registered: Registered.Unknown,
396
423
  authMode: AuthMode.Unknown,
397
- reason: `\`grok mcp list --json\` exited ${probe.exitCode}`,
424
+ reason: probeReason("grok", args, probe),
398
425
  };
399
426
  }
400
427
  let entries;
@@ -411,11 +438,9 @@ function probeGrok() {
411
438
  const entry = entries.find((server) => server.name === "tinyfish");
412
439
  if (!entry)
413
440
  return fromGrokPlugin() ?? { registered: Registered.No, authMode: AuthMode.Unknown };
414
- // Grok drops X-API-Key when the server advertises OAuth metadata, so keys ride Authorization.
415
- const hasAuthorization = Object.keys(entry.headers ?? {}).some((name) => name.toLowerCase() === "authorization");
416
441
  return {
417
442
  registered: Registered.Yes,
418
- authMode: hasAuthorization ? AuthMode.ApiKey : AuthMode.OAuth,
443
+ ...grokKeyAuth(entry.headers ?? {}),
419
444
  ...(entry.url ? { registeredUrl: entry.url } : {}),
420
445
  };
421
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.27.1-next.230",
3
+ "version": "0.28.1-next.233",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {