@tiny-fish/cli 0.20.1 → 0.21.1-next.194
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 +67 -63
- package/dist/commands/doctor.js +89 -37
- package/dist/commands/upgrade.d.ts +2 -1
- package/dist/commands/upgrade.js +16 -5
- package/dist/lib/auth.d.ts +2 -0
- package/dist/lib/auth.js +5 -1
- package/dist/lib/connect-all.js +44 -23
- package/dist/lib/connect-clients.d.ts +8 -2
- package/dist/lib/connect-clients.js +52 -27
- package/dist/lib/connect-install.js +15 -16
- package/dist/lib/connect-runtime.d.ts +27 -3
- package/dist/lib/connect-runtime.js +54 -6
- package/dist/lib/cursor-config.d.ts +2 -1
- package/dist/lib/cursor-config.js +6 -3
- package/dist/lib/doctor-report.d.ts +6 -21
- package/dist/lib/doctor-report.js +4 -4
- package/dist/lib/harness-detect.d.ts +10 -0
- package/dist/lib/harness-detect.js +13 -0
- package/dist/lib/install-root.d.ts +15 -0
- package/dist/lib/install-root.js +49 -0
- package/dist/lib/registration-detect.d.ts +3 -3
- package/dist/lib/registration-detect.js +54 -44
- package/dist/lib/verify.d.ts +2 -0
- package/dist/lib/verify.js +1 -0
- 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,10 +1,11 @@
|
|
|
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, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
6
6
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
7
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
|
+
import { AuthMode } from "../lib/harness-detect.js";
|
|
8
9
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
9
10
|
import { emitNotice } from "../lib/notice.js";
|
|
10
11
|
import { errLine } from "../lib/output.js";
|
|
@@ -22,20 +23,49 @@ function removeExistingRegistration(client, removal) {
|
|
|
22
23
|
const details = result.stderr?.trim() || result.error?.message || "unknown error";
|
|
23
24
|
if (/No MCP server named "tinyfish"/i.test(details))
|
|
24
25
|
return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
throw spawnStepError(`Could not remove existing ${removal.label}: ${details}`, result);
|
|
27
|
+
}
|
|
28
|
+
/** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
|
|
29
|
+
function exportApiKey(apiKey, displayName) {
|
|
30
|
+
try {
|
|
31
|
+
persistApiKeyToEnvironment(apiKey);
|
|
29
32
|
}
|
|
30
|
-
|
|
33
|
+
catch (error) {
|
|
34
|
+
errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}); ` +
|
|
35
|
+
"signing in instead.");
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
process.env["TINYFISH_API_KEY"] = apiKey;
|
|
39
|
+
errLine(`Exported TINYFISH_API_KEY to your shell profile — ${displayName} reads the key from there, ` +
|
|
40
|
+
"so restart it (or open a new terminal) before use.");
|
|
41
|
+
return true;
|
|
31
42
|
}
|
|
32
43
|
async function connectNativeMcpClient(client, options) {
|
|
33
44
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
34
45
|
const state = { stage: "prerequisite_check", settled: false };
|
|
35
46
|
await runGuarded(state, telemetry, () => {
|
|
36
47
|
telemetry.track("started");
|
|
37
|
-
const { optionalSupported } = requireCommandSupport(client);
|
|
48
|
+
const { optionalSupported, keyAuthSupported, harnessVersion } = requireCommandSupport(client);
|
|
49
|
+
telemetry.setHarnessVersion(harnessVersion);
|
|
38
50
|
state.harnessDegraded = !!client.loginArgs && !optionalSupported;
|
|
51
|
+
// Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
|
|
52
|
+
const storedKey = validatedApiKey(options.apiKey);
|
|
53
|
+
let keyedAdd = storedKey && keyAuthSupported && client.keyAuth
|
|
54
|
+
? { key: storedKey, spec: client.keyAuth }
|
|
55
|
+
: undefined;
|
|
56
|
+
// Before the removals: a failed export must not strand the old registration.
|
|
57
|
+
if (keyedAdd?.spec.viaEnv && !exportApiKey(keyedAdd.key, client.displayName)) {
|
|
58
|
+
keyedAdd = undefined;
|
|
59
|
+
}
|
|
60
|
+
const useKeyAuth = !!keyedAdd;
|
|
61
|
+
if (options.keyAuthOnly && !useKeyAuth) {
|
|
62
|
+
// No keyAuth at all is structural (Hermes); a missing flag is just an old install.
|
|
63
|
+
throw new Error(client.keyAuth
|
|
64
|
+
? `This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
|
|
65
|
+
`needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`
|
|
66
|
+
: `${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
|
|
67
|
+
`terminal. Run: tinyfish connect ${client.connectClient}`);
|
|
68
|
+
}
|
|
39
69
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
40
70
|
state.stage = "registration_cleanup";
|
|
41
71
|
for (const removal of client.removals)
|
|
@@ -44,9 +74,6 @@ async function connectNativeMcpClient(client, options) {
|
|
|
44
74
|
mcpUrl.searchParams.set("source", CONNECT_SOURCE);
|
|
45
75
|
mcpUrl.searchParams.set("client", client.connectClient);
|
|
46
76
|
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
77
|
// Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
|
|
51
78
|
const loginDegraded = state.harnessDegraded === true;
|
|
52
79
|
const loginArgs = loginDegraded ? undefined : client.loginArgs;
|
|
@@ -54,19 +81,15 @@ async function connectNativeMcpClient(client, options) {
|
|
|
54
81
|
state.stage =
|
|
55
82
|
loginArgs && !client.probeAuthenticated ? "registration" : "registration_or_authentication";
|
|
56
83
|
errLine(`Adding TinyFish to ${client.displayName}...`);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
], {
|
|
84
|
+
// Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
|
|
85
|
+
const addArgs = keyedAdd
|
|
86
|
+
? keyedAdd.spec.addArgs(mcpUrl.toString(), keyedAdd.key)
|
|
87
|
+
: client.addArgs(mcpUrl.toString(), options.mcpUrl);
|
|
88
|
+
const addResult = spawn.sync(client.command, addArgs, {
|
|
63
89
|
stdio: "inherit",
|
|
64
90
|
});
|
|
65
91
|
if (addResult.error || addResult.status !== 0) {
|
|
66
|
-
|
|
67
|
-
throw new Error(`Could not add TinyFish to ${client.displayName}`, {
|
|
68
|
-
cause: addResult.error,
|
|
69
|
-
});
|
|
92
|
+
throw spawnStepError(`Could not add TinyFish to ${client.displayName}`, addResult);
|
|
70
93
|
}
|
|
71
94
|
telemetry.track("checkpoint", { phase: "registered" });
|
|
72
95
|
// Codex runs OAuth inside `mcp add` and exits 0 either way, so the recorded state decides
|
|
@@ -92,23 +115,22 @@ async function connectNativeMcpClient(client, options) {
|
|
|
92
115
|
stdio: "inherit",
|
|
93
116
|
});
|
|
94
117
|
if (loginResult.error || loginResult.status !== 0) {
|
|
95
|
-
|
|
96
|
-
throw new Error(`Could not authenticate TinyFish in ${client.displayName}`, {
|
|
97
|
-
cause: loginResult.error,
|
|
98
|
-
});
|
|
118
|
+
throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
|
|
99
119
|
}
|
|
100
120
|
telemetry.track("checkpoint", { phase: "oauth_done" });
|
|
101
121
|
}
|
|
102
122
|
const signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
103
123
|
const authMode = useKeyAuth
|
|
104
|
-
?
|
|
124
|
+
? AuthMode.ApiKey
|
|
105
125
|
: signInDeferred
|
|
106
126
|
? "deferred"
|
|
107
|
-
:
|
|
127
|
+
: AuthMode.OAuth;
|
|
128
|
+
state.authMode = authMode;
|
|
108
129
|
// Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
|
|
109
130
|
settle(state, telemetry, "completed", { authMode });
|
|
110
131
|
runPostInstallSteps(client, options, state, telemetry, signInDeferred);
|
|
111
132
|
});
|
|
133
|
+
return state.authMode;
|
|
112
134
|
}
|
|
113
135
|
function connectedLine(client, signInDeferred) {
|
|
114
136
|
// The CLI never observes deferred auth, so don't claim "connected".
|
|
@@ -150,23 +172,26 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
|
|
|
150
172
|
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
151
173
|
function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
|
|
152
174
|
if (!(error instanceof ConnectInterruptedError)) {
|
|
153
|
-
telemetry.track("post_install_failed", {
|
|
175
|
+
telemetry.track("post_install_failed", {
|
|
176
|
+
failedStage: state.stage,
|
|
177
|
+
...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
|
|
178
|
+
});
|
|
154
179
|
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
155
180
|
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
156
181
|
}
|
|
157
182
|
errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
|
|
158
183
|
}
|
|
159
184
|
export async function connectClaudeCode(options) {
|
|
160
|
-
|
|
185
|
+
return connectNativeMcpClient(CLAUDE_CODE, options);
|
|
161
186
|
}
|
|
162
187
|
export async function connectCodex(options) {
|
|
163
|
-
|
|
188
|
+
return connectNativeMcpClient(CODEX, options);
|
|
164
189
|
}
|
|
165
190
|
export async function connectHermes(options) {
|
|
166
|
-
|
|
191
|
+
return connectNativeMcpClient(HERMES, options);
|
|
167
192
|
}
|
|
168
193
|
export async function connectOpencode(options) {
|
|
169
|
-
|
|
194
|
+
return connectNativeMcpClient(OPENCODE, options);
|
|
170
195
|
}
|
|
171
196
|
export async function connectOpenClaw(options) {
|
|
172
197
|
const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
|
|
@@ -178,7 +203,7 @@ export async function connectOpenClaw(options) {
|
|
|
178
203
|
};
|
|
179
204
|
await runGuarded(state, telemetry, () => {
|
|
180
205
|
telemetry.track("started");
|
|
181
|
-
requireCommandSupport(OPENCLAW);
|
|
206
|
+
telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
|
|
182
207
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
183
208
|
if (options.installCli !== false) {
|
|
184
209
|
state.stage = "cli_install";
|
|
@@ -192,35 +217,12 @@ export async function connectOpenClaw(options) {
|
|
|
192
217
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
193
218
|
});
|
|
194
219
|
if (skillInstallResult.error || skillInstallResult.status !== 0) {
|
|
195
|
-
|
|
196
|
-
throw new Error("Could not install the TinyFish skill in OpenClaw", {
|
|
197
|
-
cause: skillInstallResult.error,
|
|
198
|
-
});
|
|
220
|
+
throw spawnStepError("Could not install the TinyFish skill in OpenClaw", skillInstallResult);
|
|
199
221
|
}
|
|
200
222
|
telemetry.track("checkpoint", { phase: "skill_installed" });
|
|
223
|
+
// Stores the key before probing, so a passed --api-key never reaches an interactive login.
|
|
201
224
|
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
|
-
}
|
|
225
|
+
ensureCliAuthenticated("openclaw", options.apiKey);
|
|
224
226
|
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
225
227
|
saveConnectContext("openclaw", telemetry.attemptId);
|
|
226
228
|
errLine(UPGRADE_HINT);
|
|
@@ -269,8 +271,8 @@ export async function connectCursor(options) {
|
|
|
269
271
|
// Attempt id rides telemetry only; persisting it would break idempotency.
|
|
270
272
|
const result = writeCursorMcpConfig(mcpUrl.toString(), resolvedKey);
|
|
271
273
|
if (result.status === "corrupt_skip") {
|
|
272
|
-
throw new
|
|
273
|
-
`Fix the file, then re-run: tinyfish connect cursor
|
|
274
|
+
throw new ConnectStepError(`Could not update ${cursorMcpPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
275
|
+
`Fix the file, then re-run: tinyfish connect cursor`, "invalid_config");
|
|
274
276
|
}
|
|
275
277
|
if (result.backupPath) {
|
|
276
278
|
errLine(`Backed up existing Cursor MCP config to ${result.backupPath}`);
|
|
@@ -297,7 +299,7 @@ export async function connectCursor(options) {
|
|
|
297
299
|
else {
|
|
298
300
|
errLine("TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.");
|
|
299
301
|
}
|
|
300
|
-
settle(state, telemetry, "completed", { authMode: resolvedKey ?
|
|
302
|
+
settle(state, telemetry, "completed", { authMode: resolvedKey ? AuthMode.ApiKey : "deferred" });
|
|
301
303
|
});
|
|
302
304
|
}
|
|
303
305
|
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
|
@@ -400,6 +402,8 @@ export function registerConnect(program) {
|
|
|
400
402
|
mcpUrl,
|
|
401
403
|
launch: options.launch ?? false,
|
|
402
404
|
attemptId,
|
|
405
|
+
// A key that the install cannot use leaves only a browser hop no agent can finish.
|
|
406
|
+
keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
|
|
403
407
|
};
|
|
404
408
|
if (client === "claude-code") {
|
|
405
409
|
await connectClaudeCode(connectOptions);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,20 +2,22 @@ import spawn from "cross-spawn";
|
|
|
2
2
|
import { apiKeyStatus } from "../lib/auth.js";
|
|
3
3
|
import { CLI_VERSION } from "../lib/constants.js";
|
|
4
4
|
import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
|
|
5
|
-
import { ALL_HARNESSES } from "../lib/harness-detect.js";
|
|
5
|
+
import { ALL_HARNESSES, AuthMode, Registered } from "../lib/harness-detect.js";
|
|
6
6
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
7
7
|
import { err, errLine, out, outLine } from "../lib/output.js";
|
|
8
8
|
import { detectRegistrations } from "../lib/registration-detect.js";
|
|
9
9
|
import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
|
|
10
10
|
import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
|
|
11
11
|
// A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
|
|
12
|
-
function provesHarnessReach(status,
|
|
12
|
+
function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
|
|
13
13
|
// An endpoint doctor could not reach proves nothing about a harness pointed at it.
|
|
14
14
|
if (!healthOk)
|
|
15
15
|
return false;
|
|
16
16
|
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
17
17
|
return false;
|
|
18
|
-
return status.registered ===
|
|
18
|
+
return (status.registered === Registered.Yes &&
|
|
19
|
+
status.authMode === AuthMode.ApiKey &&
|
|
20
|
+
keyAuth?.ok === true);
|
|
19
21
|
}
|
|
20
22
|
// Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
|
|
21
23
|
function checkCliVersion() {
|
|
@@ -55,13 +57,31 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
|
|
|
55
57
|
return "an unparseable endpoint";
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
function
|
|
60
|
+
function keyedVerdict(keyAuth) {
|
|
61
|
+
if (!keyAuth)
|
|
62
|
+
return { status: "warn", detail: "registered, API key present but unverified" };
|
|
63
|
+
if (keyAuth.ok) {
|
|
64
|
+
return {
|
|
65
|
+
status: "pass",
|
|
66
|
+
detail: "registered, auth mode api-key (key verified against the TinyFish API)",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (keyAuth.status === 401) {
|
|
70
|
+
return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
|
|
71
|
+
}
|
|
72
|
+
// Only 401 blames the key; connect would loop on the rest.
|
|
73
|
+
if (keyAuth.status === 403) {
|
|
74
|
+
return { status: "warn", detail: "registered, but its API key is not allowed to list runs" };
|
|
75
|
+
}
|
|
76
|
+
return { status: "warn", detail: "registered, key could not be verified" };
|
|
77
|
+
}
|
|
78
|
+
function registrationVerdict(status, mcpUrl, keyAuth) {
|
|
59
79
|
if (!status.detected)
|
|
60
80
|
return { status: "skip", detail: "harness not installed" };
|
|
61
|
-
if (status.registered ===
|
|
81
|
+
if (status.registered === Registered.Unknown) {
|
|
62
82
|
return { status: "warn", detail: status.reason ?? "could not determine registration" };
|
|
63
83
|
}
|
|
64
|
-
if (status.registered ===
|
|
84
|
+
if (status.registered === Registered.Yes) {
|
|
65
85
|
// A `tinyfish` entry aimed at a dev server is registered and still cannot reach TinyFish.
|
|
66
86
|
const elsewhere = pointsElsewhere(status.registeredUrl, mcpUrl);
|
|
67
87
|
if (elsewhere) {
|
|
@@ -70,6 +90,9 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
70
90
|
detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
|
|
71
91
|
};
|
|
72
92
|
}
|
|
93
|
+
// Widening past api-key would warn every healthy oauth install.
|
|
94
|
+
if (status.authMode === AuthMode.ApiKey)
|
|
95
|
+
return keyedVerdict(keyAuth);
|
|
73
96
|
// A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
|
|
74
97
|
const because = status.reason ? ` (${status.reason})` : "";
|
|
75
98
|
return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
|
|
@@ -79,12 +102,12 @@ function registrationVerdict(status, mcpUrl) {
|
|
|
79
102
|
? { status: "fail", detail: "connected previously but TinyFish is no longer registered" }
|
|
80
103
|
: { status: "warn", detail: "installed but TinyFish was never connected" };
|
|
81
104
|
}
|
|
82
|
-
function checkRegistration(status, mcpUrl) {
|
|
105
|
+
function checkRegistration(status, mcpUrl, keyAuth) {
|
|
83
106
|
return {
|
|
84
107
|
id: "harness-registration",
|
|
85
108
|
title: `${status.harness} registration`,
|
|
86
109
|
harness: status.harness,
|
|
87
|
-
...registrationVerdict(status, mcpUrl),
|
|
110
|
+
...registrationVerdict(status, mcpUrl, keyAuth),
|
|
88
111
|
};
|
|
89
112
|
}
|
|
90
113
|
function checkCredential() {
|
|
@@ -98,8 +121,8 @@ function checkCredential() {
|
|
|
98
121
|
};
|
|
99
122
|
return "key" in resolved ? { check, key: resolved.key } : { check };
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
if (!
|
|
124
|
+
function checkAuthCall(auth) {
|
|
125
|
+
if (!auth) {
|
|
103
126
|
return {
|
|
104
127
|
id: "cli-auth-call",
|
|
105
128
|
title: "Authenticated call",
|
|
@@ -108,7 +131,6 @@ async function checkAuthCall(key) {
|
|
|
108
131
|
harness: null,
|
|
109
132
|
};
|
|
110
133
|
}
|
|
111
|
-
const auth = await verifyMcpAuth(key);
|
|
112
134
|
return {
|
|
113
135
|
id: "cli-auth-call",
|
|
114
136
|
title: "Authenticated call",
|
|
@@ -117,32 +139,37 @@ async function checkAuthCall(key) {
|
|
|
117
139
|
harness: null,
|
|
118
140
|
};
|
|
119
141
|
}
|
|
142
|
+
// `verifyMcpAuth` hits BASE_URL; a sandbox key 401s against prod.
|
|
143
|
+
function isDefaultEndpoint(mcpUrl) {
|
|
144
|
+
try {
|
|
145
|
+
return endpointOf(mcpUrl) === endpointOf(DEFAULT_MCP_URL);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function verifyHarnessKey(status, cliKey, cliAuth, mcpUrl) {
|
|
152
|
+
if (!isDefaultEndpoint(mcpUrl))
|
|
153
|
+
return undefined;
|
|
154
|
+
// `pointsElsewhere` already fails; a second reason only misleads.
|
|
155
|
+
if (pointsElsewhere(status.registeredUrl, mcpUrl))
|
|
156
|
+
return undefined;
|
|
157
|
+
// OpenClaw's skill shells this CLI, so its verdict stands in.
|
|
158
|
+
if (status.harness === "openclaw")
|
|
159
|
+
return cliAuth;
|
|
160
|
+
if (!status.apiKey)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (status.apiKey === cliKey && cliAuth)
|
|
163
|
+
return cliAuth;
|
|
164
|
+
return verifyMcpAuth(status.apiKey);
|
|
165
|
+
}
|
|
120
166
|
// Only Cursor's repair is a pure local file write; every other one needs a browser sign-in.
|
|
121
167
|
const UNATTENDED_SAFE = new Set(["cursor"]);
|
|
122
168
|
function repairsFor(checks, statuses) {
|
|
123
169
|
const repairs = [];
|
|
124
|
-
const credentialResolves = checks.some((c) => c.id === "cli-credential" && c.status === "pass");
|
|
125
|
-
for (const status of statuses) {
|
|
126
|
-
// `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
|
|
127
|
-
// A registered-but-misaimed entry does earn one, since connect rewrites the URL.
|
|
128
|
-
const misaimed = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
129
|
-
if (!status.detected || (status.registered !== "no" && !misaimed))
|
|
130
|
-
continue;
|
|
131
|
-
repairs.push({
|
|
132
|
-
for: status.registered === "yes"
|
|
133
|
-
? `${status.harness}-points-elsewhere`
|
|
134
|
-
: status.connectedBefore
|
|
135
|
-
? `${status.harness}-registration-lost`
|
|
136
|
-
: `${status.harness}-not-connected`,
|
|
137
|
-
action: "connect",
|
|
138
|
-
harness: status.harness,
|
|
139
|
-
command: `tinyfish connect ${status.harness}`,
|
|
140
|
-
// Without a credential even Cursor's repair shells an interactive `auth login`.
|
|
141
|
-
unattended_safe: UNATTENDED_SAFE.has(status.harness) && credentialResolves,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
170
|
// A revoked-but-well-formed key passes the credential check and fails the call; both need login.
|
|
145
171
|
const credentialBroken = checks.some((c) => (c.id === "cli-credential" || c.id === "cli-auth-call") && c.status === "fail");
|
|
172
|
+
// connect writes the stored key, so replace a dead one first.
|
|
146
173
|
if (credentialBroken) {
|
|
147
174
|
repairs.push({
|
|
148
175
|
for: "cli-credential-invalid",
|
|
@@ -152,26 +179,51 @@ function repairsFor(checks, statuses) {
|
|
|
152
179
|
unattended_safe: false,
|
|
153
180
|
});
|
|
154
181
|
}
|
|
182
|
+
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
183
|
+
for (const status of statuses) {
|
|
184
|
+
// `unknown` earns nothing: connect would contradict the check's detail.
|
|
185
|
+
// A registered entry that still fails earns one too.
|
|
186
|
+
const brokenRegistration = checks.some((c) => c.harness === status.harness && c.status === "fail");
|
|
187
|
+
if (!status.detected || (status.registered !== Registered.No && !brokenRegistration))
|
|
188
|
+
continue;
|
|
189
|
+
repairs.push({
|
|
190
|
+
for: status.registered === Registered.Yes
|
|
191
|
+
? `${status.harness}-registration-broken`
|
|
192
|
+
: status.connectedBefore
|
|
193
|
+
? `${status.harness}-registration-lost`
|
|
194
|
+
: `${status.harness}-not-connected`,
|
|
195
|
+
action: "connect",
|
|
196
|
+
harness: status.harness,
|
|
197
|
+
command: `tinyfish connect ${status.harness}`,
|
|
198
|
+
// A dead well-formed key passes the format check.
|
|
199
|
+
unattended_safe: UNATTENDED_SAFE.has(status.harness) && authCallPassed,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
155
202
|
return repairs;
|
|
156
203
|
}
|
|
157
204
|
export async function runDoctor(options) {
|
|
158
205
|
const statuses = detectRegistrations(options.harness ? [options.harness] : undefined);
|
|
159
206
|
const credential = checkCredential();
|
|
207
|
+
const cliAuth = credential.key ? verifyMcpAuth(credential.key) : undefined;
|
|
208
|
+
const [connectivity, cliAuthResult, keyAuths] = await Promise.all([
|
|
209
|
+
checkConnectivity(options.mcpUrl),
|
|
210
|
+
cliAuth,
|
|
211
|
+
Promise.all(statuses.map((status) => verifyHarnessKey(status, credential.key, cliAuth, options.mcpUrl))),
|
|
212
|
+
]);
|
|
160
213
|
const checks = [
|
|
161
214
|
checkCliVersion(),
|
|
162
|
-
|
|
163
|
-
...statuses.map((status) => checkRegistration(status, options.mcpUrl)),
|
|
215
|
+
connectivity,
|
|
216
|
+
...statuses.map((status, i) => checkRegistration(status, options.mcpUrl, keyAuths[i])),
|
|
164
217
|
credential.check,
|
|
165
|
-
|
|
218
|
+
checkAuthCall(cliAuthResult),
|
|
166
219
|
];
|
|
167
|
-
const authCallPassed = checks.some((c) => c.id === "cli-auth-call" && c.status === "pass");
|
|
168
220
|
const healthOk = checks.some((c) => c.id === "mcp-connectivity" && c.status === "pass");
|
|
169
|
-
const harnesses = statuses.map((status) => ({
|
|
221
|
+
const harnesses = statuses.map((status, i) => ({
|
|
170
222
|
harness: status.harness,
|
|
171
223
|
detected: status.detected,
|
|
172
224
|
registered: status.registered,
|
|
173
225
|
auth_mode: status.authMode,
|
|
174
|
-
proves_harness_reach: provesHarnessReach(status,
|
|
226
|
+
proves_harness_reach: provesHarnessReach(status, keyAuths[i], healthOk, options.mcpUrl),
|
|
175
227
|
}));
|
|
176
228
|
const body = {
|
|
177
229
|
schema_version: DOCTOR_SCHEMA_VERSION,
|
|
@@ -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/auth.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare function persistApiKeyToEnvironment(apiKey: string, options?: Env
|
|
|
36
36
|
export declare function clearConfig(): boolean;
|
|
37
37
|
/** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
|
|
38
38
|
export declare function isHeaderSafe(key: string): boolean;
|
|
39
|
+
/** No length rule: short real keys must survive. */
|
|
40
|
+
export declare function hasKeyPrefix(key: string): boolean;
|
|
39
41
|
export declare function validateKeyFormat(key: string): boolean;
|
|
40
42
|
export declare function maskKey(key: string): string;
|
|
41
43
|
export type KeySource = "explicit" | "env" | "config" | "none";
|
package/dist/lib/auth.js
CHANGED
|
@@ -192,8 +192,12 @@ export function isHeaderSafe(key) {
|
|
|
192
192
|
}
|
|
193
193
|
return true;
|
|
194
194
|
}
|
|
195
|
+
/** No length rule: short real keys must survive. */
|
|
196
|
+
export function hasKeyPrefix(key) {
|
|
197
|
+
return KEY_PREFIXES.some((p) => key.startsWith(p));
|
|
198
|
+
}
|
|
195
199
|
export function validateKeyFormat(key) {
|
|
196
|
-
return isHeaderSafe(key) &&
|
|
200
|
+
return isHeaderSafe(key) && hasKeyPrefix(key) && key.length > MIN_KEY_LENGTH;
|
|
197
201
|
}
|
|
198
202
|
export function maskKey(key) {
|
|
199
203
|
if (key.length <= 8)
|