@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/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
|
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { errLine, sanitizeLine } from "./output.js";
|
|
3
|
-
import {
|
|
3
|
+
import { spawnStepError, } from "./connect-runtime.js";
|
|
4
4
|
import { codexOauthCompleted } from "./registration-detect.js";
|
|
5
5
|
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
6
6
|
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
@@ -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
|
|
@@ -103,7 +122,7 @@ function launchCodexWalkthrough() {
|
|
|
103
122
|
deepLink.searchParams.set("path", process.cwd());
|
|
104
123
|
const result = openExternalUrl(deepLink.toString());
|
|
105
124
|
if (result.error || result.status !== 0) {
|
|
106
|
-
throw
|
|
125
|
+
throw spawnStepError("Could not open Codex", result);
|
|
107
126
|
}
|
|
108
127
|
errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
|
|
109
128
|
}
|
|
@@ -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], {
|
|
@@ -138,10 +156,7 @@ function launchHermesWalkthrough() {
|
|
|
138
156
|
timeout: HERMES_SEED_TIMEOUT_MS,
|
|
139
157
|
});
|
|
140
158
|
if (seedResult.error || seedResult.status !== 0) {
|
|
141
|
-
|
|
142
|
-
throw new Error("Could not start the TinyFish walkthrough in Hermes", {
|
|
143
|
-
cause: seedResult.error,
|
|
144
|
-
});
|
|
159
|
+
throw spawnStepError("Could not start the TinyFish walkthrough in Hermes", seedResult);
|
|
145
160
|
}
|
|
146
161
|
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
147
162
|
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
@@ -150,12 +165,12 @@ function launchHermesWalkthrough() {
|
|
|
150
165
|
}
|
|
151
166
|
const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
|
|
152
167
|
if (result.error) {
|
|
153
|
-
throw
|
|
168
|
+
throw spawnStepError("Could not launch Hermes", result);
|
|
154
169
|
}
|
|
155
170
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
156
171
|
return;
|
|
157
172
|
if (result.status !== 0) {
|
|
158
|
-
throw
|
|
173
|
+
throw spawnStepError(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
export const HERMES = {
|
|
@@ -168,9 +183,10 @@ export const HERMES = {
|
|
|
168
183
|
patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
|
|
169
184
|
unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
|
|
170
185
|
},
|
|
186
|
+
// No `keyAuth`: `hermes mcp add` has no --header, and `--auth header` prompts interactively.
|
|
171
187
|
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
|
|
172
188
|
// Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
|
|
173
|
-
addArgs: (mcpUrl) => [
|
|
189
|
+
addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--auth", "oauth"],
|
|
174
190
|
launchWalkthrough: launchHermesWalkthrough,
|
|
175
191
|
};
|
|
176
192
|
// OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
|
|
@@ -181,12 +197,12 @@ function launchOpencode() {
|
|
|
181
197
|
stdio: "inherit",
|
|
182
198
|
});
|
|
183
199
|
if (result.error) {
|
|
184
|
-
throw
|
|
200
|
+
throw spawnStepError("Could not launch OpenCode", result);
|
|
185
201
|
}
|
|
186
202
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
187
203
|
return;
|
|
188
204
|
if (result.status !== 0) {
|
|
189
|
-
throw
|
|
205
|
+
throw spawnStepError(`OpenCode exited with status ${result.status ?? "unknown"}`, result);
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
208
|
export const OPENCODE = {
|
|
@@ -197,16 +213,25 @@ export const OPENCODE = {
|
|
|
197
213
|
supportCheck: {
|
|
198
214
|
args: ["mcp", "add", "--help"],
|
|
199
215
|
patterns: [/--url(?:\s|$)/m],
|
|
216
|
+
keyAuthPattern: HEADER_FLAG,
|
|
200
217
|
unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
201
218
|
},
|
|
219
|
+
keyAuth: headerKeyAuth,
|
|
202
220
|
// `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
|
|
203
221
|
removals: [],
|
|
204
222
|
// OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
|
|
205
|
-
addArgs: (mcpUrl) =>
|
|
223
|
+
addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
|
|
206
224
|
loginArgs: ["mcp", "auth", "tinyfish"],
|
|
207
225
|
launchWalkthrough: launchOpencode,
|
|
208
226
|
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
209
227
|
};
|
|
228
|
+
/** Every native MCP client. Derive from this, so a new harness cannot be missed. */
|
|
229
|
+
export const NATIVE_MCP_CLIENTS = [
|
|
230
|
+
CLAUDE_CODE,
|
|
231
|
+
CODEX,
|
|
232
|
+
HERMES,
|
|
233
|
+
OPENCODE,
|
|
234
|
+
];
|
|
210
235
|
export const OPENCLAW = {
|
|
211
236
|
command: "openclaw",
|
|
212
237
|
displayName: "OpenClaw",
|
|
@@ -224,12 +249,12 @@ export function launchNativeMcpClient(client) {
|
|
|
224
249
|
}
|
|
225
250
|
const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
|
|
226
251
|
if (result.error) {
|
|
227
|
-
throw
|
|
252
|
+
throw spawnStepError(`Could not launch ${client.displayName}`, result);
|
|
228
253
|
}
|
|
229
254
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
230
255
|
return false;
|
|
231
256
|
if (result.status !== 0) {
|
|
232
|
-
throw
|
|
257
|
+
throw spawnStepError(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
233
258
|
}
|
|
234
259
|
return true;
|
|
235
260
|
}
|
|
@@ -239,12 +264,12 @@ export function launchOpenClawWalkthrough() {
|
|
|
239
264
|
stdio: "inherit",
|
|
240
265
|
});
|
|
241
266
|
if (result.error) {
|
|
242
|
-
throw
|
|
267
|
+
throw spawnStepError("Could not launch OpenClaw", result);
|
|
243
268
|
}
|
|
244
269
|
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
245
270
|
return false;
|
|
246
271
|
if (result.status !== 0) {
|
|
247
|
-
throw
|
|
272
|
+
throw spawnStepError(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`, result);
|
|
248
273
|
}
|
|
249
274
|
return true;
|
|
250
275
|
}
|
|
@@ -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 {
|
|
5
|
-
import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
4
|
+
import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
|
|
5
|
+
import { commandNotFound, ConnectStepError, spawnStepError, 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,13 +16,15 @@ 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
|
});
|
|
22
26
|
if (result.error || result.status !== 0) {
|
|
23
|
-
|
|
24
|
-
throw new Error("Could not install the TinyFish CLI", { cause: result.error });
|
|
27
|
+
throw spawnStepError("Could not install the TinyFish CLI", result);
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
/** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
|
|
@@ -68,10 +71,7 @@ export function installWebSkill(client) {
|
|
|
68
71
|
if (output.trim())
|
|
69
72
|
errLine(output.trimEnd());
|
|
70
73
|
if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
|
|
71
|
-
|
|
72
|
-
throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
|
|
73
|
-
cause: result.error,
|
|
74
|
-
});
|
|
74
|
+
throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
@@ -85,7 +85,7 @@ export function installWebSkill(client) {
|
|
|
85
85
|
export function updateWebSkill() {
|
|
86
86
|
// One entry per `tinyfish connect <client>`, persisted by PF-3169.
|
|
87
87
|
const connected = loadConfig().connect ?? {};
|
|
88
|
-
const skillAgents =
|
|
88
|
+
const skillAgents = NATIVE_MCP_CLIENTS
|
|
89
89
|
.filter((client) => connected[client.connectClient])
|
|
90
90
|
.map((client) => client.skillAgent);
|
|
91
91
|
const hasOpenClaw = Boolean(connected["openclaw"]);
|
|
@@ -160,7 +160,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
160
160
|
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
161
161
|
if (envKey) {
|
|
162
162
|
if (!validateKeyFormat(envKey)) {
|
|
163
|
-
throw new
|
|
163
|
+
throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
|
|
164
164
|
}
|
|
165
165
|
// Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
|
|
166
166
|
writeConfig(envKey);
|
|
@@ -172,7 +172,7 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
172
172
|
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
173
173
|
});
|
|
174
174
|
if (commandNotFound(authStatus.error)) {
|
|
175
|
-
throw
|
|
175
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
|
|
176
176
|
}
|
|
177
177
|
if (authStatus.status === 0)
|
|
178
178
|
return;
|
|
@@ -180,17 +180,16 @@ export function ensureCliAuthenticated(source, apiKey) {
|
|
|
180
180
|
// "not signed in" and drop the user into an interactive login they never asked for.
|
|
181
181
|
throwIfInterrupted(authStatus);
|
|
182
182
|
if (authStatus.error) {
|
|
183
|
-
throw
|
|
183
|
+
throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
|
|
184
184
|
}
|
|
185
185
|
errLine("Signing in to the TinyFish CLI...");
|
|
186
186
|
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
|
|
187
187
|
stdio: "inherit",
|
|
188
188
|
});
|
|
189
189
|
if (commandNotFound(loginResult.error)) {
|
|
190
|
-
throw
|
|
190
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
|
|
191
191
|
}
|
|
192
192
|
if (loginResult.error || loginResult.status !== 0) {
|
|
193
|
-
|
|
194
|
-
throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
|
|
193
|
+
throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
|
|
195
194
|
}
|
|
196
195
|
}
|
|
@@ -1,24 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AuthMode } from "./harness-detect.js";
|
|
2
2
|
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
3
3
|
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
|
|
4
4
|
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
5
5
|
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
|
|
7
|
+
type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
|
+
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
|
|
8
9
|
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
9
10
|
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
10
11
|
export declare class ConnectInterruptedError extends Error {
|
|
11
12
|
}
|
|
13
|
+
/** A classified step failure; runGuarded forwards the reason. */
|
|
14
|
+
export declare class ConnectStepError extends Error {
|
|
15
|
+
readonly failureReason: ConnectFailureReason;
|
|
16
|
+
readonly harnessVersion?: string;
|
|
17
|
+
constructor(message: string, failureReason: ConnectFailureReason, opts?: {
|
|
18
|
+
harnessVersion?: string;
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
12
22
|
export declare function throwIfInterrupted(result: {
|
|
13
23
|
signal?: string | null;
|
|
14
24
|
error?: Error;
|
|
15
25
|
}): void;
|
|
26
|
+
interface SpawnStepResult {
|
|
27
|
+
status?: number | null;
|
|
28
|
+
signal?: string | null;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
/** Classifies a spawn failure; interrupts throw instead. */
|
|
32
|
+
export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
|
|
16
33
|
export declare function commandNotFound(error: unknown): boolean;
|
|
17
34
|
export interface ConnectRunState {
|
|
18
35
|
stage: ConnectFailureStage;
|
|
19
36
|
settled: boolean;
|
|
20
37
|
/** On state, not per-call: otherwise degraded installs never appear to fail. */
|
|
21
38
|
harnessDegraded?: boolean;
|
|
39
|
+
/** Resolved after the probe, so callers read real key use rather than key presence. */
|
|
40
|
+
authMode?: ConnectAuthMode;
|
|
22
41
|
}
|
|
23
42
|
interface ConnectStageDetail {
|
|
24
43
|
failedStage?: ConnectFailureStage;
|
|
@@ -32,6 +51,7 @@ interface ConnectStageDetail {
|
|
|
32
51
|
export interface ConnectTelemetry {
|
|
33
52
|
attemptId: string;
|
|
34
53
|
track(stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
54
|
+
setHarnessVersion(version?: string): void;
|
|
35
55
|
flush(): Promise<void>;
|
|
36
56
|
}
|
|
37
57
|
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
@@ -47,11 +67,15 @@ export interface SupportedCommand {
|
|
|
47
67
|
patterns: RegExp[];
|
|
48
68
|
/** Missing → degrade to key or deferred auth, never refuse. */
|
|
49
69
|
optionalPatterns?: RegExp[];
|
|
70
|
+
/** Missing → this install cannot take a stored key; fall back to OAuth. */
|
|
71
|
+
keyAuthPattern?: RegExp;
|
|
50
72
|
unavailableMessage: string;
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
75
|
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
54
76
|
optionalSupported: boolean;
|
|
77
|
+
keyAuthSupported: boolean;
|
|
78
|
+
harnessVersion?: string;
|
|
55
79
|
};
|
|
56
80
|
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
57
81
|
apiKey?: string;
|