@tiny-fish/cli 0.14.1-next.151 → 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.d.ts +13 -20
- package/dist/commands/connect.js +149 -54
- 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/harness.js +2 -4
- 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");
|
|
@@ -7,6 +7,12 @@ export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openc
|
|
|
7
7
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
8
8
|
export declare class ConnectInterruptedError extends Error {
|
|
9
9
|
}
|
|
10
|
+
interface NativeConnectOptions {
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
mcpUrl: string;
|
|
13
|
+
launch: boolean;
|
|
14
|
+
attemptId?: string;
|
|
15
|
+
}
|
|
10
16
|
export declare function installTinyFishCli(): void;
|
|
11
17
|
/**
|
|
12
18
|
* Refresh the skill we installed, for the harnesses we installed it in.
|
|
@@ -17,35 +23,22 @@ export declare function installTinyFishCli(): void;
|
|
|
17
23
|
* every agent on the machine).
|
|
18
24
|
*/
|
|
19
25
|
export declare function updateWebSkill(): void;
|
|
20
|
-
export declare function connectClaudeCode(options:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}): Promise<void>;
|
|
25
|
-
export declare function connectCodex(options: {
|
|
26
|
-
apiKey?: string;
|
|
27
|
-
mcpUrl: string;
|
|
28
|
-
launch: boolean;
|
|
29
|
-
}): Promise<void>;
|
|
30
|
-
export declare function connectHermes(options: {
|
|
31
|
-
apiKey?: string;
|
|
32
|
-
mcpUrl: string;
|
|
33
|
-
launch: boolean;
|
|
34
|
-
}): Promise<void>;
|
|
35
|
-
export declare function connectOpencode(options: {
|
|
36
|
-
apiKey?: string;
|
|
37
|
-
mcpUrl: string;
|
|
38
|
-
launch: boolean;
|
|
39
|
-
}): Promise<void>;
|
|
26
|
+
export declare function connectClaudeCode(options: NativeConnectOptions): Promise<void>;
|
|
27
|
+
export declare function connectCodex(options: NativeConnectOptions): Promise<void>;
|
|
28
|
+
export declare function connectHermes(options: NativeConnectOptions): Promise<void>;
|
|
29
|
+
export declare function connectOpencode(options: NativeConnectOptions): Promise<void>;
|
|
40
30
|
export declare function connectOpenClaw(options: {
|
|
41
31
|
mcpUrl: string;
|
|
42
32
|
launch: boolean;
|
|
43
33
|
installCli?: boolean;
|
|
34
|
+
attemptId?: string;
|
|
44
35
|
}): Promise<void>;
|
|
45
36
|
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
46
37
|
export declare function connectCursor(options: {
|
|
47
38
|
apiKey?: string;
|
|
48
39
|
mcpUrl: string;
|
|
40
|
+
attemptId?: string;
|
|
49
41
|
}): Promise<void>;
|
|
50
42
|
export declare function launchAgent(client: AgentClient): void;
|
|
51
43
|
export declare function registerConnect(program: Command): void;
|
|
44
|
+
export {};
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
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";
|
|
8
8
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
9
9
|
import { emitNotice } from "../lib/notice.js";
|
|
10
|
-
import { errLine } from "../lib/output.js";
|
|
10
|
+
import { errLine, sanitizeLine } from "../lib/output.js";
|
|
11
|
+
import { z } from "zod";
|
|
11
12
|
import { installSignalGuard } from "../lib/signals.js";
|
|
12
13
|
import { postConnectEvent, telemetryDisabled } from "../lib/setup-telemetry.js";
|
|
13
14
|
import { verifyMcpAuth } from "../lib/verify.js";
|
|
@@ -15,17 +16,29 @@ export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
|
15
16
|
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
16
17
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
17
18
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
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;
|
|
29
42
|
// OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
|
|
30
43
|
// model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
|
|
31
44
|
const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
|
|
@@ -58,6 +71,15 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
|
|
|
58
71
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
59
72
|
export class ConnectInterruptedError extends Error {
|
|
60
73
|
}
|
|
74
|
+
class PrerequisiteError extends Error {
|
|
75
|
+
failureReason;
|
|
76
|
+
harnessVersion;
|
|
77
|
+
constructor(message, failureReason, opts) {
|
|
78
|
+
super(message, { cause: opts?.cause });
|
|
79
|
+
this.failureReason = failureReason;
|
|
80
|
+
this.harnessVersion = opts?.harnessVersion;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
61
83
|
function throwIfInterrupted(result) {
|
|
62
84
|
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
63
85
|
// network as the user walking away. A timeout is a failure and must report as one.
|
|
@@ -78,14 +100,15 @@ function settle(state, telemetry, stage, detail) {
|
|
|
78
100
|
// (npm/npx/OAuth waits) land here.
|
|
79
101
|
function installConnectSignalGuard(state, telemetry) {
|
|
80
102
|
return installSignalGuard(async () => {
|
|
81
|
-
if (state.settled)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
if (!state.settled) {
|
|
104
|
+
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
105
|
+
errLine("Setup interrupted — run the command again to finish.");
|
|
106
|
+
}
|
|
107
|
+
// Settled spans the whole post-install phase; the `completed` POST may still be in flight.
|
|
85
108
|
await telemetry.flush();
|
|
86
109
|
});
|
|
87
110
|
}
|
|
88
|
-
/** Shared terminal handling: interrupted → aborted
|
|
111
|
+
/** Shared terminal handling: interrupted → aborted, else failed. */
|
|
89
112
|
async function runGuarded(state, telemetry, body) {
|
|
90
113
|
const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
|
|
91
114
|
try {
|
|
@@ -93,16 +116,17 @@ async function runGuarded(state, telemetry, body) {
|
|
|
93
116
|
}
|
|
94
117
|
catch (error) {
|
|
95
118
|
if (error instanceof ConnectInterruptedError) {
|
|
96
|
-
if (state.stage === "walkthrough_launch") {
|
|
97
|
-
settle(state, telemetry, "completed");
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
119
|
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
101
120
|
errLine("Setup interrupted — run the command again to finish.");
|
|
102
121
|
process.exitCode = 130;
|
|
103
122
|
return;
|
|
104
123
|
}
|
|
105
|
-
settle(state, telemetry, "failed", {
|
|
124
|
+
settle(state, telemetry, "failed", {
|
|
125
|
+
failedStage: state.stage,
|
|
126
|
+
...(error instanceof PrerequisiteError
|
|
127
|
+
? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
106
130
|
throw error;
|
|
107
131
|
}
|
|
108
132
|
finally {
|
|
@@ -180,7 +204,8 @@ function launchHermesWalkthrough() {
|
|
|
180
204
|
cause: seedResult.error,
|
|
181
205
|
});
|
|
182
206
|
}
|
|
183
|
-
|
|
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];
|
|
184
209
|
if (!sessionId) {
|
|
185
210
|
throw new Error("Hermes did not return a walkthrough session ID");
|
|
186
211
|
}
|
|
@@ -252,8 +277,9 @@ const OPENCLAW = {
|
|
|
252
277
|
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
253
278
|
},
|
|
254
279
|
};
|
|
255
|
-
function createConnectTelemetry(mcpUrl, client,
|
|
256
|
-
|
|
280
|
+
function createConnectTelemetry(mcpUrl, client, opts) {
|
|
281
|
+
// A setup-page id (via --url) joins "copied the command" to this install attempt.
|
|
282
|
+
const attemptId = opts?.attemptId ?? randomUUID();
|
|
257
283
|
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
258
284
|
const pending = [];
|
|
259
285
|
async function deliver(body) {
|
|
@@ -265,7 +291,7 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
265
291
|
endpoint,
|
|
266
292
|
body,
|
|
267
293
|
attempts: 2,
|
|
268
|
-
apiKey: resolvedApiKey(apiKey),
|
|
294
|
+
apiKey: resolvedApiKey(opts?.apiKey),
|
|
269
295
|
label: "connect",
|
|
270
296
|
});
|
|
271
297
|
}
|
|
@@ -279,9 +305,13 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
279
305
|
stage,
|
|
280
306
|
failed_stage: detail?.failedStage,
|
|
281
307
|
phase: detail?.phase,
|
|
308
|
+
failure_reason: detail?.failureReason,
|
|
309
|
+
harness_version: detail?.harnessVersion,
|
|
282
310
|
runtime_platform: process.platform,
|
|
283
311
|
node_version: process.version,
|
|
284
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(),
|
|
285
315
|
})));
|
|
286
316
|
},
|
|
287
317
|
// Awaited in finally so in-flight events land before exit.
|
|
@@ -293,22 +323,41 @@ function createConnectTelemetry(mcpUrl, client, apiKey) {
|
|
|
293
323
|
function requireCommandSupport(client) {
|
|
294
324
|
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
295
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" },
|
|
296
328
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
297
329
|
});
|
|
298
330
|
if (commandNotFound(result.error)) {
|
|
299
|
-
throw new
|
|
300
|
-
cause: result.error,
|
|
301
|
-
});
|
|
331
|
+
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, "harness_not_installed", { cause: result.error });
|
|
302
332
|
}
|
|
303
333
|
if (result.error || result.status !== 0) {
|
|
304
334
|
throwIfInterrupted(result);
|
|
305
|
-
throw new
|
|
335
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
|
|
306
336
|
}
|
|
307
|
-
|
|
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 ?? ""}`);
|
|
308
339
|
if (!client.supportCheck.patterns.every((pattern) => pattern.test(output))) {
|
|
309
|
-
|
|
340
|
+
if (process.env["TINYFISH_DEBUG"]) {
|
|
341
|
+
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
342
|
+
}
|
|
343
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
|
|
344
|
+
harnessVersion: probeHarnessVersion(client.command),
|
|
345
|
+
});
|
|
310
346
|
}
|
|
311
347
|
}
|
|
348
|
+
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
349
|
+
function probeHarnessVersion(command) {
|
|
350
|
+
const result = spawn.sync(command, ["--version"], {
|
|
351
|
+
encoding: "utf8",
|
|
352
|
+
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
353
|
+
});
|
|
354
|
+
if (result.error || result.status !== 0)
|
|
355
|
+
return undefined;
|
|
356
|
+
const version = sanitizeLine(result.stdout ?? "")
|
|
357
|
+
.replace(/[^\x20-\x7E]/g, "")
|
|
358
|
+
.trim();
|
|
359
|
+
return version ? version.slice(0, 32) : undefined;
|
|
360
|
+
}
|
|
312
361
|
function commandNotFound(error) {
|
|
313
362
|
return error?.code === "ENOENT";
|
|
314
363
|
}
|
|
@@ -530,7 +579,7 @@ function launchOpenClawWalkthrough() {
|
|
|
530
579
|
return true;
|
|
531
580
|
}
|
|
532
581
|
async function connectNativeMcpClient(client, options) {
|
|
533
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options
|
|
582
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
534
583
|
const state = { stage: "prerequisite_check", settled: false };
|
|
535
584
|
await runGuarded(state, telemetry, () => {
|
|
536
585
|
telemetry.track("started");
|
|
@@ -544,8 +593,8 @@ async function connectNativeMcpClient(client, options) {
|
|
|
544
593
|
mcpUrl.searchParams.set("client", client.connectClient);
|
|
545
594
|
mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
|
|
546
595
|
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
547
|
-
const storedKey = options.apiKey
|
|
548
|
-
const useKeyAuth = client.headerAuthSupported === true && !!storedKey
|
|
596
|
+
const storedKey = validatedApiKey(options.apiKey);
|
|
597
|
+
const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
|
|
549
598
|
state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
|
|
550
599
|
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
551
600
|
const addResult = spawn.sync(client.command, [
|
|
@@ -580,6 +629,22 @@ async function connectNativeMcpClient(client, options) {
|
|
|
580
629
|
}
|
|
581
630
|
telemetry.track("checkpoint", { phase: "oauth_done" });
|
|
582
631
|
}
|
|
632
|
+
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
633
|
+
settle(state, telemetry, "completed");
|
|
634
|
+
runPostInstallSteps(client, options, state, telemetry, useKeyAuth);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function connectedLine(client, useKeyAuth) {
|
|
638
|
+
// Deferred OAuth (codex/hermes): the CLI never observes auth, so don't claim "connected".
|
|
639
|
+
if (!client.loginArgs && !useKeyAuth) {
|
|
640
|
+
return (`TinyFish is configured in ${client.displayName}. ${client.displayName} will ask you ` +
|
|
641
|
+
"to sign in to TinyFish the first time you use it.");
|
|
642
|
+
}
|
|
643
|
+
return `TinyFish is connected. Open ${client.displayName} to start using it.`;
|
|
644
|
+
}
|
|
645
|
+
// Runs after `completed` settled: failures warn and report post_install_failed, exit 0.
|
|
646
|
+
function runPostInstallSteps(client, options, state, telemetry, useKeyAuth) {
|
|
647
|
+
try {
|
|
583
648
|
state.stage = "cli_install";
|
|
584
649
|
installTinyFishCli();
|
|
585
650
|
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
@@ -596,15 +661,24 @@ async function connectNativeMcpClient(client, options) {
|
|
|
596
661
|
if (client.postConnectNote)
|
|
597
662
|
errLine(client.postConnectNote);
|
|
598
663
|
if (!options.launch) {
|
|
599
|
-
errLine(
|
|
600
|
-
settle(state, telemetry, "completed");
|
|
664
|
+
errLine(connectedLine(client, useKeyAuth));
|
|
601
665
|
return;
|
|
602
666
|
}
|
|
603
667
|
state.stage = "walkthrough_launch";
|
|
604
|
-
// Install already done; walkthrough Ctrl+C still counts completed.
|
|
605
668
|
launchNativeMcpClient(client);
|
|
606
|
-
|
|
607
|
-
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
reportPostInstallFailure(client.connectClient, client.displayName, state, telemetry, error);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
675
|
+
function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
|
|
676
|
+
if (!(error instanceof ConnectInterruptedError)) {
|
|
677
|
+
telemetry.track("post_install_failed", { failedStage: state.stage });
|
|
678
|
+
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
679
|
+
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
+
}
|
|
681
|
+
errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
|
|
608
682
|
}
|
|
609
683
|
export async function connectClaudeCode(options) {
|
|
610
684
|
await connectNativeMcpClient(CLAUDE_CODE, options);
|
|
@@ -619,7 +693,7 @@ export async function connectOpencode(options) {
|
|
|
619
693
|
await connectNativeMcpClient(OPENCODE, options);
|
|
620
694
|
}
|
|
621
695
|
export async function connectOpenClaw(options) {
|
|
622
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw");
|
|
696
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
|
|
623
697
|
const state = { stage: "prerequisite_check", settled: false };
|
|
624
698
|
await runGuarded(state, telemetry, () => {
|
|
625
699
|
telemetry.track("started");
|
|
@@ -668,20 +742,24 @@ export async function connectOpenClaw(options) {
|
|
|
668
742
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
669
743
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
670
744
|
errLine(UPGRADE_HINT);
|
|
745
|
+
// For openclaw the skill install and auth ARE functional; only the walkthrough is cosmetic.
|
|
746
|
+
settle(state, telemetry, "completed");
|
|
671
747
|
if (!options.launch) {
|
|
672
748
|
errLine("TinyFish is connected. Open OpenClaw to start using it.");
|
|
673
|
-
settle(state, telemetry, "completed");
|
|
674
749
|
return;
|
|
675
750
|
}
|
|
676
751
|
state.stage = "walkthrough_launch";
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
752
|
+
try {
|
|
753
|
+
launchOpenClawWalkthrough();
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
reportPostInstallFailure("openclaw", "OpenClaw", state, telemetry, error);
|
|
757
|
+
}
|
|
680
758
|
});
|
|
681
759
|
}
|
|
682
760
|
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
683
761
|
export async function connectCursor(options) {
|
|
684
|
-
const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options
|
|
762
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, "cursor", options);
|
|
685
763
|
const state = { stage: "prerequisite_check", settled: false };
|
|
686
764
|
await runGuarded(state, telemetry, async () => {
|
|
687
765
|
telemetry.track("started");
|
|
@@ -690,9 +768,11 @@ export async function connectCursor(options) {
|
|
|
690
768
|
state.stage = "authentication";
|
|
691
769
|
ensureCliAuthenticated("cursor", options.apiKey);
|
|
692
770
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
+
}
|
|
696
776
|
state.stage = "registration";
|
|
697
777
|
errLine("Adding TinyFish to Cursor...");
|
|
698
778
|
const mcpUrl = new URL(options.mcpUrl);
|
|
@@ -784,7 +864,7 @@ export function registerConnect(program) {
|
|
|
784
864
|
if (options.apiKey && !validateKeyFormat(options.apiKey)) {
|
|
785
865
|
throw new Error("Invalid --api-key value");
|
|
786
866
|
}
|
|
787
|
-
|
|
867
|
+
let mcpUrl = options.url ?? DEFAULT_MCP_URL;
|
|
788
868
|
if (mcpUrl.startsWith("-")) {
|
|
789
869
|
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
790
870
|
}
|
|
@@ -798,6 +878,16 @@ export function registerConnect(program) {
|
|
|
798
878
|
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
799
879
|
throw new Error(`Invalid --url value: ${mcpUrl}`);
|
|
800
880
|
}
|
|
881
|
+
// Page-minted attempt id rides in on --url. Strip before the URL becomes the MCP
|
|
882
|
+
// base — registration re-stamps it, and nothing else may carry it.
|
|
883
|
+
const pageAttemptId = parsedUrl.searchParams.get("connect_attempt_id");
|
|
884
|
+
let attemptId;
|
|
885
|
+
if (pageAttemptId !== null) {
|
|
886
|
+
parsedUrl.searchParams.delete("connect_attempt_id");
|
|
887
|
+
mcpUrl = parsedUrl.toString();
|
|
888
|
+
if (z.uuid().safeParse(pageAttemptId).success)
|
|
889
|
+
attemptId = pageAttemptId;
|
|
890
|
+
}
|
|
801
891
|
if (options.all) {
|
|
802
892
|
if (client) {
|
|
803
893
|
throw new Error("Pass either a client or --all, not both.");
|
|
@@ -823,7 +913,12 @@ export function registerConnect(program) {
|
|
|
823
913
|
if (!client) {
|
|
824
914
|
throw new Error("Pass a client (claude-code, codex, cursor, hermes, openclaw) or --all.");
|
|
825
915
|
}
|
|
826
|
-
const connectOptions = {
|
|
916
|
+
const connectOptions = {
|
|
917
|
+
apiKey: options.apiKey,
|
|
918
|
+
mcpUrl,
|
|
919
|
+
launch: options.launch ?? false,
|
|
920
|
+
attemptId,
|
|
921
|
+
};
|
|
827
922
|
if (client === "claude-code") {
|
|
828
923
|
await connectClaudeCode(connectOptions);
|
|
829
924
|
}
|
|
@@ -837,12 +932,12 @@ export function registerConnect(program) {
|
|
|
837
932
|
await connectOpencode(connectOptions);
|
|
838
933
|
}
|
|
839
934
|
else if (client === "openclaw") {
|
|
840
|
-
await connectOpenClaw(
|
|
935
|
+
await connectOpenClaw(connectOptions);
|
|
841
936
|
}
|
|
842
937
|
else if (client === "cursor") {
|
|
843
938
|
// `connect <client> --launch` is the shape every doc uses; connect anyway, then say
|
|
844
939
|
// why nothing launched rather than throwing away a working install.
|
|
845
|
-
await connectCursor(
|
|
940
|
+
await connectCursor(connectOptions);
|
|
846
941
|
if (connectOptions.launch) {
|
|
847
942
|
errLine("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
|
|
848
943
|
}
|
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);
|
package/dist/lib/harness.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// value (unenumerated, lowercase-kebab, matching MCP client_name like
|
|
4
4
|
// "claude-code"). Explicit TF_CLIENT_NAME always wins upstream in client.ts.
|
|
5
5
|
//
|
|
6
|
-
// Fingerprints verified by running scripts/harness-probe
|
|
6
|
+
// Fingerprints verified by running scripts/harness-probe.mjs (repo root) in each harness.
|
|
7
7
|
// Order matters: wrappers that embed another agent must precede the embedded
|
|
8
8
|
// one. OpenClaw drives Claude Code via `sdk-cli`, so its env carries both
|
|
9
9
|
// CLAUDECODE=1 and OPENCLAW_MCP_*; it must be checked before claude-code.
|
|
@@ -21,9 +21,7 @@ const HARNESS_FINGERPRINTS = [
|
|
|
21
21
|
// prefix so we catch a Codex run regardless of which of its vars are set.
|
|
22
22
|
{ name: "codex", matches: (env) => hasVarWithPrefix(env, "CODEX_") },
|
|
23
23
|
{ name: "hermes", matches: (env) => hasVarWithPrefix(env, "HERMES_") },
|
|
24
|
-
//
|
|
25
|
-
// the commands it shells out. Exact key, not a prefix — `OPENCODE_*` are user config inputs.
|
|
26
|
-
// Sourced from OpenCode's docs, not yet confirmed against scripts/harness-probe.
|
|
24
|
+
// Exact key, not a prefix: opencode writes OPENCODE=1 at startup, reads every OPENCODE_* as config.
|
|
27
25
|
{ name: "opencode", matches: (env) => Boolean(env["OPENCODE"]) },
|
|
28
26
|
{
|
|
29
27
|
name: "claude-code",
|
|
@@ -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
|
+
}
|