@tiny-fish/cli 0.19.1-next.175 → 0.19.1-next.178
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/connect.d.ts +1 -6
- package/dist/commands/connect.js +5 -411
- package/dist/commands/upgrade.js +2 -1
- package/dist/lib/connect-clients.d.ts +33 -0
- package/dist/lib/connect-clients.js +238 -0
- package/dist/lib/connect-runtime.d.ts +53 -0
- package/dist/lib/connect-runtime.js +186 -0
- package/package.json +1 -1
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type AgentClient } from "../lib/connect-runtime.js";
|
|
2
3
|
export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
3
4
|
export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
4
|
-
export declare const DEFAULT_ONBOARDING_PROMPT: string;
|
|
5
|
-
export declare const OPENCLAW_ONBOARDING_PROMPT: string;
|
|
6
|
-
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
|
|
7
|
-
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
8
|
-
export declare class ConnectInterruptedError extends Error {
|
|
9
|
-
}
|
|
10
5
|
interface NativeConnectOptions {
|
|
11
6
|
apiKey?: string;
|
|
12
7
|
mcpUrl: string;
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,55 +1,18 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import * as path from "node:path";
|
|
3
2
|
import spawn from "cross-spawn";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, writeConfig, } from "../lib/auth.js";
|
|
4
|
+
import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, } from "../lib/connect-clients.js";
|
|
5
|
+
import { commandNotFound, ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
6
|
+
import { TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
|
|
6
7
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
8
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
9
|
import { detectHumanInitiated } from "../lib/harness.js";
|
|
9
10
|
import { emitNotice } from "../lib/notice.js";
|
|
10
|
-
import { errLine
|
|
11
|
-
import { codexOauthCompleted } from "../lib/registration-detect.js";
|
|
11
|
+
import { errLine } from "../lib/output.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { installSignalGuard } from "../lib/signals.js";
|
|
14
|
-
import { postConnectEvent, telemetryDisabled } from "../lib/setup-telemetry.js";
|
|
15
13
|
import { verifyMcpAuth } from "../lib/verify.js";
|
|
16
14
|
export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
17
|
-
const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
18
15
|
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
19
|
-
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
20
|
-
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
21
|
-
const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
|
|
22
|
-
const MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this Claude Code installation exposes `claude mcp add`: `claude mcp " +
|
|
23
|
-
"--help` did not list it. Update Claude Code with `claude update` and retry." +
|
|
24
|
-
SUPPORT_CHECK_DEBUG_HINT;
|
|
25
|
-
// `claude mcp login` only exists upstream from Claude Code 2.1.186; older installs still register.
|
|
26
|
-
const CLAUDE_CODE_KEYED_FALLBACK_NOTE = "Using your stored TinyFish API key: this Claude Code predates one-command sign-in " +
|
|
27
|
-
"(`claude mcp login`), so no browser sign-in is needed.";
|
|
28
|
-
const CLAUDE_CODE_DEFERRED_AUTH_NOTE = "TinyFish is registered in Claude Code but not signed in: this Claude Code predates " +
|
|
29
|
-
"one-command sign-in (`claude mcp login`). Open Claude Code and run `/mcp` to sign in to " +
|
|
30
|
-
"TinyFish. `claude update` gets you one-command sign-in.";
|
|
31
|
-
const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "Could not confirm this Codex installation supports one-command MCP authentication: `codex " +
|
|
32
|
-
"mcp add --help` did not list `--oauth-resource`. Update Codex and retry, or add TinyFish " +
|
|
33
|
-
"manually with `codex mcp add`." +
|
|
34
|
-
SUPPORT_CHECK_DEBUG_HINT;
|
|
35
|
-
const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "Could not confirm this Hermes installation supports one-command MCP authentication: `hermes " +
|
|
36
|
-
"mcp add --help` did not list `--auth oauth`. Update Hermes and retry, or add TinyFish " +
|
|
37
|
-
"manually with `hermes mcp add`." +
|
|
38
|
-
SUPPORT_CHECK_DEBUG_HINT;
|
|
39
|
-
const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "Could not confirm this OpenClaw installation supports global skill installation: `openclaw " +
|
|
40
|
-
"skills install --help` did not list `--global` and `--acknowledge-clawhub-risk`. Update " +
|
|
41
|
-
"OpenClaw and retry, or install manually with `openclaw skills install @tinyfish/tinyfish " +
|
|
42
|
-
"--global --acknowledge-clawhub-risk`." +
|
|
43
|
-
SUPPORT_CHECK_DEBUG_HINT;
|
|
44
|
-
const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp " +
|
|
45
|
-
"add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with " +
|
|
46
|
-
"`opencode mcp add`." +
|
|
47
|
-
SUPPORT_CHECK_DEBUG_HINT;
|
|
48
|
-
// OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
|
|
49
|
-
// model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
|
|
50
|
-
const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
|
|
51
|
-
'models (e.g. Nano Banana Pro) will show "No endpoints found that support tool use" — switch ' +
|
|
52
|
-
"OpenCode's model if the walkthrough can't start.";
|
|
53
16
|
const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
54
17
|
const TINYFISH_CLI_INSTALL_SPEC = `${TINYFISH_CLI_PACKAGE}@latest`;
|
|
55
18
|
export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
@@ -57,343 +20,6 @@ export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI a
|
|
|
57
20
|
const SKILLS_CLI_PACKAGE = "skills@1.5.15";
|
|
58
21
|
const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
|
|
59
22
|
const TINYFISH_WEB_SKILL = "use-tinyfish";
|
|
60
|
-
const OPENCLAW_SKILL = "@tinyfish/tinyfish";
|
|
61
|
-
// --force makes this an unconditional overwrite, so the same call installs and refreshes.
|
|
62
|
-
const OPENCLAW_SKILL_INSTALL_ARGS = [
|
|
63
|
-
"skills",
|
|
64
|
-
"install",
|
|
65
|
-
OPENCLAW_SKILL,
|
|
66
|
-
"--global",
|
|
67
|
-
"--force",
|
|
68
|
-
"--acknowledge-clawhub-risk",
|
|
69
|
-
];
|
|
70
|
-
export const DEFAULT_ONBOARDING_PROMPT = "I just connected TinyFish. Call the guide_next_step tool now to begin. Then follow its " +
|
|
71
|
-
"instructions one step at a time. Ask for my input and wait for my reply before each subsequent " +
|
|
72
|
-
"TinyFish tool call.";
|
|
73
|
-
export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill. Guide me through it interactively: first ask what topic I " +
|
|
74
|
-
"want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
|
|
75
|
-
"which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
|
|
76
|
-
"I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
|
|
77
|
-
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
78
|
-
export class ConnectInterruptedError extends Error {
|
|
79
|
-
}
|
|
80
|
-
class PrerequisiteError extends Error {
|
|
81
|
-
failureReason;
|
|
82
|
-
harnessVersion;
|
|
83
|
-
constructor(message, failureReason, opts) {
|
|
84
|
-
super(message, { cause: opts?.cause });
|
|
85
|
-
this.failureReason = failureReason;
|
|
86
|
-
this.harnessVersion = opts?.harnessVersion;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
function throwIfInterrupted(result) {
|
|
90
|
-
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
91
|
-
// network as the user walking away. A timeout is a failure and must report as one.
|
|
92
|
-
if (result.error?.code === "ETIMEDOUT")
|
|
93
|
-
return;
|
|
94
|
-
if (result.signal === "SIGINT" || result.signal === "SIGTERM") {
|
|
95
|
-
throw new ConnectInterruptedError("Setup interrupted");
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
99
|
-
function settle(state, telemetry, stage, detail) {
|
|
100
|
-
if (state.settled)
|
|
101
|
-
return;
|
|
102
|
-
state.settled = true;
|
|
103
|
-
telemetry.track(stage, detail);
|
|
104
|
-
}
|
|
105
|
-
// Raw-mode children still surface via throwIfInterrupted; cooked-mode stages
|
|
106
|
-
// (npm/npx/OAuth waits) land here.
|
|
107
|
-
function installConnectSignalGuard(state, telemetry) {
|
|
108
|
-
return installSignalGuard(async () => {
|
|
109
|
-
if (!state.settled) {
|
|
110
|
-
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
111
|
-
errLine("Setup interrupted — run the command again to finish.");
|
|
112
|
-
}
|
|
113
|
-
// Settled spans the whole post-install phase; the `completed` POST may still be in flight.
|
|
114
|
-
await telemetry.flush();
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
/** Shared terminal handling: interrupted → aborted, else failed. */
|
|
118
|
-
async function runGuarded(state, telemetry, body) {
|
|
119
|
-
const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
|
|
120
|
-
try {
|
|
121
|
-
await body();
|
|
122
|
-
}
|
|
123
|
-
catch (error) {
|
|
124
|
-
if (error instanceof ConnectInterruptedError) {
|
|
125
|
-
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
126
|
-
errLine("Setup interrupted — run the command again to finish.");
|
|
127
|
-
process.exitCode = 130;
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
settle(state, telemetry, "failed", {
|
|
131
|
-
failedStage: state.stage,
|
|
132
|
-
...(error instanceof PrerequisiteError
|
|
133
|
-
? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
|
|
134
|
-
: {}),
|
|
135
|
-
});
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
uninstallSignalGuard();
|
|
140
|
-
await telemetry.flush();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const CLAUDE_CODE = {
|
|
144
|
-
command: "claude",
|
|
145
|
-
connectClient: "claude-code",
|
|
146
|
-
skillAgent: "claude-code",
|
|
147
|
-
displayName: "Claude Code",
|
|
148
|
-
supportCheck: {
|
|
149
|
-
args: ["mcp", "--help"],
|
|
150
|
-
patterns: [/^\s*add(?:\s|\[)/m],
|
|
151
|
-
optionalPatterns: [/^\s*login(?:\s|\[)/m],
|
|
152
|
-
unavailableMessage: MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
153
|
-
},
|
|
154
|
-
headerAuthSupported: true,
|
|
155
|
-
loginArgs: ["mcp", "login", "tinyfish"],
|
|
156
|
-
degradedAuthNotes: {
|
|
157
|
-
keyed: CLAUDE_CODE_KEYED_FALLBACK_NOTE,
|
|
158
|
-
deferred: CLAUDE_CODE_DEFERRED_AUTH_NOTE,
|
|
159
|
-
},
|
|
160
|
-
// Project scope is shared in .mcp.json; a user setup command must not rewrite it.
|
|
161
|
-
removals: [
|
|
162
|
-
{ args: ["mcp", "remove", "tinyfish", "--scope", "user"], label: "user TinyFish registration" },
|
|
163
|
-
{
|
|
164
|
-
args: ["mcp", "remove", "tinyfish", "--scope", "local"],
|
|
165
|
-
label: "local TinyFish registration",
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
addArgs: (mcpUrl) => ["mcp", "add", "--scope", "user", "--transport", "http", "tinyfish", mcpUrl],
|
|
169
|
-
};
|
|
170
|
-
function launchCodexWalkthrough() {
|
|
171
|
-
const deepLink = new URL("codex://new");
|
|
172
|
-
deepLink.searchParams.set("prompt", DEFAULT_ONBOARDING_PROMPT);
|
|
173
|
-
deepLink.searchParams.set("path", process.cwd());
|
|
174
|
-
const [command, args] = process.platform === "darwin"
|
|
175
|
-
? ["open", [deepLink.toString()]]
|
|
176
|
-
: process.platform === "win32"
|
|
177
|
-
? ["cmd", ["/c", "start", "", deepLink.toString()]]
|
|
178
|
-
: ["xdg-open", [deepLink.toString()]];
|
|
179
|
-
const result = spawn.sync(command, args, { stdio: "ignore" });
|
|
180
|
-
if (result.error || result.status !== 0) {
|
|
181
|
-
throw new Error("Could not open Codex", { cause: result.error });
|
|
182
|
-
}
|
|
183
|
-
errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
|
|
184
|
-
}
|
|
185
|
-
const CODEX = {
|
|
186
|
-
command: "codex",
|
|
187
|
-
connectClient: "codex",
|
|
188
|
-
skillAgent: "codex",
|
|
189
|
-
displayName: "Codex",
|
|
190
|
-
supportCheck: {
|
|
191
|
-
args: ["mcp", "add", "--help"],
|
|
192
|
-
patterns: [/--oauth-resource(?:\s|<)/],
|
|
193
|
-
unavailableMessage: CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE,
|
|
194
|
-
},
|
|
195
|
-
launchWalkthrough: launchCodexWalkthrough,
|
|
196
|
-
probeAuthenticated: codexOauthCompleted,
|
|
197
|
-
// Retry only: running it after a finished inline OAuth is a second browser hop.
|
|
198
|
-
loginArgs: ["mcp", "login", "tinyfish"],
|
|
199
|
-
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
|
|
200
|
-
addArgs: (mcpUrl, oauthResource) => [
|
|
201
|
-
"mcp",
|
|
202
|
-
"add",
|
|
203
|
-
"tinyfish",
|
|
204
|
-
"--url",
|
|
205
|
-
mcpUrl,
|
|
206
|
-
"--oauth-resource",
|
|
207
|
-
oauthResource,
|
|
208
|
-
],
|
|
209
|
-
};
|
|
210
|
-
function launchHermesWalkthrough() {
|
|
211
|
-
const seedResult = spawn.sync("hermes", ["chat", "-Q", "-q", DEFAULT_ONBOARDING_PROMPT], {
|
|
212
|
-
encoding: "utf8",
|
|
213
|
-
timeout: HERMES_SEED_TIMEOUT_MS,
|
|
214
|
-
});
|
|
215
|
-
if (seedResult.error || seedResult.status !== 0) {
|
|
216
|
-
throwIfInterrupted(seedResult);
|
|
217
|
-
throw new Error("Could not start the TinyFish walkthrough in Hermes", {
|
|
218
|
-
cause: seedResult.error,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
222
|
-
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
223
|
-
if (!sessionId) {
|
|
224
|
-
throw new Error("Hermes did not return a walkthrough session ID");
|
|
225
|
-
}
|
|
226
|
-
const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
|
|
227
|
-
if (result.error) {
|
|
228
|
-
throw new Error("Could not launch Hermes", { cause: result.error });
|
|
229
|
-
}
|
|
230
|
-
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
231
|
-
return;
|
|
232
|
-
if (result.status !== 0) {
|
|
233
|
-
throw new Error(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
const HERMES = {
|
|
237
|
-
command: "hermes",
|
|
238
|
-
connectClient: "hermes",
|
|
239
|
-
skillAgent: "hermes-agent",
|
|
240
|
-
displayName: "Hermes",
|
|
241
|
-
supportCheck: {
|
|
242
|
-
args: ["mcp", "add", "--help"],
|
|
243
|
-
patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
|
|
244
|
-
unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
|
|
245
|
-
},
|
|
246
|
-
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
|
|
247
|
-
// Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
|
|
248
|
-
addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl, "--auth", "oauth"],
|
|
249
|
-
launchWalkthrough: launchHermesWalkthrough,
|
|
250
|
-
};
|
|
251
|
-
// OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
|
|
252
|
-
// would be read as a folder. The `--prompt` flag seeds the interactive TUI with a first message
|
|
253
|
-
// (opencode.ai/docs/cli), so the onboarding guide fires just like the other agents.
|
|
254
|
-
function launchOpencode() {
|
|
255
|
-
const result = spawn.sync("opencode", ["--prompt", DEFAULT_ONBOARDING_PROMPT], {
|
|
256
|
-
stdio: "inherit",
|
|
257
|
-
});
|
|
258
|
-
if (result.error) {
|
|
259
|
-
throw new Error("Could not launch OpenCode", { cause: result.error });
|
|
260
|
-
}
|
|
261
|
-
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
262
|
-
return;
|
|
263
|
-
if (result.status !== 0) {
|
|
264
|
-
throw new Error(`OpenCode exited with status ${result.status ?? "unknown"}`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
const OPENCODE = {
|
|
268
|
-
command: "opencode",
|
|
269
|
-
connectClient: "opencode",
|
|
270
|
-
skillAgent: "opencode",
|
|
271
|
-
displayName: "OpenCode",
|
|
272
|
-
supportCheck: {
|
|
273
|
-
args: ["mcp", "add", "--help"],
|
|
274
|
-
patterns: [/--url(?:\s|$)/m],
|
|
275
|
-
unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
276
|
-
},
|
|
277
|
-
// `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
|
|
278
|
-
removals: [],
|
|
279
|
-
// OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
|
|
280
|
-
addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl],
|
|
281
|
-
loginArgs: ["mcp", "auth", "tinyfish"],
|
|
282
|
-
launchWalkthrough: launchOpencode,
|
|
283
|
-
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
284
|
-
};
|
|
285
|
-
const OPENCLAW = {
|
|
286
|
-
command: "openclaw",
|
|
287
|
-
displayName: "OpenClaw",
|
|
288
|
-
supportCheck: {
|
|
289
|
-
args: ["skills", "install", "--help"],
|
|
290
|
-
patterns: [/--global(?:\s|$)/, /--acknowledge-clawhub-risk(?:\s|$)/],
|
|
291
|
-
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
292
|
-
},
|
|
293
|
-
};
|
|
294
|
-
/**
|
|
295
|
-
* One page-minted id seeds one install attempt, so `tinyfish onboard` connecting three agents
|
|
296
|
-
* does not report three installs under it. Later connects in the run fall back to a random id.
|
|
297
|
-
*/
|
|
298
|
-
function takeSeedAttemptId() {
|
|
299
|
-
const fromEnv = process.env[CONNECT_ATTEMPT_ENV];
|
|
300
|
-
// Both sources are read-and-clear, and the delete lands before any subprocess inherits it.
|
|
301
|
-
delete process.env[CONNECT_ATTEMPT_ENV];
|
|
302
|
-
const parked = takePendingConnectAttempt();
|
|
303
|
-
return fromEnv && isAttemptId(fromEnv) ? fromEnv : parked;
|
|
304
|
-
}
|
|
305
|
-
function createConnectTelemetry(mcpUrl, client, opts) {
|
|
306
|
-
// Consumed even when --url already supplied an id: leaving it behind would let a stale
|
|
307
|
-
// seed reach the agent this connect launches, or the next connect on this machine.
|
|
308
|
-
const seeded = takeSeedAttemptId();
|
|
309
|
-
// A setup-page id (via --url, the env var, or one parked by login) joins "copied the
|
|
310
|
-
// command" to this install attempt.
|
|
311
|
-
const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
|
|
312
|
-
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
313
|
-
const pending = [];
|
|
314
|
-
async function deliver(body) {
|
|
315
|
-
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
316
|
-
if (telemetryDisabled())
|
|
317
|
-
return;
|
|
318
|
-
// Resolved per event: `tinyfish auth login` writes the config from a subprocess mid-connect.
|
|
319
|
-
await postConnectEvent({
|
|
320
|
-
endpoint,
|
|
321
|
-
body,
|
|
322
|
-
attempts: 2,
|
|
323
|
-
apiKey: resolvedApiKey(opts?.apiKey),
|
|
324
|
-
label: "connect",
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
attemptId,
|
|
329
|
-
// Fire-and-forget; awaiting each event stalls setup when telemetry down.
|
|
330
|
-
track(stage, detail) {
|
|
331
|
-
pending.push(deliver(JSON.stringify({
|
|
332
|
-
attempt_id: attemptId,
|
|
333
|
-
client,
|
|
334
|
-
stage,
|
|
335
|
-
failed_stage: detail?.failedStage,
|
|
336
|
-
phase: detail?.phase,
|
|
337
|
-
failure_reason: detail?.failureReason,
|
|
338
|
-
harness_version: detail?.harnessVersion,
|
|
339
|
-
runtime_platform: process.platform,
|
|
340
|
-
node_version: process.version,
|
|
341
|
-
cli_version: CLI_VERSION,
|
|
342
|
-
// Same signal the usage events carry, so TTY and headless connects can be split.
|
|
343
|
-
is_human_initiated: detectHumanInitiated(),
|
|
344
|
-
})));
|
|
345
|
-
},
|
|
346
|
-
// Awaited in finally so in-flight events land before exit.
|
|
347
|
-
async flush() {
|
|
348
|
-
await Promise.all(pending);
|
|
349
|
-
},
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
function requireCommandSupport(client) {
|
|
353
|
-
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
354
|
-
encoding: "utf8",
|
|
355
|
-
// Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
|
|
356
|
-
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
357
|
-
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
358
|
-
});
|
|
359
|
-
if (commandNotFound(result.error)) {
|
|
360
|
-
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, "harness_not_installed", { cause: result.error });
|
|
361
|
-
}
|
|
362
|
-
if (result.error || result.status !== 0) {
|
|
363
|
-
throwIfInterrupted(result);
|
|
364
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
|
|
365
|
-
}
|
|
366
|
-
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
367
|
-
const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
368
|
-
const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
|
|
369
|
-
const essential = listed(client.supportCheck.patterns);
|
|
370
|
-
const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
|
|
371
|
-
if ((!essential || !optionalSupported) && process.env["TINYFISH_DEBUG"]) {
|
|
372
|
-
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
373
|
-
}
|
|
374
|
-
if (!essential) {
|
|
375
|
-
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
|
|
376
|
-
harnessVersion: probeHarnessVersion(client.command),
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
return { optionalSupported };
|
|
380
|
-
}
|
|
381
|
-
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
382
|
-
function probeHarnessVersion(command) {
|
|
383
|
-
const result = spawn.sync(command, ["--version"], {
|
|
384
|
-
encoding: "utf8",
|
|
385
|
-
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
386
|
-
});
|
|
387
|
-
if (result.error || result.status !== 0)
|
|
388
|
-
return undefined;
|
|
389
|
-
const version = sanitizeLine(result.stdout ?? "")
|
|
390
|
-
.replace(/[^\x20-\x7E]/g, "")
|
|
391
|
-
.trim();
|
|
392
|
-
return version ? version.slice(0, 32) : undefined;
|
|
393
|
-
}
|
|
394
|
-
function commandNotFound(error) {
|
|
395
|
-
return error?.code === "ENOENT";
|
|
396
|
-
}
|
|
397
23
|
export function installTinyFishCli() {
|
|
398
24
|
errLine("Installing the TinyFish CLI...");
|
|
399
25
|
const result = spawn.sync("npm", ["install", "--global", TINYFISH_CLI_INSTALL_SPEC], {
|
|
@@ -579,38 +205,6 @@ function removeExistingRegistration(client, removal) {
|
|
|
579
205
|
}
|
|
580
206
|
throw new Error(`Could not remove existing ${removal.label}: ${details}`);
|
|
581
207
|
}
|
|
582
|
-
function launchNativeMcpClient(client) {
|
|
583
|
-
errLine(`Starting the TinyFish walkthrough in ${client.displayName}...`);
|
|
584
|
-
if (client.launchWalkthrough) {
|
|
585
|
-
client.launchWalkthrough();
|
|
586
|
-
return true;
|
|
587
|
-
}
|
|
588
|
-
const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
|
|
589
|
-
if (result.error) {
|
|
590
|
-
throw new Error(`Could not launch ${client.displayName}`, { cause: result.error });
|
|
591
|
-
}
|
|
592
|
-
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
593
|
-
return false;
|
|
594
|
-
if (result.status !== 0) {
|
|
595
|
-
throw new Error(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
596
|
-
}
|
|
597
|
-
return true;
|
|
598
|
-
}
|
|
599
|
-
function launchOpenClawWalkthrough() {
|
|
600
|
-
errLine("Starting the TinyFish walkthrough in OpenClaw...");
|
|
601
|
-
const result = spawn.sync("openclaw", ["chat", "--message", OPENCLAW_ONBOARDING_PROMPT], {
|
|
602
|
-
stdio: "inherit",
|
|
603
|
-
});
|
|
604
|
-
if (result.error) {
|
|
605
|
-
throw new Error("Could not launch OpenClaw", { cause: result.error });
|
|
606
|
-
}
|
|
607
|
-
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
608
|
-
return false;
|
|
609
|
-
if (result.status !== 0) {
|
|
610
|
-
throw new Error(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
611
|
-
}
|
|
612
|
-
return true;
|
|
613
|
-
}
|
|
614
208
|
async function connectNativeMcpClient(client, options) {
|
|
615
209
|
const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
|
|
616
210
|
const state = { stage: "prerequisite_check", settled: false };
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -5,7 +5,8 @@ import { suppressNotice } from "../lib/notice.js";
|
|
|
5
5
|
import { errLine } from "../lib/output.js";
|
|
6
6
|
import { sendUpgradeCompleted, telemetryDisabled, } from "../lib/setup-telemetry.js";
|
|
7
7
|
import { installSignalGuard, SIGNAL_EXIT_CODES } from "../lib/signals.js";
|
|
8
|
-
import { ConnectInterruptedError
|
|
8
|
+
import { ConnectInterruptedError } from "../lib/connect-runtime.js";
|
|
9
|
+
import { installTinyFishCli, updateWebSkill } from "./connect.js";
|
|
9
10
|
const VERSION_READ_TIMEOUT_MS = 2_000;
|
|
10
11
|
const INTERRUPTED_MESSAGE = "Upgrade interrupted — run `tinyfish upgrade` again to finish.";
|
|
11
12
|
function attempt(label, run) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AgentClient, type SupportedCommand } from "./connect-runtime.js";
|
|
2
|
+
export declare const OPENCLAW_SKILL_INSTALL_ARGS: readonly string[];
|
|
3
|
+
export declare const DEFAULT_ONBOARDING_PROMPT: string;
|
|
4
|
+
export declare const OPENCLAW_ONBOARDING_PROMPT: string;
|
|
5
|
+
export interface NativeMcpClient extends SupportedCommand {
|
|
6
|
+
connectClient: Exclude<AgentClient, "openclaw">;
|
|
7
|
+
skillAgent: "claude-code" | "codex" | "hermes-agent" | "opencode";
|
|
8
|
+
/** `mcp add --header` support: a stored TinyFish key replaces the OAuth login. */
|
|
9
|
+
headerAuthSupported?: boolean;
|
|
10
|
+
loginArgs?: string[];
|
|
11
|
+
/** Replaces the `loginArgs` step when `supportCheck.optionalPatterns` miss. */
|
|
12
|
+
degradedAuthNotes?: {
|
|
13
|
+
keyed: string;
|
|
14
|
+
deferred: string;
|
|
15
|
+
};
|
|
16
|
+
/** Did the OAuth inside `mcp add` finish? undefined when the client cannot say. */
|
|
17
|
+
probeAuthenticated?: () => boolean | undefined;
|
|
18
|
+
removals: {
|
|
19
|
+
args: string[];
|
|
20
|
+
label: string;
|
|
21
|
+
}[];
|
|
22
|
+
addArgs: (mcpUrl: string, oauthResource: string) => string[];
|
|
23
|
+
launchWalkthrough?: () => void;
|
|
24
|
+
/** Printed after a successful connect, on both the launch and non-launch paths. */
|
|
25
|
+
postConnectNote?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const CLAUDE_CODE: NativeMcpClient;
|
|
28
|
+
export declare const CODEX: NativeMcpClient;
|
|
29
|
+
export declare const HERMES: NativeMcpClient;
|
|
30
|
+
export declare const OPENCODE: NativeMcpClient;
|
|
31
|
+
export declare const OPENCLAW: SupportedCommand;
|
|
32
|
+
export declare function launchNativeMcpClient(client: NativeMcpClient): boolean;
|
|
33
|
+
export declare function launchOpenClawWalkthrough(): boolean;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
import { errLine, sanitizeLine } from "./output.js";
|
|
3
|
+
import { throwIfInterrupted } from "./connect-runtime.js";
|
|
4
|
+
import { codexOauthCompleted } from "./registration-detect.js";
|
|
5
|
+
const HERMES_SEED_TIMEOUT_MS = 120_000;
|
|
6
|
+
// An old install is only one reason a flag can go unseen, so no message asserts the cause.
|
|
7
|
+
const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
|
|
8
|
+
const MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this Claude Code installation exposes `claude mcp add`: `claude mcp " +
|
|
9
|
+
"--help` did not list it. Update Claude Code with `claude update` and retry." +
|
|
10
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
11
|
+
// `claude mcp login` only exists upstream from Claude Code 2.1.186; older installs still register.
|
|
12
|
+
const CLAUDE_CODE_KEYED_FALLBACK_NOTE = "Using your stored TinyFish API key: this Claude Code predates one-command sign-in " +
|
|
13
|
+
"(`claude mcp login`), so no browser sign-in is needed.";
|
|
14
|
+
const CLAUDE_CODE_DEFERRED_AUTH_NOTE = "TinyFish is registered in Claude Code but not signed in: this Claude Code predates " +
|
|
15
|
+
"one-command sign-in (`claude mcp login`). Open Claude Code and run `/mcp` to sign in to " +
|
|
16
|
+
"TinyFish. `claude update` gets you one-command sign-in.";
|
|
17
|
+
const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "Could not confirm this Codex installation supports one-command MCP authentication: `codex " +
|
|
18
|
+
"mcp add --help` did not list `--oauth-resource`. Update Codex and retry, or add TinyFish " +
|
|
19
|
+
"manually with `codex mcp add`." +
|
|
20
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
21
|
+
const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "Could not confirm this Hermes installation supports one-command MCP authentication: `hermes " +
|
|
22
|
+
"mcp add --help` did not list `--auth oauth`. Update Hermes and retry, or add TinyFish " +
|
|
23
|
+
"manually with `hermes mcp add`." +
|
|
24
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
25
|
+
const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "Could not confirm this OpenClaw installation supports global skill installation: `openclaw " +
|
|
26
|
+
"skills install --help` did not list `--global` and `--acknowledge-clawhub-risk`. Update " +
|
|
27
|
+
"OpenClaw and retry, or install manually with `openclaw skills install @tinyfish/tinyfish " +
|
|
28
|
+
"--global --acknowledge-clawhub-risk`." +
|
|
29
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
30
|
+
const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp " +
|
|
31
|
+
"add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with " +
|
|
32
|
+
"`opencode mcp add`." +
|
|
33
|
+
SUPPORT_CHECK_DEBUG_HINT;
|
|
34
|
+
// OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
|
|
35
|
+
// model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
|
|
36
|
+
const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
|
|
37
|
+
'models (e.g. Nano Banana Pro) will show "No endpoints found that support tool use" — switch ' +
|
|
38
|
+
"OpenCode's model if the walkthrough can't start.";
|
|
39
|
+
const OPENCLAW_SKILL = "@tinyfish/tinyfish";
|
|
40
|
+
// --force makes this an unconditional overwrite, so the same call installs and refreshes.
|
|
41
|
+
export const OPENCLAW_SKILL_INSTALL_ARGS = [
|
|
42
|
+
"skills",
|
|
43
|
+
"install",
|
|
44
|
+
OPENCLAW_SKILL,
|
|
45
|
+
"--global",
|
|
46
|
+
"--force",
|
|
47
|
+
"--acknowledge-clawhub-risk",
|
|
48
|
+
];
|
|
49
|
+
export const DEFAULT_ONBOARDING_PROMPT = "I just connected TinyFish. Call the guide_next_step tool now to begin. Then follow its " +
|
|
50
|
+
"instructions one step at a time. Ask for my input and wait for my reply before each subsequent " +
|
|
51
|
+
"TinyFish tool call.";
|
|
52
|
+
export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill. Guide me through it interactively: first ask what topic I " +
|
|
53
|
+
"want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
|
|
54
|
+
"which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
|
|
55
|
+
"I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
|
|
56
|
+
export const CLAUDE_CODE = {
|
|
57
|
+
command: "claude",
|
|
58
|
+
connectClient: "claude-code",
|
|
59
|
+
skillAgent: "claude-code",
|
|
60
|
+
displayName: "Claude Code",
|
|
61
|
+
supportCheck: {
|
|
62
|
+
args: ["mcp", "--help"],
|
|
63
|
+
patterns: [/^\s*add(?:\s|\[)/m],
|
|
64
|
+
optionalPatterns: [/^\s*login(?:\s|\[)/m],
|
|
65
|
+
unavailableMessage: MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
66
|
+
},
|
|
67
|
+
headerAuthSupported: true,
|
|
68
|
+
loginArgs: ["mcp", "login", "tinyfish"],
|
|
69
|
+
degradedAuthNotes: {
|
|
70
|
+
keyed: CLAUDE_CODE_KEYED_FALLBACK_NOTE,
|
|
71
|
+
deferred: CLAUDE_CODE_DEFERRED_AUTH_NOTE,
|
|
72
|
+
},
|
|
73
|
+
// Project scope is shared in .mcp.json; a user setup command must not rewrite it.
|
|
74
|
+
removals: [
|
|
75
|
+
{ args: ["mcp", "remove", "tinyfish", "--scope", "user"], label: "user TinyFish registration" },
|
|
76
|
+
{
|
|
77
|
+
args: ["mcp", "remove", "tinyfish", "--scope", "local"],
|
|
78
|
+
label: "local TinyFish registration",
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
addArgs: (mcpUrl) => ["mcp", "add", "--scope", "user", "--transport", "http", "tinyfish", mcpUrl],
|
|
82
|
+
};
|
|
83
|
+
function launchCodexWalkthrough() {
|
|
84
|
+
const deepLink = new URL("codex://new");
|
|
85
|
+
deepLink.searchParams.set("prompt", DEFAULT_ONBOARDING_PROMPT);
|
|
86
|
+
deepLink.searchParams.set("path", process.cwd());
|
|
87
|
+
const [command, args] = process.platform === "darwin"
|
|
88
|
+
? ["open", [deepLink.toString()]]
|
|
89
|
+
: process.platform === "win32"
|
|
90
|
+
? ["cmd", ["/c", "start", "", deepLink.toString()]]
|
|
91
|
+
: ["xdg-open", [deepLink.toString()]];
|
|
92
|
+
const result = spawn.sync(command, args, { stdio: "ignore" });
|
|
93
|
+
if (result.error || result.status !== 0) {
|
|
94
|
+
throw new Error("Could not open Codex", { cause: result.error });
|
|
95
|
+
}
|
|
96
|
+
errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
|
|
97
|
+
}
|
|
98
|
+
export const CODEX = {
|
|
99
|
+
command: "codex",
|
|
100
|
+
connectClient: "codex",
|
|
101
|
+
skillAgent: "codex",
|
|
102
|
+
displayName: "Codex",
|
|
103
|
+
supportCheck: {
|
|
104
|
+
args: ["mcp", "add", "--help"],
|
|
105
|
+
patterns: [/--oauth-resource(?:\s|<)/],
|
|
106
|
+
unavailableMessage: CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE,
|
|
107
|
+
},
|
|
108
|
+
launchWalkthrough: launchCodexWalkthrough,
|
|
109
|
+
probeAuthenticated: codexOauthCompleted,
|
|
110
|
+
// Retry only: running it after a finished inline OAuth is a second browser hop.
|
|
111
|
+
loginArgs: ["mcp", "login", "tinyfish"],
|
|
112
|
+
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
|
|
113
|
+
addArgs: (mcpUrl, oauthResource) => [
|
|
114
|
+
"mcp",
|
|
115
|
+
"add",
|
|
116
|
+
"tinyfish",
|
|
117
|
+
"--url",
|
|
118
|
+
mcpUrl,
|
|
119
|
+
"--oauth-resource",
|
|
120
|
+
oauthResource,
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
function launchHermesWalkthrough() {
|
|
124
|
+
const seedResult = spawn.sync("hermes", ["chat", "-Q", "-q", DEFAULT_ONBOARDING_PROMPT], {
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
timeout: HERMES_SEED_TIMEOUT_MS,
|
|
127
|
+
});
|
|
128
|
+
if (seedResult.error || seedResult.status !== 0) {
|
|
129
|
+
throwIfInterrupted(seedResult);
|
|
130
|
+
throw new Error("Could not start the TinyFish walkthrough in Hermes", {
|
|
131
|
+
cause: seedResult.error,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
|
|
135
|
+
const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
|
|
136
|
+
if (!sessionId) {
|
|
137
|
+
throw new Error("Hermes did not return a walkthrough session ID");
|
|
138
|
+
}
|
|
139
|
+
const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
|
|
140
|
+
if (result.error) {
|
|
141
|
+
throw new Error("Could not launch Hermes", { cause: result.error });
|
|
142
|
+
}
|
|
143
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
144
|
+
return;
|
|
145
|
+
if (result.status !== 0) {
|
|
146
|
+
throw new Error(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export const HERMES = {
|
|
150
|
+
command: "hermes",
|
|
151
|
+
connectClient: "hermes",
|
|
152
|
+
skillAgent: "hermes-agent",
|
|
153
|
+
displayName: "Hermes",
|
|
154
|
+
supportCheck: {
|
|
155
|
+
args: ["mcp", "add", "--help"],
|
|
156
|
+
patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
|
|
157
|
+
unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
|
|
158
|
+
},
|
|
159
|
+
removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
|
|
160
|
+
// Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
|
|
161
|
+
addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl, "--auth", "oauth"],
|
|
162
|
+
launchWalkthrough: launchHermesWalkthrough,
|
|
163
|
+
};
|
|
164
|
+
// OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
|
|
165
|
+
// would be read as a folder. The `--prompt` flag seeds the interactive TUI with a first message
|
|
166
|
+
// (opencode.ai/docs/cli), so the onboarding guide fires just like the other agents.
|
|
167
|
+
function launchOpencode() {
|
|
168
|
+
const result = spawn.sync("opencode", ["--prompt", DEFAULT_ONBOARDING_PROMPT], {
|
|
169
|
+
stdio: "inherit",
|
|
170
|
+
});
|
|
171
|
+
if (result.error) {
|
|
172
|
+
throw new Error("Could not launch OpenCode", { cause: result.error });
|
|
173
|
+
}
|
|
174
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
175
|
+
return;
|
|
176
|
+
if (result.status !== 0) {
|
|
177
|
+
throw new Error(`OpenCode exited with status ${result.status ?? "unknown"}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export const OPENCODE = {
|
|
181
|
+
command: "opencode",
|
|
182
|
+
connectClient: "opencode",
|
|
183
|
+
skillAgent: "opencode",
|
|
184
|
+
displayName: "OpenCode",
|
|
185
|
+
supportCheck: {
|
|
186
|
+
args: ["mcp", "add", "--help"],
|
|
187
|
+
patterns: [/--url(?:\s|$)/m],
|
|
188
|
+
unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
|
|
189
|
+
},
|
|
190
|
+
// `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
|
|
191
|
+
removals: [],
|
|
192
|
+
// OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
|
|
193
|
+
addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl],
|
|
194
|
+
loginArgs: ["mcp", "auth", "tinyfish"],
|
|
195
|
+
launchWalkthrough: launchOpencode,
|
|
196
|
+
postConnectNote: OPENCODE_MODEL_NOTE,
|
|
197
|
+
};
|
|
198
|
+
export const OPENCLAW = {
|
|
199
|
+
command: "openclaw",
|
|
200
|
+
displayName: "OpenClaw",
|
|
201
|
+
supportCheck: {
|
|
202
|
+
args: ["skills", "install", "--help"],
|
|
203
|
+
patterns: [/--global(?:\s|$)/, /--acknowledge-clawhub-risk(?:\s|$)/],
|
|
204
|
+
unavailableMessage: OPENCLAW_SKILL_UNAVAILABLE_MESSAGE,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
export function launchNativeMcpClient(client) {
|
|
208
|
+
errLine(`Starting the TinyFish walkthrough in ${client.displayName}...`);
|
|
209
|
+
if (client.launchWalkthrough) {
|
|
210
|
+
client.launchWalkthrough();
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
|
|
214
|
+
if (result.error) {
|
|
215
|
+
throw new Error(`Could not launch ${client.displayName}`, { cause: result.error });
|
|
216
|
+
}
|
|
217
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
218
|
+
return false;
|
|
219
|
+
if (result.status !== 0) {
|
|
220
|
+
throw new Error(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
export function launchOpenClawWalkthrough() {
|
|
225
|
+
errLine("Starting the TinyFish walkthrough in OpenClaw...");
|
|
226
|
+
const result = spawn.sync("openclaw", ["chat", "--message", OPENCLAW_ONBOARDING_PROMPT], {
|
|
227
|
+
stdio: "inherit",
|
|
228
|
+
});
|
|
229
|
+
if (result.error) {
|
|
230
|
+
throw new Error("Could not launch OpenClaw", { cause: result.error });
|
|
231
|
+
}
|
|
232
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM")
|
|
233
|
+
return false;
|
|
234
|
+
if (result.status !== 0) {
|
|
235
|
+
throw new Error(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`);
|
|
236
|
+
}
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
2
|
+
export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
|
|
3
|
+
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
4
|
+
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
5
|
+
type ConnectFailureReason = "harness_not_installed" | "harness_command_unsupported" | "harness_too_old";
|
|
6
|
+
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
7
|
+
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
8
|
+
export declare class ConnectInterruptedError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export declare function throwIfInterrupted(result: {
|
|
11
|
+
signal?: string | null;
|
|
12
|
+
error?: Error;
|
|
13
|
+
}): void;
|
|
14
|
+
export declare function commandNotFound(error: unknown): boolean;
|
|
15
|
+
export interface ConnectRunState {
|
|
16
|
+
stage: ConnectFailureStage;
|
|
17
|
+
settled: boolean;
|
|
18
|
+
}
|
|
19
|
+
interface ConnectStageDetail {
|
|
20
|
+
failedStage?: ConnectFailureStage;
|
|
21
|
+
phase?: ConnectCheckpoint;
|
|
22
|
+
failureReason?: ConnectFailureReason;
|
|
23
|
+
harnessVersion?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ConnectTelemetry {
|
|
26
|
+
attemptId: string;
|
|
27
|
+
track(stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
28
|
+
flush(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
31
|
+
export declare function settle(state: ConnectRunState, telemetry: ConnectTelemetry, stage: ConnectStage, detail?: ConnectStageDetail): void;
|
|
32
|
+
/** Shared terminal handling: interrupted → aborted, else failed. */
|
|
33
|
+
export declare function runGuarded(state: ConnectRunState, telemetry: ConnectTelemetry, body: () => void | Promise<void>): Promise<void>;
|
|
34
|
+
export interface SupportedCommand {
|
|
35
|
+
command: string;
|
|
36
|
+
displayName: string;
|
|
37
|
+
supportCheck: {
|
|
38
|
+
args: string[];
|
|
39
|
+
/** Missing → nothing can work; setup fails. */
|
|
40
|
+
patterns: RegExp[];
|
|
41
|
+
/** Missing → degrade to key or deferred auth, never refuse. */
|
|
42
|
+
optionalPatterns?: RegExp[];
|
|
43
|
+
unavailableMessage: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export declare function requireCommandSupport(client: SupportedCommand): {
|
|
47
|
+
optionalSupported: boolean;
|
|
48
|
+
};
|
|
49
|
+
export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
|
|
50
|
+
apiKey?: string;
|
|
51
|
+
attemptId?: string;
|
|
52
|
+
}): ConnectTelemetry;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import spawn from "cross-spawn";
|
|
3
|
+
import { CONNECT_ATTEMPT_ENV, isAttemptId, resolvedApiKey, takePendingConnectAttempt, } from "./auth.js";
|
|
4
|
+
import { CLI_VERSION } from "./constants.js";
|
|
5
|
+
import { detectHumanInitiated } from "./harness.js";
|
|
6
|
+
import { errLine, sanitizeLine } from "./output.js";
|
|
7
|
+
import { postConnectEvent, telemetryDisabled } from "./setup-telemetry.js";
|
|
8
|
+
import { installSignalGuard } from "./signals.js";
|
|
9
|
+
export const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
|
|
10
|
+
/** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
|
|
11
|
+
export class ConnectInterruptedError extends Error {
|
|
12
|
+
}
|
|
13
|
+
class PrerequisiteError extends Error {
|
|
14
|
+
failureReason;
|
|
15
|
+
harnessVersion;
|
|
16
|
+
constructor(message, failureReason, opts) {
|
|
17
|
+
super(message, { cause: opts?.cause });
|
|
18
|
+
this.failureReason = failureReason;
|
|
19
|
+
this.harnessVersion = opts?.harnessVersion;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function throwIfInterrupted(result) {
|
|
23
|
+
// spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
|
|
24
|
+
// network as the user walking away. A timeout is a failure and must report as one.
|
|
25
|
+
if (result.error?.code === "ETIMEDOUT")
|
|
26
|
+
return;
|
|
27
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM") {
|
|
28
|
+
throw new ConnectInterruptedError("Setup interrupted");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function commandNotFound(error) {
|
|
32
|
+
return error?.code === "ENOENT";
|
|
33
|
+
}
|
|
34
|
+
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
35
|
+
export function settle(state, telemetry, stage, detail) {
|
|
36
|
+
if (state.settled)
|
|
37
|
+
return;
|
|
38
|
+
state.settled = true;
|
|
39
|
+
telemetry.track(stage, detail);
|
|
40
|
+
}
|
|
41
|
+
// Raw-mode children still surface via throwIfInterrupted; cooked-mode stages
|
|
42
|
+
// (npm/npx/OAuth waits) land here.
|
|
43
|
+
function installConnectSignalGuard(state, telemetry) {
|
|
44
|
+
return installSignalGuard(async () => {
|
|
45
|
+
if (!state.settled) {
|
|
46
|
+
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
47
|
+
errLine("Setup interrupted — run the command again to finish.");
|
|
48
|
+
}
|
|
49
|
+
// Settled spans the whole post-install phase; the `completed` POST may still be in flight.
|
|
50
|
+
await telemetry.flush();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Shared terminal handling: interrupted → aborted, else failed. */
|
|
54
|
+
export async function runGuarded(state, telemetry, body) {
|
|
55
|
+
const uninstallSignalGuard = installConnectSignalGuard(state, telemetry);
|
|
56
|
+
try {
|
|
57
|
+
await body();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof ConnectInterruptedError) {
|
|
61
|
+
settle(state, telemetry, "aborted", { failedStage: state.stage });
|
|
62
|
+
errLine("Setup interrupted — run the command again to finish.");
|
|
63
|
+
process.exitCode = 130;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
settle(state, telemetry, "failed", {
|
|
67
|
+
failedStage: state.stage,
|
|
68
|
+
...(error instanceof PrerequisiteError
|
|
69
|
+
? { failureReason: error.failureReason, harnessVersion: error.harnessVersion }
|
|
70
|
+
: {}),
|
|
71
|
+
});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
uninstallSignalGuard();
|
|
76
|
+
await telemetry.flush();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function requireCommandSupport(client) {
|
|
80
|
+
const result = spawn.sync(client.command, client.supportCheck.args, {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
// Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
|
|
83
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
84
|
+
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
85
|
+
});
|
|
86
|
+
if (commandNotFound(result.error)) {
|
|
87
|
+
throw new PrerequisiteError(`${client.displayName} is not installed or not available on PATH.`, "harness_not_installed", { cause: result.error });
|
|
88
|
+
}
|
|
89
|
+
if (result.error || result.status !== 0) {
|
|
90
|
+
throwIfInterrupted(result);
|
|
91
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
|
|
92
|
+
}
|
|
93
|
+
// Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
|
|
94
|
+
const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
95
|
+
const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
|
|
96
|
+
const essential = listed(client.supportCheck.patterns);
|
|
97
|
+
const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
|
|
98
|
+
if ((!essential || !optionalSupported) && process.env["TINYFISH_DEBUG"]) {
|
|
99
|
+
errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
if (!essential) {
|
|
102
|
+
throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
|
|
103
|
+
harnessVersion: probeHarnessVersion(client.command),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { optionalSupported };
|
|
107
|
+
}
|
|
108
|
+
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
109
|
+
function probeHarnessVersion(command) {
|
|
110
|
+
const result = spawn.sync(command, ["--version"], {
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
113
|
+
});
|
|
114
|
+
if (result.error || result.status !== 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const version = sanitizeLine(result.stdout ?? "")
|
|
117
|
+
.replace(/[^\x20-\x7E]/g, "")
|
|
118
|
+
.trim();
|
|
119
|
+
return version ? version.slice(0, 32) : undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* One page-minted id seeds one install attempt, so `tinyfish onboard` connecting three agents
|
|
123
|
+
* does not report three installs under it. Later connects in the run fall back to a random id.
|
|
124
|
+
*/
|
|
125
|
+
function takeSeedAttemptId() {
|
|
126
|
+
const fromEnv = process.env[CONNECT_ATTEMPT_ENV];
|
|
127
|
+
// Both sources are read-and-clear, and the delete lands before any subprocess inherits it.
|
|
128
|
+
delete process.env[CONNECT_ATTEMPT_ENV];
|
|
129
|
+
const parked = takePendingConnectAttempt();
|
|
130
|
+
return fromEnv && isAttemptId(fromEnv) ? fromEnv : parked;
|
|
131
|
+
}
|
|
132
|
+
export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
133
|
+
// Consumed even when --url already supplied an id: leaving it behind would let a stale
|
|
134
|
+
// seed reach the agent this connect launches, or the next connect on this machine.
|
|
135
|
+
const seeded = takeSeedAttemptId();
|
|
136
|
+
// A setup-page id (via --url, the env var, or one parked by login) joins "copied the
|
|
137
|
+
// command" to this install attempt.
|
|
138
|
+
const attemptId = opts?.attemptId ?? seeded ?? randomUUID();
|
|
139
|
+
const endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
140
|
+
const pending = [];
|
|
141
|
+
async function deliver(body) {
|
|
142
|
+
// Ahead of the key read; postConnectEvent's own opt-out check is too late to skip it.
|
|
143
|
+
if (telemetryDisabled())
|
|
144
|
+
return;
|
|
145
|
+
// Resolved per event: `tinyfish auth login` writes the config from a subprocess mid-connect.
|
|
146
|
+
await postConnectEvent({
|
|
147
|
+
endpoint,
|
|
148
|
+
body,
|
|
149
|
+
attempts: 2,
|
|
150
|
+
apiKey: resolvedApiKey(opts?.apiKey),
|
|
151
|
+
label: "connect",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
attemptId,
|
|
156
|
+
// Fire-and-forget; awaiting each event stalls setup when telemetry down.
|
|
157
|
+
track(stage, detail) {
|
|
158
|
+
// Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
|
|
159
|
+
// replace the error the flow is already reporting.
|
|
160
|
+
pending.push(deliver(JSON.stringify({
|
|
161
|
+
attempt_id: attemptId,
|
|
162
|
+
client,
|
|
163
|
+
stage,
|
|
164
|
+
failed_stage: detail?.failedStage,
|
|
165
|
+
phase: detail?.phase,
|
|
166
|
+
failure_reason: detail?.failureReason,
|
|
167
|
+
harness_version: detail?.harnessVersion,
|
|
168
|
+
runtime_platform: process.platform,
|
|
169
|
+
node_version: process.version,
|
|
170
|
+
cli_version: CLI_VERSION,
|
|
171
|
+
// Same signal the usage events carry, so TTY and headless connects can be split.
|
|
172
|
+
is_human_initiated: detectHumanInitiated(),
|
|
173
|
+
})).catch((error) => {
|
|
174
|
+
// postConnectEvent is total, so this only fires if something inside deliver starts
|
|
175
|
+
// throwing. Same channel postConnectEvent uses for its own failures.
|
|
176
|
+
if (!process.env["TINYFISH_DEBUG"])
|
|
177
|
+
return;
|
|
178
|
+
errLine(`connect telemetry failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
// Awaited in finally so in-flight events land before exit.
|
|
182
|
+
async flush() {
|
|
183
|
+
await Promise.all(pending);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|