@tiny-fish/cli 0.14.1-next.154 → 0.15.1-next.158
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/config-claude.js +2 -6
- package/dist/commands/connect.js +42 -19
- package/dist/lib/auth.d.ts +18 -0
- package/dist/lib/auth.js +40 -30
- package/dist/lib/connect-all.js +5 -9
- package/dist/lib/cursor-config.d.ts +9 -0
- package/dist/lib/cursor-config.js +19 -0
- package/dist/lib/harness-detect.d.ts +2 -0
- package/dist/lib/harness-detect.js +4 -0
- package/dist/lib/registration-detect.d.ts +15 -0
- package/dist/lib/registration-detect.js +239 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { err, errLine, out } from "../lib/output.js";
|
|
2
|
-
import {
|
|
2
|
+
import { validatedApiKey } from "../lib/auth.js";
|
|
3
3
|
import { readSettingsJson, writeSettingsJson, readClaudeMd, writeClaudeMd, mergeSettings, removeFromSettings, mergeClaudeMd, removeFromClaudeMd, claudeSettingsPath, claudeMdPath, isTinyfishConfiguredInSettings, isTinyfishConfiguredInClaudeMd, } from "../lib/claude-config.js";
|
|
4
4
|
function loadExistingConfig() {
|
|
5
5
|
let settings;
|
|
@@ -25,11 +25,7 @@ function loadExistingConfig() {
|
|
|
25
25
|
return { settings, claudeMd };
|
|
26
26
|
}
|
|
27
27
|
function isSignedIn() {
|
|
28
|
-
|
|
29
|
-
if (envKey && validateKeyFormat(envKey))
|
|
30
|
-
return true;
|
|
31
|
-
const config = loadConfig();
|
|
32
|
-
return !!config.api_key && validateKeyFormat(config.api_key);
|
|
28
|
+
return !!validatedApiKey();
|
|
33
29
|
}
|
|
34
30
|
function install(settings, claudeMd) {
|
|
35
31
|
errLine("Configuring Claude Code to use TinyFish...\n");
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import spawn from "cross-spawn";
|
|
4
|
-
import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
|
|
4
|
+
import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, validatedApiKey, writeConfig, } from "../lib/auth.js";
|
|
5
5
|
import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
|
|
6
6
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
7
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
@@ -16,17 +16,29 @@ export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
|
16
16
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
17
17
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
18
18
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
20
|
+
const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
|
|
21
|
+
const MCP_LOGIN_UNAVAILABLE_MESSAGE = "Could not confirm this Claude Code installation exposes `claude mcp login`: `claude mcp " +
|
|
22
|
+
"--help` did not list it. Update Claude Code and retry, or authenticate TinyFish manually " +
|
|
23
|
+
"from /mcp." +
|
|
24
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
25
|
+
const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "Could not confirm this Codex installation supports one-command MCP authentication: `codex " +
|
|
26
|
+
"mcp add --help` did not list `--oauth-resource`. Update Codex and retry, or add TinyFish " +
|
|
27
|
+
"manually with `codex mcp add`." +
|
|
28
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
29
|
+
const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "Could not confirm this Hermes installation supports one-command MCP authentication: `hermes " +
|
|
30
|
+
"mcp add --help` did not list `--auth oauth`. Update Hermes and retry, or add TinyFish " +
|
|
31
|
+
"manually with `hermes mcp add`." +
|
|
32
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
33
|
+
const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "Could not confirm this OpenClaw installation supports global skill installation: `openclaw " +
|
|
34
|
+
"skills install --help` did not list `--global` and `--acknowledge-clawhub-risk`. Update " +
|
|
35
|
+
"OpenClaw and retry, or install manually with `openclaw skills install @tinyfish/tinyfish " +
|
|
36
|
+
"--global --acknowledge-clawhub-risk`." +
|
|
37
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
38
|
+
const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp " +
|
|
39
|
+
"add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with " +
|
|
40
|
+
"`opencode mcp add`." +
|
|
41
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
30
42
|
// OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
|
|
31
43
|
// model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
|
|
32
44
|
const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
|
|
@@ -192,7 +204,8 @@ function launchHermesWalkthrough() {
|
|
|
192
204
|
cause: seedResult.error,
|
|
193
205
|
});
|
|
194
206
|
}
|
|
195
|
-
|
|
207
|
+
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
208
|
+
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
196
209
|
if (!sessionId) {
|
|
197
210
|
throw new Error("Hermes did not return a walkthrough session ID");
|
|
198
211
|
}
|
|
@@ -297,6 +310,8 @@ function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
297
310
|
runtime_platform: process.platform,
|
|
298
311
|
node_version: process.version,
|
|
299
312
|
cli_version: CLI_VERSION,
|
|
313
|
+
// Same signal the usage events carry, so TTY and headless connects can be split.
|
|
314
|
+
is_human_initiated: detectHumanInitiated(),
|
|
300
315
|
})));
|
|
301
316
|
},
|
|
302
317
|
// Awaited in finally so in-flight events land before exit.
|
|
@@ -308,6 +323,8 @@ function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
308
323
|
function requireCommandSupport(client) {
|
|
309
324
|
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
310
325
|
encoding: "utf8",
|
|
326
|
+
// Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
|
|
327
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
311
328
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
312
329
|
});
|
|
313
330
|
if (commandNotFound(result.error)) {
|
|
@@ -317,8 +334,12 @@ function requireCommandSupport(client) {
|
|
|
317
334
|
throwIfInterrupted(result);
|
|
318
335
|
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
|
|
319
336
|
}
|
|
320
|
-
|
|
337
|
+
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
338
|
+
const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
321
339
|
if (!client.supportCheck.patterns.every((pattern) => pattern.test(output))) {
|
|
340
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
341
|
+
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
342
|
+
}
|
|
322
343
|
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
|
|
323
344
|
harnessVersion: probeHarnessVersion(client.command),
|
|
324
345
|
});
|
|
@@ -572,8 +593,8 @@ async function connectNativeMcpClient(client, options) {
|
|
|
572
593
|
mcpUrl.searchParams.set("client", client.connectClient);
|
|
573
594
|
mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
|
|
574
595
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
575
|
-
const storedKey = options.apiKey
|
|
576
|
-
const useKeyAuth = client.headerAuthSupported === true && !!storedKey
|
|
596
|
+
const storedKey = validatedApiKey(options.apiKey);
|
|
597
|
+
const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
|
|
577
598
|
state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
|
|
578
599
|
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
579
600
|
const addResult = spawn.sync(client.command, [
|
|
@@ -747,9 +768,11 @@ export async function connectCursor(options) {
|
|
|
747
768
|
state.stage = "authentication";
|
|
748
769
|
ensureCliAuthenticated("cursor", options.apiKey);
|
|
749
770
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
771
|
+
const resolvedKey = validatedApiKey(options.apiKey);
|
|
772
|
+
// ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
|
|
773
|
+
if (!resolvedKey) {
|
|
774
|
+
errLine("Ignoring the stored API key: invalid format. Run: tinyfish auth login");
|
|
775
|
+
}
|
|
753
776
|
state.stage = "registration";
|
|
754
777
|
errLine("Adding TinyFish to Cursor...");
|
|
755
778
|
const mcpUrl = new URL(options.mcpUrl);
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -20,8 +20,26 @@ interface EnvironmentOptions {
|
|
|
20
20
|
}
|
|
21
21
|
export declare function persistApiKeyToEnvironment(apiKey: string, options?: EnvironmentOptions): void;
|
|
22
22
|
export declare function clearConfig(): boolean;
|
|
23
|
+
/** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
|
|
24
|
+
export declare function isHeaderSafe(key: string): boolean;
|
|
23
25
|
export declare function validateKeyFormat(key: string): boolean;
|
|
24
26
|
export declare function maskKey(key: string): string;
|
|
27
|
+
export type KeySource = "explicit" | "env" | "config" | "none";
|
|
28
|
+
export interface ResolvedKey {
|
|
29
|
+
key: string;
|
|
30
|
+
source: Exclude<KeySource, "none">;
|
|
31
|
+
}
|
|
32
|
+
export interface UnresolvedKey {
|
|
33
|
+
error: string;
|
|
34
|
+
source: KeySource;
|
|
35
|
+
}
|
|
25
36
|
/** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
|
|
26
37
|
export declare function resolvedApiKey(explicitApiKey?: string): string | undefined;
|
|
38
|
+
/** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
|
|
39
|
+
export declare function validatedApiKey(explicitApiKey?: string): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The diagnosis, not the key: doctor must report a bad credential and say where it came from
|
|
42
|
+
* rather than exit like getApiKey does.
|
|
43
|
+
*/
|
|
44
|
+
export declare function apiKeyStatus(): ResolvedKey | UnresolvedKey;
|
|
27
45
|
export declare function getApiKey(): string;
|
package/dist/lib/auth.js
CHANGED
|
@@ -132,52 +132,62 @@ export function clearConfig() {
|
|
|
132
132
|
return false;
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
/** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
|
|
136
|
+
export function isHeaderSafe(key) {
|
|
137
137
|
for (let i = 0; i < key.length; i += 1) {
|
|
138
138
|
if (key.charCodeAt(i) < 0x20)
|
|
139
139
|
return false;
|
|
140
140
|
}
|
|
141
|
-
return
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
export function validateKeyFormat(key) {
|
|
144
|
+
return isHeaderSafe(key) && KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
|
|
142
145
|
}
|
|
143
146
|
export function maskKey(key) {
|
|
144
147
|
if (key.length <= 8)
|
|
145
148
|
return "***";
|
|
146
149
|
return key.slice(0, 12) + "..." + key.slice(-4);
|
|
147
150
|
}
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
// The one flag/env/config precedence chain; `source` says which place a caller must name.
|
|
152
|
+
function lookupApiKey(explicitApiKey) {
|
|
150
153
|
if (explicitApiKey)
|
|
151
|
-
return explicitApiKey;
|
|
154
|
+
return { value: explicitApiKey, source: "explicit" };
|
|
152
155
|
const envKey = process.env["TINYFISH_API_KEY"];
|
|
153
156
|
if (envKey)
|
|
154
|
-
return envKey;
|
|
155
|
-
try {
|
|
156
|
-
|
|
157
|
+
return { value: envKey, source: "env" };
|
|
158
|
+
// No try/catch: loadConfig swallows its own errors and returns {}.
|
|
159
|
+
const stored = loadConfig().api_key;
|
|
160
|
+
return stored ? { value: stored, source: "config" } : { source: "none" };
|
|
161
|
+
}
|
|
162
|
+
/** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
|
|
163
|
+
export function resolvedApiKey(explicitApiKey) {
|
|
164
|
+
return lookupApiKey(explicitApiKey).value;
|
|
165
|
+
}
|
|
166
|
+
/** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
|
|
167
|
+
export function validatedApiKey(explicitApiKey) {
|
|
168
|
+
const key = resolvedApiKey(explicitApiKey);
|
|
169
|
+
return key && validateKeyFormat(key) ? key : undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The diagnosis, not the key: doctor must report a bad credential and say where it came from
|
|
173
|
+
* rather than exit like getApiKey does.
|
|
174
|
+
*/
|
|
175
|
+
export function apiKeyStatus() {
|
|
176
|
+
const { value, source } = lookupApiKey();
|
|
177
|
+
if (!value || source === "none") {
|
|
178
|
+
return { error: "No API key found. Run: tinyfish auth login", source: "none" };
|
|
157
179
|
}
|
|
158
|
-
|
|
159
|
-
|
|
180
|
+
// Shape is the server's call. A local prefix rule turns a key format we have not shipped
|
|
181
|
+
// support for yet into a hard failure the user cannot work around, and the 401 says more.
|
|
182
|
+
if (!isHeaderSafe(value)) {
|
|
183
|
+
return { error: "The API key contains control characters and cannot be sent", source };
|
|
160
184
|
}
|
|
185
|
+
return { key: value, source };
|
|
161
186
|
}
|
|
162
187
|
export function getApiKey() {
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-...",
|
|
168
|
-
});
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
return envKey;
|
|
172
|
-
}
|
|
173
|
-
const config = loadConfig();
|
|
174
|
-
if (config.api_key) {
|
|
175
|
-
if (!validateKeyFormat(config.api_key)) {
|
|
176
|
-
err({ error: "Stored API key has invalid format. Run: tinyfish auth login" });
|
|
177
|
-
process.exit(1);
|
|
178
|
-
}
|
|
179
|
-
return config.api_key;
|
|
180
|
-
}
|
|
181
|
-
err({ error: "No API key found. Run: tinyfish auth login" });
|
|
188
|
+
const resolved = apiKeyStatus();
|
|
189
|
+
if ("key" in resolved)
|
|
190
|
+
return resolved.key;
|
|
191
|
+
err({ error: resolved.error });
|
|
182
192
|
process.exit(1);
|
|
183
193
|
}
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as readline from "node:readline/promises";
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
|
|
4
|
-
import {
|
|
4
|
+
import { validatedApiKey } from "./auth.js";
|
|
5
5
|
import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
|
|
6
6
|
import { detectHumanInitiated } from "./harness.js";
|
|
7
7
|
import { emitNotice } from "./notice.js";
|
|
@@ -29,10 +29,6 @@ const UNINSTALL_POINTERS = {
|
|
|
29
29
|
hermes: "hermes mcp remove tinyfish",
|
|
30
30
|
openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
|
|
31
31
|
};
|
|
32
|
-
function hasStoredAuth(explicitApiKey) {
|
|
33
|
-
const key = resolvedApiKey(explicitApiKey);
|
|
34
|
-
return !!key && validateKeyFormat(key);
|
|
35
|
-
}
|
|
36
32
|
async function runHarnessConnect(harness, opts) {
|
|
37
33
|
const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
|
|
38
34
|
if (harness === "claude-code")
|
|
@@ -102,7 +98,7 @@ async function processHarness(detection, opts, prompt) {
|
|
|
102
98
|
if (opts.dryRun) {
|
|
103
99
|
const plan = opts.uninstall
|
|
104
100
|
? uninstallPlanText(harness)
|
|
105
|
-
: planText(harness, opts.mcpUrl,
|
|
101
|
+
: planText(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
|
|
106
102
|
return { ...base, outcome: "dry_run", fixCommand: plan };
|
|
107
103
|
}
|
|
108
104
|
if (opts.uninstall)
|
|
@@ -110,7 +106,7 @@ async function processHarness(detection, opts, prompt) {
|
|
|
110
106
|
const isTTY = detectHumanInitiated();
|
|
111
107
|
// claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
|
|
112
108
|
// X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
|
|
113
|
-
const keyed =
|
|
109
|
+
const keyed = !!validatedApiKey(opts.apiKey);
|
|
114
110
|
const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
|
|
115
111
|
const alreadyAuthed = !ownAuthHarness && keyed;
|
|
116
112
|
if (!isTTY && !alreadyAuthed) {
|
|
@@ -142,7 +138,7 @@ async function processHarness(detection, opts, prompt) {
|
|
|
142
138
|
if (!interruptedBefore && process.exitCode === 130) {
|
|
143
139
|
return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
|
|
144
140
|
}
|
|
145
|
-
const verify = await verifyHarness(harness, opts.mcpUrl,
|
|
141
|
+
const verify = await verifyHarness(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
|
|
146
142
|
return {
|
|
147
143
|
...base,
|
|
148
144
|
installed: true,
|
|
@@ -279,7 +275,7 @@ export async function runConnectAll(opts) {
|
|
|
279
275
|
verify_depth: r.verifyDepth ?? null,
|
|
280
276
|
verify_ok: r.verifyOk ?? null,
|
|
281
277
|
}));
|
|
282
|
-
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry,
|
|
278
|
+
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey));
|
|
283
279
|
// After the summary and before the launch, which is where the user is reading.
|
|
284
280
|
emitNotice();
|
|
285
281
|
}
|
|
@@ -9,6 +9,15 @@ export interface CursorMcpWriteResult {
|
|
|
9
9
|
}
|
|
10
10
|
/** Dry-run description of the pending write; touches nothing. */
|
|
11
11
|
export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
|
|
12
|
+
export interface CursorTinyfishEntry {
|
|
13
|
+
present: boolean;
|
|
14
|
+
hasApiKeyHeader: boolean;
|
|
15
|
+
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
16
|
+
url?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Read-only probe for doctor — reports shape, never the header value. */
|
|
20
|
+
export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
|
|
12
21
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
13
22
|
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
14
23
|
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
@@ -65,6 +65,25 @@ export function planCursorWrite(mcpUrl, apiKey) {
|
|
|
65
65
|
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
66
66
|
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
67
67
|
}
|
|
68
|
+
/** Read-only probe for doctor — reports shape, never the header value. */
|
|
69
|
+
export function readCursorTinyfishEntry() {
|
|
70
|
+
const existing = readExisting();
|
|
71
|
+
if ("error" in existing)
|
|
72
|
+
return { present: false, hasApiKeyHeader: false, error: existing.error };
|
|
73
|
+
const servers = existing.parsed.mcpServers;
|
|
74
|
+
const entry = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
|
|
75
|
+
if (!isPlainRecord(entry))
|
|
76
|
+
return { present: false, hasApiKeyHeader: false };
|
|
77
|
+
const headers = entry.headers;
|
|
78
|
+
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
79
|
+
// casing we write would understate auth mode for a user who typed it differently.
|
|
80
|
+
return {
|
|
81
|
+
present: true,
|
|
82
|
+
hasApiKeyHeader: isPlainRecord(headers) &&
|
|
83
|
+
Object.entries(headers).some(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string"),
|
|
84
|
+
...(typeof entry.url === "string" ? { url: entry.url } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
68
87
|
// Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
|
|
69
88
|
function commitServers(filePath, existing, servers) {
|
|
70
89
|
fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
|
|
2
2
|
export type FiveHarness = (typeof FIVE_HARNESSES)[number];
|
|
3
3
|
export declare function harnessConfigPath(harness: FiveHarness): string;
|
|
4
|
+
/** For reason strings that name a location; keeps them in sync with the table above. */
|
|
5
|
+
export declare function harnessDisplayPath(harness: FiveHarness): string;
|
|
4
6
|
export interface HarnessDetection {
|
|
5
7
|
harness: FiveHarness;
|
|
6
8
|
detected: boolean;
|
|
@@ -13,6 +13,10 @@ const CONFIG_DIRS = {
|
|
|
13
13
|
export function harnessConfigPath(harness) {
|
|
14
14
|
return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
|
|
15
15
|
}
|
|
16
|
+
/** For reason strings that name a location; keeps them in sync with the table above. */
|
|
17
|
+
export function harnessDisplayPath(harness) {
|
|
18
|
+
return `~/${CONFIG_DIRS[harness]}`;
|
|
19
|
+
}
|
|
16
20
|
export function detectInstalledHarnesses() {
|
|
17
21
|
return FIVE_HARNESSES.map((harness) => {
|
|
18
22
|
const configPath = harnessConfigPath(harness);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type FiveHarness } from "./harness-detect.js";
|
|
2
|
+
export type Registered = "yes" | "no" | "unknown";
|
|
3
|
+
export type AuthMode = "api-key" | "oauth" | "unknown";
|
|
4
|
+
export interface RegistrationStatus {
|
|
5
|
+
harness: FiveHarness;
|
|
6
|
+
detected: boolean;
|
|
7
|
+
registered: Registered;
|
|
8
|
+
authMode: AuthMode;
|
|
9
|
+
connectedBefore: boolean;
|
|
10
|
+
/** The endpoint the harness is actually pointed at, when the probe exposes it. */
|
|
11
|
+
registeredUrl?: string;
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
|
|
15
|
+
export declare function detectRegistrations(harnesses?: FiveHarness[]): RegistrationStatus[];
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import spawn from "cross-spawn";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { loadConfig } from "./auth.js";
|
|
6
|
+
import { errLine } from "./output.js";
|
|
7
|
+
import { readCursorTinyfishEntry } from "./cursor-config.js";
|
|
8
|
+
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
|
|
9
|
+
// 2s tripped on a cold `hermes mcp list` (0.3s warm), reporting `unknown` on a healthy install.
|
|
10
|
+
const PROBE_TIMEOUT_MS = 6_000;
|
|
11
|
+
// A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
|
|
12
|
+
// field is optional, so that arm parsed *any* object-of-objects: a wrapper like
|
|
13
|
+
// `{"servers": {...}}` decoded as one entry named `servers` and reported TinyFish absent.
|
|
14
|
+
const codexEntrySchema = z.looseObject({
|
|
15
|
+
name: z.string().optional(),
|
|
16
|
+
auth_status: z.string().optional(),
|
|
17
|
+
transport: z
|
|
18
|
+
.looseObject({ url: z.string().optional(), bearer_token_env_var: z.string().nullish() })
|
|
19
|
+
.optional(),
|
|
20
|
+
});
|
|
21
|
+
const codexListSchema = z.array(codexEntrySchema);
|
|
22
|
+
// An old harness CLI without the subcommand exits non-zero too; that is unknown, not absent.
|
|
23
|
+
const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid subcommand/i;
|
|
24
|
+
// `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
|
|
25
|
+
const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
|
|
26
|
+
const API_KEY_HEADER = /x-api-key/i;
|
|
27
|
+
// The report is pasted in public, so every reason is authored here. A spawn error's own
|
|
28
|
+
// message quotes absolute paths, which is how a home directory reaches the payload.
|
|
29
|
+
function spawnFailureReason(command, code) {
|
|
30
|
+
if (code === "ENOENT")
|
|
31
|
+
return `\`${command}\` is not on PATH`;
|
|
32
|
+
if (code === "EACCES")
|
|
33
|
+
return `\`${command}\` is not executable`;
|
|
34
|
+
return `could not run \`${command}\``;
|
|
35
|
+
}
|
|
36
|
+
function runProbe(command, args) {
|
|
37
|
+
const result = spawn.sync(command, args, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
|
|
38
|
+
if (result.error) {
|
|
39
|
+
const code = result.error.code;
|
|
40
|
+
// A timeout arrives as ETIMEDOUT here, not only as a signal, so it must be named before
|
|
41
|
+
// the generic spawn failure; otherwise a timeout reports "could not run".
|
|
42
|
+
if (code === "ETIMEDOUT") {
|
|
43
|
+
return { outcome: "unavailable", reason: `\`${command} ${args.join(" ")}\` timed out` };
|
|
44
|
+
}
|
|
45
|
+
return { outcome: "unavailable", reason: spawnFailureReason(command, code) };
|
|
46
|
+
}
|
|
47
|
+
// Reached only when something else killed the child, so this is a crash, not the timeout.
|
|
48
|
+
if (result.signal) {
|
|
49
|
+
return { outcome: "unavailable", reason: `\`${command}\` was killed by ${result.signal}` };
|
|
50
|
+
}
|
|
51
|
+
const stdout = result.stdout ?? "";
|
|
52
|
+
return {
|
|
53
|
+
outcome: "ran",
|
|
54
|
+
exitCode: result.status ?? 1,
|
|
55
|
+
stdout,
|
|
56
|
+
output: `${stdout}${result.stderr ?? ""}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
|
|
60
|
+
function fromMcpGet(command, keyAuthPattern) {
|
|
61
|
+
const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
|
|
62
|
+
if (probe.outcome === "unavailable") {
|
|
63
|
+
return { registered: "unknown", authMode: "unknown", reason: probe.reason };
|
|
64
|
+
}
|
|
65
|
+
if (probe.exitCode !== 0) {
|
|
66
|
+
if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
|
|
67
|
+
return {
|
|
68
|
+
registered: "unknown",
|
|
69
|
+
authMode: "unknown",
|
|
70
|
+
reason: `\`${command} mcp get\` is unsupported by this version`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
|
|
74
|
+
if (!NO_SUCH_SERVER.test(probe.output)) {
|
|
75
|
+
return {
|
|
76
|
+
registered: "unknown",
|
|
77
|
+
authMode: "unknown",
|
|
78
|
+
reason: `\`${command} mcp get\` failed without reporting the server as absent`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { registered: "no", authMode: "unknown" };
|
|
82
|
+
}
|
|
83
|
+
// Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
|
|
84
|
+
// of the pattern is `unknown`, never proof of OAuth.
|
|
85
|
+
const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
|
|
86
|
+
return {
|
|
87
|
+
registered: "yes",
|
|
88
|
+
authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
|
|
89
|
+
...(url ? { registeredUrl: url } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
|
|
93
|
+
// its exit code; `mcp list --json` is the only trustworthy read-back.
|
|
94
|
+
function probeCodex() {
|
|
95
|
+
const probe = runProbe("codex", ["mcp", "list", "--json"]);
|
|
96
|
+
if (probe.outcome === "unavailable") {
|
|
97
|
+
return { registered: "unknown", authMode: "unknown", reason: probe.reason };
|
|
98
|
+
}
|
|
99
|
+
// Presence is never inferred from exit code 0, but a nonzero exit means the output cannot
|
|
100
|
+
// be trusted either — that is `unknown`, not "no server".
|
|
101
|
+
if (probe.exitCode !== 0) {
|
|
102
|
+
return {
|
|
103
|
+
registered: "unknown",
|
|
104
|
+
authMode: "unknown",
|
|
105
|
+
reason: `\`codex mcp list --json\` exited ${probe.exitCode}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
let servers;
|
|
109
|
+
try {
|
|
110
|
+
// stdout only: an update banner on stderr would otherwise break the parse and report a
|
|
111
|
+
// healthy install as `unknown`.
|
|
112
|
+
servers = JSON.parse(probe.stdout);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {
|
|
116
|
+
registered: "unknown",
|
|
117
|
+
authMode: "unknown",
|
|
118
|
+
reason: "`codex mcp list --json` returned unparseable output",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// A scalar or a wrapper object decodes fine and then silently reports "no server", which
|
|
122
|
+
// would suggest a spurious repair. Anything but a list is unknown.
|
|
123
|
+
const shape = codexListSchema.safeParse(servers);
|
|
124
|
+
if (!shape.success) {
|
|
125
|
+
return {
|
|
126
|
+
registered: "unknown",
|
|
127
|
+
authMode: "unknown",
|
|
128
|
+
reason: "`codex mcp list --json` returned an unexpected shape",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const entry = shape.data.find((s) => s.name === "tinyfish");
|
|
132
|
+
if (!entry)
|
|
133
|
+
return { registered: "no", authMode: "unknown" };
|
|
134
|
+
// auth_status is the configured auth type, not whether a login succeeded — exactly auth_mode.
|
|
135
|
+
const codexUrl = entry.transport?.url;
|
|
136
|
+
return {
|
|
137
|
+
registered: "yes",
|
|
138
|
+
// Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
|
|
139
|
+
// the whole payload reports api-key for TinyFish because some other server carries a key.
|
|
140
|
+
authMode: entry.auth_status === "o_auth"
|
|
141
|
+
? "oauth"
|
|
142
|
+
: entry.transport?.bearer_token_env_var
|
|
143
|
+
? "api-key"
|
|
144
|
+
: "unknown",
|
|
145
|
+
...(codexUrl ? { registeredUrl: codexUrl } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function probeCursor() {
|
|
149
|
+
const entry = readCursorTinyfishEntry();
|
|
150
|
+
if (entry.error) {
|
|
151
|
+
return {
|
|
152
|
+
registered: "unknown",
|
|
153
|
+
authMode: "unknown",
|
|
154
|
+
reason: "mcp.json exists but could not be read or parsed",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (!entry.present)
|
|
158
|
+
return { registered: "no", authMode: "unknown" };
|
|
159
|
+
return {
|
|
160
|
+
registered: "yes",
|
|
161
|
+
authMode: entry.hasApiKeyHeader ? "api-key" : "unknown",
|
|
162
|
+
...(entry.url ? { registeredUrl: entry.url } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
|
|
166
|
+
function probeHermes() {
|
|
167
|
+
const probe = runProbe("hermes", ["mcp", "list"]);
|
|
168
|
+
if (probe.outcome === "unavailable") {
|
|
169
|
+
return { registered: "unknown", authMode: "unknown", reason: probe.reason };
|
|
170
|
+
}
|
|
171
|
+
if (probe.exitCode !== 0) {
|
|
172
|
+
return {
|
|
173
|
+
registered: "unknown",
|
|
174
|
+
authMode: "unknown",
|
|
175
|
+
reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return probe.output.includes("tinyfish")
|
|
179
|
+
? { registered: "yes", authMode: "oauth" }
|
|
180
|
+
: { registered: "no", authMode: "unknown" };
|
|
181
|
+
}
|
|
182
|
+
// OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
|
|
183
|
+
function probeOpenClaw() {
|
|
184
|
+
const skillsDir = path.join(harnessConfigPath("openclaw"), "skills");
|
|
185
|
+
let entries;
|
|
186
|
+
try {
|
|
187
|
+
entries = fs.readdirSync(skillsDir);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return {
|
|
191
|
+
registered: "unknown",
|
|
192
|
+
authMode: "unknown",
|
|
193
|
+
reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return entries.some((name) => /tinyfish/i.test(name))
|
|
197
|
+
? { registered: "yes", authMode: "api-key" }
|
|
198
|
+
: { registered: "no", authMode: "unknown" };
|
|
199
|
+
}
|
|
200
|
+
const PROBES = {
|
|
201
|
+
"claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
|
|
202
|
+
codex: probeCodex,
|
|
203
|
+
cursor: probeCursor,
|
|
204
|
+
hermes: probeHermes,
|
|
205
|
+
openclaw: probeOpenClaw,
|
|
206
|
+
};
|
|
207
|
+
/** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
|
|
208
|
+
export function detectRegistrations(harnesses) {
|
|
209
|
+
const connect = loadConfig().connect ?? {};
|
|
210
|
+
return detectInstalledHarnesses()
|
|
211
|
+
.filter((detection) => !harnesses || harnesses.includes(detection.harness))
|
|
212
|
+
.map((detection) => {
|
|
213
|
+
const base = {
|
|
214
|
+
harness: detection.harness,
|
|
215
|
+
detected: detection.detected,
|
|
216
|
+
connectedBefore: detection.harness in connect,
|
|
217
|
+
};
|
|
218
|
+
// Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
|
|
219
|
+
if (!detection.detected) {
|
|
220
|
+
return { ...base, registered: "no", authMode: "unknown" };
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return { ...base, ...PROBES[detection.harness]() };
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
// An arbitrary throw is the one message we cannot author, so it never ships. Full
|
|
227
|
+
// fidelity stays on stderr behind --debug, which is not the pasted artifact.
|
|
228
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
229
|
+
errLine(`probe ${detection.harness} threw: ${e instanceof Error ? e.message : String(e)}`);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
registered: "unknown",
|
|
234
|
+
authMode: "unknown",
|
|
235
|
+
reason: "the probe failed unexpectedly",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|