@tiny-fish/cli 0.19.1-next.181 → 0.20.2-next.190
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/README.md +8 -2
- package/dist/commands/connect.d.ts +8 -5
- package/dist/commands/connect.js +52 -39
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/upgrade.d.ts +2 -1
- package/dist/commands/upgrade.js +16 -5
- package/dist/lib/connect-all.js +86 -58
- package/dist/lib/connect-clients.d.ts +8 -2
- package/dist/lib/connect-clients.js +41 -13
- package/dist/lib/connect-install.js +7 -3
- package/dist/lib/connect-runtime.d.ts +5 -0
- package/dist/lib/connect-runtime.js +4 -2
- package/dist/lib/install-root.d.ts +15 -0
- package/dist/lib/install-root.js +49 -0
- package/dist/lib/registration-detect.js +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,10 @@ One-command OAuth requires a Codex release whose `codex mcp add` command support
|
|
|
40
40
|
`--oauth-resource`. Update Codex if support is unavailable, or add TinyFish manually with
|
|
41
41
|
`codex mcp add`.
|
|
42
42
|
|
|
43
|
+
With `--api-key`, Codex reads the key from `TINYFISH_API_KEY` instead of signing in — the command
|
|
44
|
+
exports it to your shell profile, so restart Codex (or open a new terminal) before using it. Needs
|
|
45
|
+
`codex mcp add --bearer-token-env-var`; without that flag the command falls back to OAuth.
|
|
46
|
+
|
|
43
47
|
### Connect Hermes
|
|
44
48
|
|
|
45
49
|
Add TinyFish MCP and the global `use-tinyfish` web skill, authenticate the CLI, and start the
|
|
@@ -49,8 +53,10 @@ interactive walkthrough with one command:
|
|
|
49
53
|
npx -y @tiny-fish/cli@latest connect hermes --launch --api-key sk-tinyfish-...
|
|
50
54
|
```
|
|
51
55
|
|
|
52
|
-
Hermes completes OAuth while adding the MCP server.
|
|
53
|
-
|
|
56
|
+
Hermes completes OAuth while adding the MCP server. `hermes mcp add` accepts no header flag, so
|
|
57
|
+
`--api-key` cannot replace the Hermes sign-in — its only header path, `--auth header`, prompts
|
|
58
|
+
interactively. After setup, the command starts the walkthrough in a Hermes session and leaves that
|
|
59
|
+
session open for your replies.
|
|
54
60
|
|
|
55
61
|
### Connect OpenClaw
|
|
56
62
|
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { type AgentClient } from "../lib/connect-runtime.js";
|
|
2
|
+
import { type AgentClient, type ConnectAuthMode } from "../lib/connect-runtime.js";
|
|
3
3
|
export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
4
4
|
interface NativeConnectOptions {
|
|
5
5
|
apiKey?: string;
|
|
6
6
|
mcpUrl: string;
|
|
7
7
|
launch: boolean;
|
|
8
8
|
attemptId?: string;
|
|
9
|
+
/** Headless: refuse rather than fall back to an unfinishable browser sign-in. */
|
|
10
|
+
keyAuthOnly?: boolean;
|
|
9
11
|
}
|
|
10
|
-
export declare function connectClaudeCode(options: NativeConnectOptions): Promise<
|
|
11
|
-
export declare function connectCodex(options: NativeConnectOptions): Promise<
|
|
12
|
-
export declare function connectHermes(options: NativeConnectOptions): Promise<
|
|
13
|
-
export declare function connectOpencode(options: NativeConnectOptions): Promise<
|
|
12
|
+
export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
13
|
+
export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
14
|
+
export declare function connectHermes(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
15
|
+
export declare function connectOpencode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
14
16
|
export declare function connectOpenClaw(options: {
|
|
17
|
+
apiKey?: string;
|
|
15
18
|
mcpUrl: string;
|
|
16
19
|
launch: boolean;
|
|
17
20
|
installCli?: boolean;
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
|
-
import { CONNECT_SOURCE, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
|
|
2
|
+
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
|
|
3
3
|
import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
|
|
4
|
-
import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS,
|
|
5
|
-
import {
|
|
4
|
+
import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
|
|
5
|
+
import { ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.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";
|
|
@@ -29,13 +29,46 @@ function removeExistingRegistration(client, removal) {
|
|
|
29
29
|
}
|
|
30
30
|
throw new Error(`Could not remove existing ${removal.label}: ${details}`);
|
|
31
31
|
}
|
|
32
|
+
/** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
|
|
33
|
+
function exportApiKey(apiKey, displayName) {
|
|
34
|
+
try {
|
|
35
|
+
persistApiKeyToEnvironment(apiKey);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}); ` +
|
|
39
|
+
"signing in instead.");
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
process.env["TINYFISH_API_KEY"] = apiKey;
|
|
43
|
+
errLine(`Exported TINYFISH_API_KEY to your shell profile — ${displayName} reads the key from there, ` +
|
|
44
|
+
"so restart it (or open a new terminal) before use.");
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
32
47
|
async function connectNativeMcpClient(client, options) {
|
|
33
48
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
34
49
|
const state = { stage: "prerequisite_check", settled: false };
|
|
35
50
|
await runGuarded(state, telemetry, () => {
|
|
36
51
|
telemetry.track("started");
|
|
37
|
-
const { optionalSupported } = requireCommandSupport(client);
|
|
52
|
+
const { optionalSupported, keyAuthSupported } = requireCommandSupport(client);
|
|
38
53
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
54
|
+
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
55
|
+
const storedKey = validatedApiKey(options.apiKey);
|
|
56
|
+
let keyedAdd = storedKey && keyAuthSupported && client.keyAuth
|
|
57
|
+
? { key: storedKey, spec: client.keyAuth }
|
|
58
|
+
: undefined;
|
|
59
|
+
// Before the removals: a failed export must not strand the old registration.
|
|
60
|
+
if (keyedAdd?.spec.viaEnv && !exportApiKey(keyedAdd.key, client.displayName)) {
|
|
61
|
+
keyedAdd = undefined;
|
|
62
|
+
}
|
|
63
|
+
const useKeyAuth = !!keyedAdd;
|
|
64
|
+
if (options.keyAuthOnly && !useKeyAuth) {
|
|
65
|
+
// No keyAuth at all is structural (Hermes); a missing flag is just an old install.
|
|
66
|
+
throw new Error(client.keyAuth
|
|
67
|
+
? `This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
|
|
68
|
+
`needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`
|
|
69
|
+
: `${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
|
|
70
|
+
`terminal. Run: tinyfish connect ${client.connectClient}`);
|
|
71
|
+
}
|
|
39
72
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
40
73
|
state.stage = "registration_cleanup";
|
|
41
74
|
for (const removal of client.removals)
|
|
@@ -44,9 +77,6 @@ async function connectNativeMcpClient(client, options) {
|
|
|
44
77
|
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
45
78
|
mcpUrl.searchParams.set("client", client.connectClient);
|
|
46
79
|
mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
|
|
47
|
-
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
48
|
-
const storedKey = validatedApiKey(options.apiKey);
|
|
49
|
-
const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
|
|
50
80
|
// Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
|
|
51
81
|
const loginDegraded = state.harnessDegraded === true;
|
|
52
82
|
const loginArgs = loginDegraded ? undefined : client.loginArgs;
|
|
@@ -54,12 +84,11 @@ async function connectNativeMcpClient(client, options) {
|
|
|
54
84
|
state.stage =
|
|
55
85
|
loginArgs && !client.probeAuthenticated ? "registration" : "registration_or_authentication";
|
|
56
86
|
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
], {
|
|
87
|
+
// Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
|
|
88
|
+
const addArgs = keyedAdd
|
|
89
|
+
? keyedAdd.spec.addArgs(mcpUrl.toString(), keyedAdd.key)
|
|
90
|
+
: client.addArgs(mcpUrl.toString(), options.mcpUrl);
|
|
91
|
+
const addResult = spawn.sync(client.command, addArgs, {
|
|
63
92
|
stdio: "inherit",
|
|
64
93
|
});
|
|
65
94
|
if (addResult.error || addResult.status !== 0) {
|
|
@@ -105,10 +134,12 @@ async function connectNativeMcpClient(client, options) {
|
|
|
105
134
|
: signInDeferred
|
|
106
135
|
? "deferred"
|
|
107
136
|
: "oauth";
|
|
137
|
+
state.authMode = authMode;
|
|
108
138
|
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
109
139
|
settle(state, telemetry, "completed", { authMode });
|
|
110
140
|
runPostInstallSteps(client, options, state, telemetry, signInDeferred);
|
|
111
141
|
});
|
|
142
|
+
return state.authMode;
|
|
112
143
|
}
|
|
113
144
|
function connectedLine(client, signInDeferred) {
|
|
114
145
|
// The CLI never observes deferred auth, so don't claim "connected".
|
|
@@ -157,16 +188,16 @@ function reportPostInstallFailure(connectClient, displayName, state, telemetry,
|
|
|
157
188
|
errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
|
|
158
189
|
}
|
|
159
190
|
export async function connectClaudeCode(options) {
|
|
160
|
-
|
|
191
|
+
return connectNativeMcpClient(CLAUDE_CODE, options);
|
|
161
192
|
}
|
|
162
193
|
export async function connectCodex(options) {
|
|
163
|
-
|
|
194
|
+
return connectNativeMcpClient(CODEX, options);
|
|
164
195
|
}
|
|
165
196
|
export async function connectHermes(options) {
|
|
166
|
-
|
|
197
|
+
return connectNativeMcpClient(HERMES, options);
|
|
167
198
|
}
|
|
168
199
|
export async function connectOpencode(options) {
|
|
169
|
-
|
|
200
|
+
return connectNativeMcpClient(OPENCODE, options);
|
|
170
201
|
}
|
|
171
202
|
export async function connectOpenClaw(options) {
|
|
172
203
|
const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
|
|
@@ -198,29 +229,9 @@ export async function connectOpenClaw(options) {
|
|
|
198
229
|
});
|
|
199
230
|
}
|
|
200
231
|
telemetry.track("checkpoint", { phase: "skill_installed" });
|
|
232
|
+
// Stores the key before probing, so a passed --api-key never reaches an interactive login.
|
|
201
233
|
state.stage = "authentication";
|
|
202
|
-
|
|
203
|
-
stdio: "ignore",
|
|
204
|
-
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
205
|
-
});
|
|
206
|
-
if (commandNotFound(authStatus.error)) {
|
|
207
|
-
throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: authStatus.error });
|
|
208
|
-
}
|
|
209
|
-
if (authStatus.status !== 0) {
|
|
210
|
-
errLine("Signing in to TinyFish...");
|
|
211
|
-
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", "openclaw"], {
|
|
212
|
-
stdio: "inherit",
|
|
213
|
-
});
|
|
214
|
-
if (commandNotFound(loginResult.error)) {
|
|
215
|
-
throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
|
|
216
|
-
}
|
|
217
|
-
if (loginResult.error || loginResult.status !== 0) {
|
|
218
|
-
throwIfInterrupted(loginResult);
|
|
219
|
-
throw new Error("Could not authenticate TinyFish for OpenClaw", {
|
|
220
|
-
cause: loginResult.error,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
234
|
+
ensureCliAuthenticated("openclaw", options.apiKey);
|
|
224
235
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
225
236
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
226
237
|
errLine(UPGRADE_HINT);
|
|
@@ -400,6 +411,8 @@ export function registerConnect(program) {
|
|
|
400
411
|
mcpUrl,
|
|
401
412
|
launch: options.launch ?? false,
|
|
402
413
|
attemptId,
|
|
414
|
+
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
415
|
+
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
403
416
|
};
|
|
404
417
|
if (client === "claude-code") {
|
|
405
418
|
await connectClaudeCode(connectOptions);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -93,7 +93,7 @@ function checkCredential() {
|
|
|
93
93
|
id: "cli-credential",
|
|
94
94
|
title: "CLI credential",
|
|
95
95
|
status: "key" in resolved ? "pass" : "fail",
|
|
96
|
-
detail: "key" in resolved ? `
|
|
96
|
+
detail: "key" in resolved ? `key present from ${resolved.source}` : resolved.error,
|
|
97
97
|
harness: null,
|
|
98
98
|
};
|
|
99
99
|
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
|
|
2
|
+
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
3
|
+
export declare function runUpgrade(): string | null;
|
|
3
4
|
/** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
|
|
4
5
|
export declare function runUpgradeCommand(): Promise<void>;
|
|
5
6
|
export declare function registerUpgrade(program: Command): void;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { resolvedApiKey } from "../lib/auth.js";
|
|
3
3
|
import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
|
|
4
|
+
import { installRoot, readInstalledVersion } from "../lib/install-root.js";
|
|
4
5
|
import { suppressNotice } from "../lib/notice.js";
|
|
5
6
|
import { errLine } from "../lib/output.js";
|
|
6
7
|
import { sendUpgradeCompleted, telemetryDisabled, } from "../lib/setup-telemetry.js";
|
|
@@ -24,6 +25,7 @@ function attempt(label, run) {
|
|
|
24
25
|
return message;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
27
29
|
export function runUpgrade() {
|
|
28
30
|
errLine("Upgrading TinyFish...");
|
|
29
31
|
try {
|
|
@@ -40,12 +42,21 @@ export function runUpgrade() {
|
|
|
40
42
|
throw error;
|
|
41
43
|
errLine(INTERRUPTED_MESSAGE);
|
|
42
44
|
process.exitCode = SIGNAL_EXIT_CODES.SIGINT;
|
|
43
|
-
return;
|
|
45
|
+
return null;
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
const version = installedCliVersion();
|
|
48
|
+
errLine(version
|
|
49
|
+
? `TinyFish CLI ${version} is up to date. Restart your agent to pick up the refreshed skill.`
|
|
50
|
+
: "TinyFish is up to date. Restart your agent to pick up the refreshed skill.");
|
|
51
|
+
return version;
|
|
46
52
|
}
|
|
47
53
|
/** The version now on disk. Only the upgrade's own npm call knows what "latest" resolved to. */
|
|
48
54
|
function installedCliVersion() {
|
|
55
|
+
const root = installRoot();
|
|
56
|
+
return (root ? readInstalledVersion(root.nodeModules) : null) ?? globalCliVersion();
|
|
57
|
+
}
|
|
58
|
+
/** Fallback when no prefix was detected: npm's own global tree is the only place left to look. */
|
|
59
|
+
function globalCliVersion() {
|
|
49
60
|
try {
|
|
50
61
|
const result = spawn.sync("npm", ["ls", "--global", "--depth=0", "--json", TINYFISH_CLI_PACKAGE], { encoding: "utf8", timeout: VERSION_READ_TIMEOUT_MS });
|
|
51
62
|
const parsed = JSON.parse(result.stdout ?? "");
|
|
@@ -60,17 +71,17 @@ export async function runUpgradeCommand() {
|
|
|
60
71
|
// This command is the upgrade, so a trailing "an update is available" would be noise.
|
|
61
72
|
suppressNotice();
|
|
62
73
|
let reported = false;
|
|
74
|
+
let printedVersion = null;
|
|
63
75
|
// Read on the failed path too: a failed skill refresh still leaves an upgraded CLI, so
|
|
64
76
|
// `outcome` reports whether every step ran and `to_version` whether the binary moved.
|
|
65
77
|
const report = async (outcome) => {
|
|
66
78
|
if (reported)
|
|
67
79
|
return;
|
|
68
80
|
reported = true;
|
|
69
|
-
// Ahead of the version and key reads; an opted-out run does neither.
|
|
70
81
|
if (telemetryDisabled())
|
|
71
82
|
return;
|
|
72
83
|
// Interrupted skips the read — racing the exit. Undercounts; see posthog-events.md.
|
|
73
|
-
const toVersion = outcome === "interrupted" ? null : installedCliVersion();
|
|
84
|
+
const toVersion = outcome === "interrupted" ? null : (printedVersion ?? installedCliVersion());
|
|
74
85
|
await sendUpgradeCompleted(CLI_VERSION, toVersion, outcome, resolvedApiKey());
|
|
75
86
|
};
|
|
76
87
|
const uninstallSignalGuard = installSignalGuard(async () => {
|
|
@@ -79,7 +90,7 @@ export async function runUpgradeCommand() {
|
|
|
79
90
|
});
|
|
80
91
|
let outcome = "updated";
|
|
81
92
|
try {
|
|
82
|
-
runUpgrade();
|
|
93
|
+
printedVersion = runUpgrade();
|
|
83
94
|
if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
|
|
84
95
|
outcome = "interrupted";
|
|
85
96
|
}
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as readline from "node:readline/promises";
|
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
|
|
4
4
|
import { validatedApiKey } from "./auth.js";
|
|
5
|
+
import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
|
|
5
6
|
import { detectInstalledHarnesses, ALL_HARNESSES, } from "./harness-detect.js";
|
|
6
7
|
import { detectHumanInitiated } from "./harness.js";
|
|
7
8
|
import { emitNotice } from "./notice.js";
|
|
@@ -20,10 +21,14 @@ const DISPLAY_NAMES = {
|
|
|
20
21
|
// `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
|
|
21
22
|
// TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
|
|
22
23
|
const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
|
|
23
|
-
// Harnesses that drive their own OAuth at install — a stored TinyFish key says nothing
|
|
24
|
-
// about their session, so it must never unlock a headless run for them.
|
|
25
|
-
const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes", "opencode"]);
|
|
26
24
|
const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
|
|
25
|
+
// Descriptor-derived so a harness that cannot carry a key is skipped, not failed, when headless.
|
|
26
|
+
// Cursor and OpenClaw hold the key themselves rather than handing it to a harness `mcp add`.
|
|
27
|
+
const KEY_AUTH_CAPABLE = new Set([
|
|
28
|
+
...NATIVE_MCP_CLIENTS.filter((client) => !!client.keyAuth).map((client) => client.connectClient),
|
|
29
|
+
"cursor",
|
|
30
|
+
"openclaw",
|
|
31
|
+
]);
|
|
27
32
|
// Real removal commands — harness id is not the binary name.
|
|
28
33
|
const UNINSTALL_POINTERS = {
|
|
29
34
|
"claude-code": "claude mcp remove tinyfish --scope user",
|
|
@@ -33,24 +38,28 @@ const UNINSTALL_POINTERS = {
|
|
|
33
38
|
// OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
|
|
34
39
|
opencode: "remove the tinyfish entry from ~/.config/opencode/opencode.json",
|
|
35
40
|
};
|
|
36
|
-
async function runHarnessConnect(harness, opts) {
|
|
37
|
-
const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
|
|
41
|
+
async function runHarnessConnect(harness, opts, keyAuthOnly) {
|
|
42
|
+
const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false, keyAuthOnly };
|
|
38
43
|
if (harness === "claude-code")
|
|
39
44
|
return connectClaudeCode(base);
|
|
40
45
|
if (harness === "codex")
|
|
41
46
|
return connectCodex(base);
|
|
42
47
|
if (harness === "hermes")
|
|
43
48
|
return connectHermes(base);
|
|
44
|
-
if (harness === "openclaw")
|
|
45
|
-
|
|
49
|
+
if (harness === "openclaw") {
|
|
50
|
+
// Without the key the child shells an interactive `tinyfish auth login`.
|
|
51
|
+
await connectOpenClaw({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false });
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
46
54
|
if (harness === "opencode")
|
|
47
55
|
return connectOpencode(base);
|
|
48
|
-
|
|
56
|
+
await connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
|
|
57
|
+
return undefined;
|
|
49
58
|
}
|
|
50
|
-
async function verifyHarness(harness, mcpUrl, apiKey) {
|
|
59
|
+
async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
|
|
51
60
|
const health = await verifyMcpHealth(mcpUrl);
|
|
52
|
-
//
|
|
53
|
-
const capable = AUTH_CHECK_CAPABLE.has(harness) ||
|
|
61
|
+
// The own-OAuth harnesses are auth-checkable only in key mode (key-authed config).
|
|
62
|
+
const capable = AUTH_CHECK_CAPABLE.has(harness) || keyed;
|
|
54
63
|
if (!capable || !health.ok)
|
|
55
64
|
return health;
|
|
56
65
|
if (!apiKey)
|
|
@@ -110,15 +119,18 @@ async function processHarness(detection, opts, prompt) {
|
|
|
110
119
|
if (opts.uninstall)
|
|
111
120
|
return uninstallHarness(harness, configPath);
|
|
112
121
|
const isTTY = detectHumanInitiated();
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
const keyed = !!
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
const apiKey = validatedApiKey(opts.apiKey);
|
|
123
|
+
// A key only leaves the own-OAuth set where the harness can actually carry one.
|
|
124
|
+
const keyed = !!apiKey && KEY_AUTH_CAPABLE.has(harness);
|
|
125
|
+
if (!isTTY && !keyed) {
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
outcome: "no_tty_auth_skip",
|
|
129
|
+
keyUnusable: !!apiKey,
|
|
130
|
+
fixCommand: `tinyfish connect ${harness}`,
|
|
131
|
+
};
|
|
120
132
|
}
|
|
121
|
-
if (isTTY && !
|
|
133
|
+
if (isTTY && !keyed && prompt) {
|
|
122
134
|
errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
|
|
123
135
|
try {
|
|
124
136
|
await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
|
|
@@ -130,8 +142,10 @@ async function processHarness(detection, opts, prompt) {
|
|
|
130
142
|
}
|
|
131
143
|
// runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
|
|
132
144
|
const interruptedBefore = process.exitCode === 130;
|
|
145
|
+
let authMode;
|
|
133
146
|
try {
|
|
134
|
-
|
|
147
|
+
// Headless: an install too old for key auth must fail, not open a browser.
|
|
148
|
+
authMode = await runHarnessConnect(harness, opts, !isTTY);
|
|
135
149
|
}
|
|
136
150
|
catch (e) {
|
|
137
151
|
return {
|
|
@@ -144,7 +158,9 @@ async function processHarness(detection, opts, prompt) {
|
|
|
144
158
|
if (!interruptedBefore && process.exitCode === 130) {
|
|
145
159
|
return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
|
|
146
160
|
}
|
|
147
|
-
|
|
161
|
+
// The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
|
|
162
|
+
const keyAuthed = authMode ? authMode === "api-key" : keyed;
|
|
163
|
+
const verify = await verifyHarness(harness, opts.mcpUrl, keyAuthed, apiKey);
|
|
148
164
|
return {
|
|
149
165
|
...base,
|
|
150
166
|
installed: true,
|
|
@@ -152,6 +168,7 @@ async function processHarness(detection, opts, prompt) {
|
|
|
152
168
|
verifyDepth: verify.depth,
|
|
153
169
|
verifyOk: verify.ok,
|
|
154
170
|
verifyReason: verify.reason,
|
|
171
|
+
keyAuthed,
|
|
155
172
|
};
|
|
156
173
|
}
|
|
157
174
|
function summaryLine(result, uninstall) {
|
|
@@ -169,7 +186,10 @@ function summaryLine(result, uninstall) {
|
|
|
169
186
|
return `… ${name} — ${result.fixCommand}`;
|
|
170
187
|
}
|
|
171
188
|
if (result.outcome === "no_tty_auth_skip") {
|
|
172
|
-
|
|
189
|
+
const why = result.keyUnusable
|
|
190
|
+
? `${name} cannot take an API key and there is no terminal to sign in`
|
|
191
|
+
: "no stored credentials and no terminal to sign in";
|
|
192
|
+
return `… ${name} — skipped, ${why}. Fix: ${result.fixCommand}`;
|
|
173
193
|
}
|
|
174
194
|
if (result.outcome === "uninstall_pointer") {
|
|
175
195
|
return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
|
|
@@ -188,7 +208,8 @@ function summaryLine(result, uninstall) {
|
|
|
188
208
|
if (result.outcome === "failed") {
|
|
189
209
|
return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
|
|
190
210
|
}
|
|
191
|
-
|
|
211
|
+
// A keyed install has no sign-in left, so the deferred-auth warning would contradict it.
|
|
212
|
+
const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness) && !result.keyAuthed;
|
|
192
213
|
const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : "not verified";
|
|
193
214
|
if (authDeferred || result.verifyOk === false) {
|
|
194
215
|
const reason = authDeferred
|
|
@@ -210,46 +231,53 @@ function computeExitCode(results) {
|
|
|
210
231
|
}
|
|
211
232
|
export async function runConnectAll(opts) {
|
|
212
233
|
const isTTY = detectHumanInitiated();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const detections = detectInstalledHarnesses();
|
|
224
|
-
const results = [];
|
|
225
|
-
try {
|
|
226
|
-
for (const [index, detection] of detections.entries()) {
|
|
227
|
-
const { harness } = detection;
|
|
228
|
-
errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
|
|
229
|
-
let result;
|
|
234
|
+
let stdinClosed = false;
|
|
235
|
+
// Open interface holds stdin raw; hermes' input() then wedges.
|
|
236
|
+
const prompt = isTTY
|
|
237
|
+
? async (question) => {
|
|
238
|
+
if (stdinClosed)
|
|
239
|
+
throw new Error("stdin is closed");
|
|
240
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
241
|
+
// Without a close-linked signal, question() never settles on Ctrl+D.
|
|
242
|
+
const closed = new AbortController();
|
|
243
|
+
rl.on("close", () => closed.abort());
|
|
230
244
|
try {
|
|
231
|
-
|
|
245
|
+
return await rl.question(question, { signal: closed.signal });
|
|
232
246
|
}
|
|
233
247
|
catch (e) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
installed: false,
|
|
240
|
-
outcome: "failed",
|
|
241
|
-
errorMessage: e instanceof Error ? e.message : String(e),
|
|
242
|
-
fixCommand: `tinyfish connect ${harness}`,
|
|
243
|
-
};
|
|
248
|
+
stdinClosed = true;
|
|
249
|
+
throw e;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
rl.close();
|
|
244
253
|
}
|
|
245
|
-
results.push(result);
|
|
246
|
-
// Ctrl+C means stop, not continue to the next harness.
|
|
247
|
-
if (result.outcome === "interrupted")
|
|
248
|
-
break;
|
|
249
254
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
255
|
+
: undefined;
|
|
256
|
+
const detections = detectInstalledHarnesses();
|
|
257
|
+
const results = [];
|
|
258
|
+
for (const [index, detection] of detections.entries()) {
|
|
259
|
+
const { harness } = detection;
|
|
260
|
+
errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
|
|
261
|
+
let result;
|
|
262
|
+
try {
|
|
263
|
+
result = await processHarness(detection, opts, prompt);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
// Isolation: one harness throwing never stops the others.
|
|
267
|
+
result = {
|
|
268
|
+
harness,
|
|
269
|
+
detected: detection.detected,
|
|
270
|
+
configPath: detection.configPath,
|
|
271
|
+
installed: false,
|
|
272
|
+
outcome: "failed",
|
|
273
|
+
errorMessage: e instanceof Error ? e.message : String(e),
|
|
274
|
+
fixCommand: `tinyfish connect ${harness}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
results.push(result);
|
|
278
|
+
// Ctrl+C means stop, not continue to the next harness.
|
|
279
|
+
if (result.outcome === "interrupted")
|
|
280
|
+
break;
|
|
253
281
|
}
|
|
254
282
|
// The interrupt break truncates `results`; pad so the 5-entry telemetry schema still parses.
|
|
255
283
|
for (const detection of detections) {
|
|
@@ -6,8 +6,12 @@ export declare const OPENCLAW_ONBOARDING_PROMPT: string;
|
|
|
6
6
|
export interface NativeMcpClient extends SupportedCommand {
|
|
7
7
|
connectClient: Exclude<AgentClient, "openclaw">;
|
|
8
8
|
skillAgent: "claude-code" | "codex" | "hermes-agent" | "opencode";
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
|
|
10
|
+
keyAuth?: {
|
|
11
|
+
/** Key reaches the client through TINYFISH_API_KEY, not argv. */
|
|
12
|
+
viaEnv?: boolean;
|
|
13
|
+
addArgs: (mcpUrl: string, apiKey: string) => string[];
|
|
14
|
+
};
|
|
11
15
|
loginArgs?: string[];
|
|
12
16
|
/** Replaces the `loginArgs` step when `supportCheck.optionalPatterns` miss. */
|
|
13
17
|
degradedAuthNotes?: {
|
|
@@ -35,6 +39,8 @@ export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sy
|
|
|
35
39
|
export declare const CODEX: NativeMcpClient;
|
|
36
40
|
export declare const HERMES: NativeMcpClient;
|
|
37
41
|
export declare const OPENCODE: NativeMcpClient;
|
|
42
|
+
/** Every native MCP client. Derive from this, so a new harness cannot be missed. */
|
|
43
|
+
export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
|
|
38
44
|
export declare const OPENCLAW: SupportedCommand;
|
|
39
45
|
export declare function launchNativeMcpClient(client: NativeMcpClient): boolean;
|
|
40
46
|
export declare function launchOpenClawWalkthrough(): boolean;
|
|
@@ -54,6 +54,22 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
|
|
|
54
54
|
"want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
|
|
55
55
|
"which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
|
|
56
56
|
"I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
|
|
57
|
+
const HEADER_FLAG = /(?:^|\s)--header(?:[\s<=]|$)/m;
|
|
58
|
+
const claudeAddArgs = (mcpUrl) => [
|
|
59
|
+
"mcp",
|
|
60
|
+
"add",
|
|
61
|
+
"--scope",
|
|
62
|
+
"user",
|
|
63
|
+
"--transport",
|
|
64
|
+
"http",
|
|
65
|
+
"tinyfish",
|
|
66
|
+
mcpUrl,
|
|
67
|
+
];
|
|
68
|
+
const urlAddArgs = (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl];
|
|
69
|
+
// OpenCode takes `--header NAME=VALUE`, not Claude Code's `NAME: VALUE`.
|
|
70
|
+
const headerKeyAuth = {
|
|
71
|
+
addArgs: (mcpUrl, apiKey) => [...urlAddArgs(mcpUrl), "--header", `X-API-Key=${apiKey}`],
|
|
72
|
+
};
|
|
57
73
|
export const CLAUDE_CODE = {
|
|
58
74
|
command: "claude",
|
|
59
75
|
connectClient: "claude-code",
|
|
@@ -63,9 +79,12 @@ export const CLAUDE_CODE = {
|
|
|
63
79
|
args: ["mcp", "--help"],
|
|
64
80
|
patterns: [/^\s*add(?:\s|\[)/m],
|
|
65
81
|
optionalPatterns: [/^\s*login(?:\s|\[)/m],
|
|
82
|
+
keyAuthPattern: HEADER_FLAG,
|
|
66
83
|
unavailableMessage: MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
67
84
|
},
|
|
68
|
-
|
|
85
|
+
keyAuth: {
|
|
86
|
+
addArgs: (mcpUrl, apiKey) => [...claudeAddArgs(mcpUrl), "--header", `X-API-Key: ${apiKey}`],
|
|
87
|
+
},
|
|
69
88
|
loginArgs: ["mcp", "login", "tinyfish"],
|
|
70
89
|
degradedAuthNotes: {
|
|
71
90
|
keyed: CLAUDE_CODE_KEYED_FALLBACK_NOTE,
|
|
@@ -79,7 +98,7 @@ export const CLAUDE_CODE = {
|
|
|
79
98
|
label: "local TinyFish registration",
|
|
80
99
|
},
|
|
81
100
|
],
|
|
82
|
-
addArgs: (mcpUrl) =>
|
|
101
|
+
addArgs: (mcpUrl) => claudeAddArgs(mcpUrl),
|
|
83
102
|
};
|
|
84
103
|
/**
|
|
85
104
|
* `cmd` re-parses its command line after `/c`, so an unquoted `&` in the query string ends the
|
|
@@ -115,22 +134,21 @@ export const CODEX = {
|
|
|
115
134
|
supportCheck: {
|
|
116
135
|
args: ["mcp", "add", "--help"],
|
|
117
136
|
patterns: [/--oauth-resource(?:\s|<)/],
|
|
137
|
+
keyAuthPattern: /(?:^|\s)--bearer-token-env-var(?:[\s<=]|$)/m,
|
|
118
138
|
unavailableMessage: CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE,
|
|
119
139
|
},
|
|
140
|
+
// Codex has no `--header`; the MCP server takes a key as `Bearer sk-…`.
|
|
141
|
+
// Omitting `--oauth-resource` is what keeps `mcp add` from running OAuth.
|
|
142
|
+
keyAuth: {
|
|
143
|
+
viaEnv: true,
|
|
144
|
+
addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--bearer-token-env-var", "TINYFISH_API_KEY"],
|
|
145
|
+
},
|
|
120
146
|
launchWalkthrough: launchCodexWalkthrough,
|
|
121
147
|
probeAuthenticated: codexOauthCompleted,
|
|
122
148
|
// Retry only: running it after a finished inline OAuth is a second browser hop.
|
|
123
149
|
loginArgs: ["mcp", "login", "tinyfish"],
|
|
124
150
|
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
|
|
125
|
-
addArgs: (mcpUrl, oauthResource) => [
|
|
126
|
-
"mcp",
|
|
127
|
-
"add",
|
|
128
|
-
"tinyfish",
|
|
129
|
-
"--url",
|
|
130
|
-
mcpUrl,
|
|
131
|
-
"--oauth-resource",
|
|
132
|
-
oauthResource,
|
|
133
|
-
],
|
|
151
|
+
addArgs: (mcpUrl, oauthResource) => [...urlAddArgs(mcpUrl), "--oauth-resource", oauthResource],
|
|
134
152
|
};
|
|
135
153
|
function launchHermesWalkthrough() {
|
|
136
154
|
const seedResult = spawn.sync("hermes", ["chat", "-Q", "-q", DEFAULT_ONBOARDING_PROMPT], {
|
|
@@ -168,9 +186,10 @@ export const HERMES = {
|
|
|
168
186
|
patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
|
|
169
187
|
unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
|
|
170
188
|
},
|
|
189
|
+
// No `keyAuth`: `hermes mcp add` has no --header, and `--auth header` prompts interactively.
|
|
171
190
|
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
|
|
172
191
|
// Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
|
|
173
|
-
addArgs: (mcpUrl) => [
|
|
192
|
+
addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--auth", "oauth"],
|
|
174
193
|
launchWalkthrough: launchHermesWalkthrough,
|
|
175
194
|
};
|
|
176
195
|
// OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
|
|
@@ -197,16 +216,25 @@ export const OPENCODE = {
|
|
|
197
216
|
supportCheck: {
|
|
198
217
|
args: ["mcp", "add", "--help"],
|
|
199
218
|
patterns: [/--url(?:\s|$)/m],
|
|
219
|
+
keyAuthPattern: HEADER_FLAG,
|
|
200
220
|
unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
201
221
|
},
|
|
222
|
+
keyAuth: headerKeyAuth,
|
|
202
223
|
// `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
|
|
203
224
|
removals: [],
|
|
204
225
|
// OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
|
|
205
|
-
addArgs: (mcpUrl) =>
|
|
226
|
+
addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
|
|
206
227
|
loginArgs: ["mcp", "auth", "tinyfish"],
|
|
207
228
|
launchWalkthrough: launchOpencode,
|
|
208
229
|
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
209
230
|
};
|
|
231
|
+
/** Every native MCP client. Derive from this, so a new harness cannot be missed. */
|
|
232
|
+
export const NATIVE_MCP_CLIENTS = [
|
|
233
|
+
CLAUDE_CODE,
|
|
234
|
+
CODEX,
|
|
235
|
+
HERMES,
|
|
236
|
+
OPENCODE,
|
|
237
|
+
];
|
|
210
238
|
export const OPENCLAW = {
|
|
211
239
|
command: "openclaw",
|
|
212
240
|
displayName: "OpenClaw",
|
|
@@ -1,9 +1,10 @@
|
|
|
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 {
|
|
4
|
+
import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
|
|
5
5
|
import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
6
6
|
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
7
|
+
import { installRoot } from "./install-root.js";
|
|
7
8
|
import { errLine } from "./output.js";
|
|
8
9
|
export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
9
10
|
export const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
@@ -15,7 +16,10 @@ const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
|
|
|
15
16
|
const TINYFISH_WEB_SKILL = "use-tinyfish";
|
|
16
17
|
export function installTinyFishCli() {
|
|
17
18
|
errLine("Installing the TinyFish CLI...");
|
|
18
|
-
|
|
19
|
+
// Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
|
|
20
|
+
const root = installRoot();
|
|
21
|
+
const prefixArgs = root ? ["--prefix", root.prefix] : [];
|
|
22
|
+
const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], {
|
|
19
23
|
stdio: "inherit",
|
|
20
24
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
21
25
|
});
|
|
@@ -85,7 +89,7 @@ export function installWebSkill(client) {
|
|
|
85
89
|
export function updateWebSkill() {
|
|
86
90
|
// One entry per `tinyfish connect <client>`, persisted by PF-3169.
|
|
87
91
|
const connected = loadConfig().connect ?? {};
|
|
88
|
-
const skillAgents =
|
|
92
|
+
const skillAgents = NATIVE_MCP_CLIENTS
|
|
89
93
|
.filter((client) => connected[client.connectClient])
|
|
90
94
|
.map((client) => client.skillAgent);
|
|
91
95
|
const hasOpenClaw = Boolean(connected["openclaw"]);
|
|
@@ -19,6 +19,8 @@ export interface ConnectRunState {
|
|
|
19
19
|
settled: boolean;
|
|
20
20
|
/** On state, not per-call: otherwise degraded installs never appear to fail. */
|
|
21
21
|
harnessDegraded?: boolean;
|
|
22
|
+
/** Resolved after the probe, so callers read real key use rather than key presence. */
|
|
23
|
+
authMode?: ConnectAuthMode;
|
|
22
24
|
}
|
|
23
25
|
interface ConnectStageDetail {
|
|
24
26
|
failedStage?: ConnectFailureStage;
|
|
@@ -47,11 +49,14 @@ export interface SupportedCommand {
|
|
|
47
49
|
patterns: RegExp[];
|
|
48
50
|
/** Missing → degrade to key or deferred auth, never refuse. */
|
|
49
51
|
optionalPatterns?: RegExp[];
|
|
52
|
+
/** Missing → this install cannot take a stored key; fall back to OAuth. */
|
|
53
|
+
keyAuthPattern?: RegExp;
|
|
50
54
|
unavailableMessage: string;
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
54
58
|
optionalSupported: boolean;
|
|
59
|
+
keyAuthSupported: boolean;
|
|
55
60
|
};
|
|
56
61
|
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
57
62
|
apiKey?: string;
|
|
@@ -95,7 +95,9 @@ export function requireCommandSupport(client) {
|
|
|
95
95
|
const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
|
|
96
96
|
const essential = listed(client.supportCheck.patterns);
|
|
97
97
|
const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
|
|
98
|
-
|
|
98
|
+
const keyAuthPattern = client.supportCheck.keyAuthPattern;
|
|
99
|
+
const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
|
|
100
|
+
if ((!essential || !optionalSupported || !keyAuthSupported) && process.env["TINYFISH_DEBUG"]) {
|
|
99
101
|
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
100
102
|
}
|
|
101
103
|
if (!essential) {
|
|
@@ -103,7 +105,7 @@ export function requireCommandSupport(client) {
|
|
|
103
105
|
harnessVersion: probeHarnessVersion(client.command),
|
|
104
106
|
});
|
|
105
107
|
}
|
|
106
|
-
return { optionalSupported };
|
|
108
|
+
return { optionalSupported, keyAuthSupported };
|
|
107
109
|
}
|
|
108
110
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
109
111
|
function probeHarnessVersion(command) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Where this CLI is installed: `prefix` for npm, `nodeModules` for reading the version back. */
|
|
2
|
+
export type InstallRoot = {
|
|
3
|
+
prefix: string;
|
|
4
|
+
nodeModules: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The prefix to upgrade in place, or null when the layout is not npm's.
|
|
8
|
+
*
|
|
9
|
+
* Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
|
|
10
|
+
* installing into their trees would report a success the PATH binary never sees.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveInstallRoot(packageRoot: string, platform: typeof process.platform, onDisk?: (candidate: string) => boolean): InstallRoot | null;
|
|
13
|
+
export declare function installRoot(): InstallRoot | null;
|
|
14
|
+
/** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
|
|
15
|
+
export declare function readInstalledVersion(nodeModules: string): string | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
5
|
+
function isDirectory(candidate) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(candidate).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The prefix to upgrade in place, or null when the layout is not npm's.
|
|
15
|
+
*
|
|
16
|
+
* Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
|
|
17
|
+
* installing into their trees would report a success the PATH binary never sees.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveInstallRoot(packageRoot, platform, onDisk = isDirectory) {
|
|
20
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
21
|
+
// npx's win32 cache uses the same bare layout as a global install.
|
|
22
|
+
if (packageRoot.split(p.sep).includes("_npx"))
|
|
23
|
+
return null;
|
|
24
|
+
const nodeModules = p.resolve(packageRoot, "..", "..");
|
|
25
|
+
if (p.basename(nodeModules) !== "node_modules")
|
|
26
|
+
return null;
|
|
27
|
+
const container = p.dirname(nodeModules);
|
|
28
|
+
// Only win32 puts node_modules directly under the prefix.
|
|
29
|
+
if (platform !== "win32" && p.basename(container) !== "lib")
|
|
30
|
+
return null;
|
|
31
|
+
if (!onDisk(nodeModules))
|
|
32
|
+
return null;
|
|
33
|
+
return { prefix: platform === "win32" ? container : p.dirname(container), nodeModules };
|
|
34
|
+
}
|
|
35
|
+
export function installRoot() {
|
|
36
|
+
// Two levels up from dist/lib/ is the package root, and tsc mirrors src/ into dist/.
|
|
37
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
38
|
+
return resolveInstallRoot(packageRoot, process.platform);
|
|
39
|
+
}
|
|
40
|
+
/** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
|
|
41
|
+
export function readInstalledVersion(nodeModules) {
|
|
42
|
+
try {
|
|
43
|
+
const manifest = fs.readFileSync(path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split("/"), "package.json"), "utf8");
|
|
44
|
+
return JSON.parse(manifest).version ?? null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -6,8 +6,8 @@ import { loadConfig } from "./auth.js";
|
|
|
6
6
|
import { errLine } from "./output.js";
|
|
7
7
|
import { readCursorTinyfishEntry } from "./cursor-config.js";
|
|
8
8
|
import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
|
|
9
|
-
//
|
|
10
|
-
const PROBE_TIMEOUT_MS =
|
|
9
|
+
// 6s tripped on a cold `hermes mcp list`, measured at 6.34s.
|
|
10
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
11
11
|
// A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
|
|
12
12
|
// field is optional, so that arm parsed *any* object-of-objects: a wrapper like
|
|
13
13
|
// `{"servers": {...}}` decoded as one entry named `servers` and reported TinyFish absent.
|
|
@@ -224,7 +224,7 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
|
|
|
224
224
|
}
|
|
225
225
|
return { registered: "no", authMode: "unknown" };
|
|
226
226
|
}
|
|
227
|
-
//
|
|
227
|
+
// connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
|
|
228
228
|
function probeHermes() {
|
|
229
229
|
const probe = runProbe("hermes", ["mcp", "list"]);
|
|
230
230
|
if (probe.outcome === "unavailable") {
|
|
@@ -259,7 +259,7 @@ function probeOpenClaw() {
|
|
|
259
259
|
? { registered: "yes", authMode: "api-key" }
|
|
260
260
|
: { registered: "no", authMode: "unknown" };
|
|
261
261
|
}
|
|
262
|
-
//
|
|
262
|
+
// `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
|
|
263
263
|
function probeOpencode() {
|
|
264
264
|
const probe = runProbe("opencode", ["mcp", "list"]);
|
|
265
265
|
if (probe.outcome === "unavailable") {
|
|
@@ -272,7 +272,7 @@ function probeOpencode() {
|
|
|
272
272
|
reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
|
|
273
273
|
};
|
|
274
274
|
}
|
|
275
|
-
return fromMcpList("opencode", probe.output, "
|
|
275
|
+
return fromMcpList("opencode", probe.output, "unknown", OPENCODE_ROW_URL);
|
|
276
276
|
}
|
|
277
277
|
const PROBES = {
|
|
278
278
|
"claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
|