@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.
- package/dist/commands/connect.js +4 -1
- package/dist/commands/doctor.js +12 -20
- package/dist/lib/auth.d.ts +2 -2
- package/dist/lib/auth.js +5 -1
- package/dist/lib/connect-all.js +11 -4
- package/dist/lib/connect-clients.d.ts +8 -1
- package/dist/lib/connect-clients.js +2 -0
- package/dist/lib/connect-install.d.ts +5 -2
- package/dist/lib/connect-install.js +7 -6
- package/dist/lib/cursor-config.d.ts +2 -2
- package/dist/lib/cursor-config.js +4 -3
- package/dist/lib/doctor-report.d.ts +6 -0
- package/dist/lib/harness-detect.d.ts +1 -1
- package/dist/lib/harness-detect.js +2 -0
- package/dist/lib/registration-detect.d.ts +2 -4
- package/dist/lib/registration-detect.js +64 -20
- package/dist/lib/setup-telemetry.d.ts +2 -0
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -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);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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(),
|
|
@@ -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 }),
|
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);
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
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?:
|
|
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
|
|
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:
|
|
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 =
|
|
116
|
-
.filter((client) => connected[client.connectClient])
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
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,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"
|
|
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";
|