@tiny-fish/cli 0.23.1-next.204 → 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.
@@ -1,6 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
3
- import { CLAUDE_CODE, CODEX, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
3
+ import { CLAUDE_CODE, CODEX, CURSOR_SKILL_TARGET, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
4
4
  import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
5
5
  import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
@@ -266,6 +266,9 @@ export async function connectCursor(options) {
266
266
  await runGuarded(state, telemetry, async () => {
267
267
  telemetry.track("started");
268
268
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
269
+ state.stage = "skill_install";
270
+ installWebSkill(CURSOR_SKILL_TARGET);
271
+ telemetry.track("checkpoint", { phase: "skill_installed" });
269
272
  // Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
270
273
  state.stage = "authentication";
271
274
  ensureCliAuthenticated("cursor", options.apiKey);
@@ -7,7 +7,7 @@ import { detectHumanInitiated } from "../lib/harness.js";
7
7
  import { err, errLine, out, outLine } from "../lib/output.js";
8
8
  import { detectRegistrations } from "../lib/registration-detect.js";
9
9
  import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
10
- import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
10
+ import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
11
11
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
12
12
  function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
13
13
  // An endpoint doctor could not reach proves nothing about a harness pointed at it.
@@ -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(),
@@ -300,6 +291,7 @@ const CONNECTORS = {
300
291
  "claude-code": (opts) => connectClaudeCode({ ...opts, launch: false }),
301
292
  codex: (opts) => connectCodex({ ...opts, launch: false }),
302
293
  cursor: (opts) => connectCursor(opts),
294
+ grok: (opts) => connectGrok({ ...opts, launch: false }),
303
295
  hermes: (opts) => connectHermes({ ...opts, launch: false }),
304
296
  openclaw: (opts) => connectOpenClaw({ ...opts, launch: false }),
305
297
  opencode: (opts) => connectOpencode({ ...opts, launch: false }),
@@ -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 * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
- import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
3
+ import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
4
  import { validatedApiKey } from "./auth.js";
5
5
  import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
6
6
  import { ensureCliAuthenticated, installTinyFishCli, TINYFISH_CLI_NOT_FOUND_MESSAGE, } from "./connect-install.js";
@@ -16,6 +16,7 @@ const DISPLAY_NAMES = {
16
16
  "claude-code": "Claude Code",
17
17
  codex: "Codex",
18
18
  cursor: "Cursor",
19
+ grok: "Grok",
19
20
  hermes: "Hermes",
20
21
  openclaw: "OpenClaw",
21
22
  opencode: "OpenCode",
@@ -26,7 +27,6 @@ const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
26
27
  const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
27
28
  // Descriptor-derived so a harness that cannot carry a key is skipped, not failed, when headless.
28
29
  // Cursor and OpenClaw hold the key themselves rather than handing it to a harness `mcp add`.
29
- // `--all` only reaches harnesses it can detect; Grok has a keyAuth but no detection yet.
30
30
  const KEY_AUTH_CAPABLE = new Set([
31
31
  ...NATIVE_MCP_CLIENTS.filter((client) => !!client.keyAuth)
32
32
  .map((client) => client.connectClient)
@@ -40,6 +40,7 @@ const KEY_AUTH_CAPABLE = new Set([
40
40
  const UNINSTALL_POINTERS = {
41
41
  "claude-code": "claude mcp remove tinyfish --scope user",
42
42
  codex: "codex mcp remove tinyfish",
43
+ grok: "grok mcp remove tinyfish",
43
44
  hermes: "hermes mcp remove tinyfish",
44
45
  openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
45
46
  // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
@@ -60,8 +61,14 @@ async function runHarnessConnect(harness, opts, keyAuthOnly) {
60
61
  }
61
62
  if (harness === "opencode")
62
63
  return connectOpencode(base);
63
- await connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
64
- return undefined;
64
+ if (harness === "grok")
65
+ return connectGrok(base);
66
+ if (harness === "cursor") {
67
+ await connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
68
+ return undefined;
69
+ }
70
+ // Explicit: a new harness reaching an untested branch must fail, not write Cursor's config.
71
+ throw new Error(`No installer is wired for ${harness}`);
65
72
  }
66
73
  async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
67
74
  const health = await verifyMcpHealth(mcpUrl);
@@ -3,10 +3,17 @@ import { type AgentClient, type SupportedCommand } from "./connect-runtime.js";
3
3
  export declare const OPENCLAW_SKILL_INSTALL_ARGS: readonly string[];
4
4
  export declare const DEFAULT_ONBOARDING_PROMPT: string;
5
5
  export declare const OPENCLAW_ONBOARDING_PROMPT: string;
6
+ /** The `skills` CLI's own agent names — not ours, and not every harness has one. */
7
+ export type SkillAgent = "claude-code" | "codex" | "cursor" | "hermes-agent" | "opencode";
8
+ /** Cursor writes its own MCP config, so it has no descriptor to carry this. */
9
+ export declare const CURSOR_SKILL_TARGET: {
10
+ readonly skillAgent: "cursor";
11
+ readonly displayName: "Cursor";
12
+ };
6
13
  export interface NativeMcpClient extends SupportedCommand {
7
14
  connectClient: Exclude<AgentClient, "openclaw">;
8
15
  /** Omitted where the `skills` CLI has no agent for this harness (Grok ships its own plugin). */
9
- skillAgent?: "claude-code" | "codex" | "hermes-agent" | "opencode";
16
+ skillAgent?: SkillAgent;
10
17
  /** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
11
18
  keyAuth?: {
12
19
  /** Key reaches the client through TINYFISH_API_KEY, not argv. */
@@ -62,6 +62,8 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
62
62
  "want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
63
63
  "which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
64
64
  "I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
65
+ /** Cursor writes its own MCP config, so it has no descriptor to carry this. */
66
+ export const CURSOR_SKILL_TARGET = { skillAgent: "cursor", displayName: "Cursor" };
65
67
  const HEADER_FLAG = /(?:^|\s)--header(?:[\s<=]|$)/m;
66
68
  const claudeAddArgs = (mcpUrl) => [
67
69
  "mcp",
@@ -1,4 +1,4 @@
1
- import { type NativeMcpClient } from "./connect-clients.js";
1
+ import { type SkillAgent } from "./connect-clients.js";
2
2
  import { type AgentClient } from "./connect-runtime.js";
3
3
  export declare const SKILL_INSTALL_TIMEOUT_MS = 120000;
4
4
  export declare const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
@@ -8,7 +8,10 @@ export type InstallOptions = {
8
8
  verbose: boolean;
9
9
  };
10
10
  export declare function installTinyFishCli({ verbose }: InstallOptions): void;
11
- export declare function installWebSkill(client: NativeMcpClient): void;
11
+ export declare function installWebSkill(client: {
12
+ skillAgent?: SkillAgent;
13
+ displayName: string;
14
+ }): void;
12
15
  /**
13
16
  * Refresh the skill we installed, for the harnesses we installed it in.
14
17
  *
@@ -1,7 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import spawn from "cross-spawn";
3
3
  import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
4
- import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
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
6
  import { TINYFISH_CLI_PACKAGE } from "./constants.js";
7
7
  import { installRoot } from "./install-root.js";
@@ -112,13 +112,14 @@ export function installWebSkill(client) {
112
112
  export function updateWebSkill({ verbose }) {
113
113
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
114
114
  const connected = loadConfig().connect ?? {};
115
- const skillAgents = NATIVE_MCP_CLIENTS
116
- .filter((client) => connected[client.connectClient])
117
- .map((client) => client.skillAgent)
118
- .filter((agent) => agent !== undefined);
115
+ const skillAgents = [
116
+ ...NATIVE_MCP_CLIENTS.filter((client) => connected[client.connectClient]).map((client) => client.skillAgent),
117
+ // Cursor is not a NativeMcpClient — it writes its own MCP config.
118
+ ...(connected["cursor"] ? [CURSOR_SKILL_TARGET.skillAgent] : []),
119
+ ].filter((agent) => agent !== undefined);
119
120
  const hasOpenClaw = Boolean(connected["openclaw"]);
120
121
  // Installs predating PF-3169 recorded nothing; ask `skills` to refresh what it tracks. A
121
- // skill-less recorded install (Grok, Cursor) may still have one from before, so refresh anyway
122
+ // skill-less recorded install (Grok) may still have one from before, so refresh anyway
122
123
  // — but there having been none is then expected, not an upgrade failure.
123
124
  if (skillAgents.length === 0 && !hasOpenClaw) {
124
125
  return refreshWebSkillByHash(verbose, Object.keys(connected).length > 0);
@@ -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
  }
@@ -25,6 +25,7 @@ declare const doctorCheckSchema: z.ZodObject<{
25
25
  detail: z.ZodString;
26
26
  harness: z.ZodNullable<z.ZodEnum<{
27
27
  openclaw: "openclaw";
28
+ grok: "grok";
28
29
  cursor: "cursor";
29
30
  codex: "codex";
30
31
  hermes: "hermes";
@@ -40,6 +41,7 @@ declare const doctorCheckSchema: z.ZodObject<{
40
41
  declare const doctorHarnessSchema: z.ZodObject<{
41
42
  harness: z.ZodEnum<{
42
43
  openclaw: "openclaw";
44
+ grok: "grok";
43
45
  cursor: "cursor";
44
46
  codex: "codex";
45
47
  hermes: "hermes";
@@ -59,6 +61,7 @@ declare const doctorRepairSchema: z.ZodObject<{
59
61
  }>;
60
62
  harness: z.ZodNullable<z.ZodEnum<{
61
63
  openclaw: "openclaw";
64
+ grok: "grok";
62
65
  cursor: "cursor";
63
66
  codex: "codex";
64
67
  hermes: "hermes";
@@ -85,6 +88,7 @@ export declare const doctorReportSchema: z.ZodObject<{
85
88
  detail: z.ZodString;
86
89
  harness: z.ZodNullable<z.ZodEnum<{
87
90
  openclaw: "openclaw";
91
+ grok: "grok";
88
92
  cursor: "cursor";
89
93
  codex: "codex";
90
94
  hermes: "hermes";
@@ -100,6 +104,7 @@ export declare const doctorReportSchema: z.ZodObject<{
100
104
  harnesses: z.ZodArray<z.ZodObject<{
101
105
  harness: z.ZodEnum<{
102
106
  openclaw: "openclaw";
107
+ grok: "grok";
103
108
  cursor: "cursor";
104
109
  codex: "codex";
105
110
  hermes: "hermes";
@@ -119,6 +124,7 @@ export declare const doctorReportSchema: z.ZodObject<{
119
124
  }>;
120
125
  harness: z.ZodNullable<z.ZodEnum<{
121
126
  openclaw: "openclaw";
127
+ grok: "grok";
122
128
  cursor: "cursor";
123
129
  codex: "codex";
124
130
  hermes: "hermes";
@@ -1,4 +1,4 @@
1
- export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw", "opencode"];
1
+ export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
2
2
  export type Harness = (typeof ALL_HARNESSES)[number];
3
3
  export declare enum Registered {
4
4
  Yes = "yes",
@@ -5,6 +5,7 @@ export const ALL_HARNESSES = [
5
5
  "claude-code",
6
6
  "codex",
7
7
  "cursor",
8
+ "grok",
8
9
  "hermes",
9
10
  "openclaw",
10
11
  "opencode",
@@ -27,6 +28,7 @@ const CONFIG_DIRS = {
27
28
  "claude-code": ".claude",
28
29
  codex: ".codex",
29
30
  cursor: ".cursor",
31
+ grok: ".grok",
30
32
  hermes: ".hermes",
31
33
  openclaw: ".openclaw",
32
34
  opencode: ".config/opencode",
@@ -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,10 +336,60 @@ function probeOpencode() {
342
336
  }
343
337
  return fromMcpList("opencode", probe.output, AuthMode.Unknown, OPENCODE_ROW_URL);
344
338
  }
339
+ // Verified against grok 0.0.60: a list. Header values stay `unknown`; only names are read.
340
+ const grokEntrySchema = z.looseObject({
341
+ name: z.string().optional(),
342
+ url: z.string().optional(),
343
+ headers: z.record(z.string(), z.unknown()).optional(),
344
+ });
345
+ const grokListSchema = z.array(grokEntrySchema);
346
+ // Grok has no `mcp get`; `mcp list --json` is the only machine-readable view.
347
+ function probeGrok() {
348
+ const probe = runProbe("grok", ["mcp", "list", "--json"]);
349
+ if (probe.outcome === "unavailable") {
350
+ return { registered: Registered.Unknown, authMode: AuthMode.Unknown, reason: probe.reason };
351
+ }
352
+ if (probe.exitCode !== 0) {
353
+ if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
354
+ return {
355
+ registered: Registered.Unknown,
356
+ authMode: AuthMode.Unknown,
357
+ reason: "this Grok build has no `mcp list --json`",
358
+ };
359
+ }
360
+ return {
361
+ registered: Registered.Unknown,
362
+ authMode: AuthMode.Unknown,
363
+ reason: `\`grok mcp list --json\` exited ${probe.exitCode}`,
364
+ };
365
+ }
366
+ let entries;
367
+ try {
368
+ entries = grokListSchema.parse(JSON.parse(probe.stdout));
369
+ }
370
+ catch {
371
+ return {
372
+ registered: Registered.Unknown,
373
+ authMode: AuthMode.Unknown,
374
+ reason: "`grok mcp list --json` did not return the expected shape",
375
+ };
376
+ }
377
+ const entry = entries.find((server) => server.name === "tinyfish");
378
+ if (!entry)
379
+ return { registered: Registered.No, authMode: AuthMode.Unknown };
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");
382
+ return {
383
+ registered: Registered.Yes,
384
+ authMode: hasAuthorization ? AuthMode.ApiKey : AuthMode.OAuth,
385
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
386
+ };
387
+ }
345
388
  const PROBES = {
346
- "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
389
+ "claude-code": () => fromMcpGet("claude"),
347
390
  codex: probeCodex,
348
391
  cursor: probeCursor,
392
+ grok: probeGrok,
349
393
  hermes: probeHermes,
350
394
  openclaw: probeOpenClaw,
351
395
  opencode: probeOpencode,
@@ -25,6 +25,7 @@ export declare function postConnectEvent(options: {
25
25
  declare const harnessResultSchema: z.ZodObject<{
26
26
  harness: z.ZodEnum<{
27
27
  openclaw: "openclaw";
28
+ grok: "grok";
28
29
  cursor: "cursor";
29
30
  codex: "codex";
30
31
  hermes: "hermes";
@@ -43,6 +44,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
43
44
  harnesses: z.ZodArray<z.ZodObject<{
44
45
  harness: z.ZodEnum<{
45
46
  openclaw: "openclaw";
47
+ grok: "grok";
46
48
  cursor: "cursor";
47
49
  codex: "codex";
48
50
  hermes: "hermes";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.23.1-next.204",
3
+ "version": "0.23.1-next.206",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {