@tiny-fish/cli 0.24.1-next.208 → 0.24.1-next.212
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 +3 -0
- package/dist/commands/connect.d.ts +10 -1
- package/dist/commands/connect.js +90 -29
- package/dist/commands/onboard.js +1 -1
- package/dist/commands/upgrade.d.ts +3 -2
- package/dist/commands/upgrade.js +26 -12
- package/dist/lib/cli-install.d.ts +42 -0
- package/dist/lib/cli-install.js +44 -0
- package/dist/lib/connect-all.js +67 -8
- package/dist/lib/connect-auth.d.ts +3 -0
- package/dist/lib/connect-auth.js +42 -0
- package/dist/lib/connect-runtime.d.ts +4 -0
- package/dist/lib/connect-runtime.js +6 -1
- package/dist/lib/setup-telemetry.d.ts +3 -1
- package/dist/lib/setup-telemetry.js +2 -1
- package/dist/lib/{connect-install.d.ts → skill-install.d.ts} +1 -10
- package/dist/lib/{connect-install.js → skill-install.js} +60 -94
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,9 @@ Brings both halves of an install current: the global `@tiny-fish/cli` package an
|
|
|
101
101
|
Successful steps are summarised in one line each. Add `--verbose` to see the full npm and skills
|
|
102
102
|
output; a failing step prints its output either way.
|
|
103
103
|
|
|
104
|
+
`--cli-only` updates just the global package and `--skill-only` refreshes just the skill. The two
|
|
105
|
+
flags are mutually exclusive.
|
|
106
|
+
|
|
104
107
|
The skill step refreshes only the TinyFish skill, and only for the agents you ran
|
|
105
108
|
`tinyfish connect` against. Other global skills, and agents you never connected, are left alone.
|
|
106
109
|
The refresh overwrites your copy of the skill, including any local edits to it. Restart your
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type TakeHoistedCliInstall } from "../lib/cli-install.js";
|
|
2
3
|
import { type AgentClient, type ConnectAuthMode } from "../lib/connect-runtime.js";
|
|
3
4
|
export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
|
|
4
5
|
interface NativeConnectOptions {
|
|
@@ -8,6 +9,12 @@ interface NativeConnectOptions {
|
|
|
8
9
|
attemptId?: string;
|
|
9
10
|
/** Headless: refuse rather than fall back to an unfinishable browser sign-in. */
|
|
10
11
|
keyAuthOnly?: boolean;
|
|
12
|
+
/** Internal only: false leaves the global npm tree untouched. */
|
|
13
|
+
cliInstall?: boolean;
|
|
14
|
+
/** Set by `--all`: the CLI install already ran once, upfront. */
|
|
15
|
+
hoistedCliInstall?: TakeHoistedCliInstall;
|
|
16
|
+
/** Lets `--all` warn in its summary instead of all-green. */
|
|
17
|
+
onPostInstallFailed?: () => void;
|
|
11
18
|
}
|
|
12
19
|
export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
13
20
|
export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
|
|
@@ -18,8 +25,10 @@ export declare function connectOpenClaw(options: {
|
|
|
18
25
|
apiKey?: string;
|
|
19
26
|
mcpUrl: string;
|
|
20
27
|
launch: boolean;
|
|
21
|
-
|
|
28
|
+
cliInstall?: boolean;
|
|
22
29
|
attemptId?: string;
|
|
30
|
+
/** Set by `--all`: the CLI install already ran once, upfront. */
|
|
31
|
+
hoistedCliInstall?: TakeHoistedCliInstall;
|
|
23
32
|
}): Promise<void>;
|
|
24
33
|
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
25
34
|
export declare function connectCursor(options: {
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
|
|
3
3
|
import { CLAUDE_CODE, CODEX, CURSOR_SKILL_TARGET, GROK, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { HoistedFailureReportedError, installTinyFishCli, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/cli-install.js";
|
|
5
|
+
import { ensureCliAuthenticated } from "../lib/connect-auth.js";
|
|
6
|
+
import { installWebSkill } from "../lib/skill-install.js";
|
|
7
|
+
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
|
|
6
8
|
import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
|
|
7
9
|
import { runConnectAll } from "../lib/connect-all.js";
|
|
8
10
|
import { AuthMode } from "../lib/harness-detect.js";
|
|
@@ -143,52 +145,97 @@ function connectedLine(client, signInDeferred) {
|
|
|
143
145
|
}
|
|
144
146
|
return `TinyFish is connected. Open ${client.displayName} to start using it.`;
|
|
145
147
|
}
|
|
146
|
-
// Runs after `completed` settled:
|
|
148
|
+
// Runs after `completed` settled: steps fail alone, the rest still run.
|
|
147
149
|
function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
|
|
150
|
+
let failed = false;
|
|
151
|
+
// Modelled on upgrade's attempt(); interrupts rethrow and abandon remaining steps.
|
|
152
|
+
const attempt = (stage, run) => {
|
|
153
|
+
state.stage = stage;
|
|
154
|
+
try {
|
|
155
|
+
run();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error instanceof ConnectInterruptedError)
|
|
159
|
+
throw error;
|
|
160
|
+
failed = true;
|
|
161
|
+
trackPostInstallFailure(client.displayName, state, telemetry, error);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
148
164
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
165
|
+
if (options.cliInstall !== false) {
|
|
166
|
+
attempt("cli_install", () => runCliInstallStep(options, telemetry));
|
|
167
|
+
}
|
|
153
168
|
if (client.skillAgent) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
attempt("skill_install", () => {
|
|
170
|
+
installWebSkill(client);
|
|
171
|
+
telemetry.track("checkpoint", { phase: "skill_installed" });
|
|
172
|
+
});
|
|
157
173
|
}
|
|
158
|
-
|
|
159
|
-
ensureCliAuthenticated(client.connectClient, options.apiKey);
|
|
160
|
-
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
161
|
-
// Persist once here (skill installed + authenticated) so both success paths, launch
|
|
162
|
-
// or not, record the install; buildConnectHeaders reads it on subsequent requests.
|
|
174
|
+
// Before auth: a failed sign-in must not lose the harness record.
|
|
163
175
|
saveConnectContext(client.connectClient, telemetry.attemptId);
|
|
176
|
+
attempt("authentication", () => {
|
|
177
|
+
ensureCliAuthenticated(client.connectClient, options.apiKey);
|
|
178
|
+
telemetry.track("checkpoint", { phase: "authenticated" });
|
|
179
|
+
});
|
|
164
180
|
errLine(UPGRADE_HINT);
|
|
165
181
|
if (client.postConnectNote)
|
|
166
182
|
errLine(client.postConnectNote);
|
|
167
183
|
if (!options.launch) {
|
|
168
184
|
errLine(connectedLine(client, signInDeferred));
|
|
169
|
-
return;
|
|
170
185
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
else {
|
|
187
|
+
attempt("walkthrough_launch", () => {
|
|
188
|
+
// Before the handover, or the only sign-in instructions scroll past under the agent.
|
|
189
|
+
if (signInDeferred && client.signInHint)
|
|
190
|
+
errLine(client.signInHint);
|
|
191
|
+
launchNativeMcpClient(client);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
176
194
|
}
|
|
177
195
|
catch (error) {
|
|
178
|
-
|
|
196
|
+
// Interrupts stop quietly; anything else escapes to runGuarded.
|
|
197
|
+
if (!(error instanceof ConnectInterruptedError))
|
|
198
|
+
throw error;
|
|
199
|
+
failed = true;
|
|
200
|
+
}
|
|
201
|
+
if (failed) {
|
|
202
|
+
errLine(finishSetupHint(client.connectClient));
|
|
203
|
+
options.onPostInstallFailed?.();
|
|
179
204
|
}
|
|
180
205
|
}
|
|
206
|
+
/** Consumes the `--all` hoist when present; installs inline otherwise. */
|
|
207
|
+
function runCliInstallStep(options, telemetry) {
|
|
208
|
+
if (options.hoistedCliInstall) {
|
|
209
|
+
const { outcome, firstTake } = options.hoistedCliInstall();
|
|
210
|
+
if (!outcome.ok) {
|
|
211
|
+
// Late takers fail too; only the first reports the install.
|
|
212
|
+
throw firstTake ? outcome.error : new HoistedFailureReportedError(outcome.error.message);
|
|
213
|
+
}
|
|
214
|
+
if (firstTake) {
|
|
215
|
+
telemetry.track("checkpoint", { phase: "cli_installed", stageDurationMs: outcome.durationMs });
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Verbose on purpose: first-run setup must not go silent.
|
|
220
|
+
installTinyFishCli({ verbose: true });
|
|
221
|
+
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
222
|
+
}
|
|
223
|
+
function finishSetupHint(connectClient) {
|
|
224
|
+
return `Finish setup with: ${finishSetupCommand(connectClient)}`;
|
|
225
|
+
}
|
|
181
226
|
/** Warn + report post_install_failed; interrupts are abandonment, not failure. */
|
|
182
|
-
function
|
|
183
|
-
if (
|
|
227
|
+
function trackPostInstallFailure(displayName, state, telemetry, error) {
|
|
228
|
+
if (error instanceof ConnectInterruptedError)
|
|
229
|
+
return;
|
|
230
|
+
// An earlier harness already owns the shared install's failure record.
|
|
231
|
+
if (!(error instanceof HoistedFailureReportedError)) {
|
|
184
232
|
telemetry.track("post_install_failed", {
|
|
185
233
|
failedStage: state.stage,
|
|
186
234
|
...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
|
|
187
235
|
});
|
|
188
|
-
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
189
|
-
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
190
236
|
}
|
|
191
|
-
errLine(`
|
|
237
|
+
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
238
|
+
`(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
192
239
|
}
|
|
193
240
|
export async function connectClaudeCode(options) {
|
|
194
241
|
return connectNativeMcpClient(CLAUDE_CODE, options);
|
|
@@ -217,7 +264,20 @@ export async function connectOpenClaw(options) {
|
|
|
217
264
|
telemetry.track("started");
|
|
218
265
|
telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
|
|
219
266
|
telemetry.track("checkpoint", { phase: "prerequisite_ok" });
|
|
220
|
-
if (options.
|
|
267
|
+
if (options.hoistedCliInstall) {
|
|
268
|
+
state.stage = "cli_install";
|
|
269
|
+
const { outcome, firstTake } = options.hoistedCliInstall();
|
|
270
|
+
// A carried failure fails openclaw exactly like the inline install.
|
|
271
|
+
if (!outcome.ok)
|
|
272
|
+
throw outcome.error;
|
|
273
|
+
if (firstTake) {
|
|
274
|
+
telemetry.track("checkpoint", {
|
|
275
|
+
phase: "cli_installed",
|
|
276
|
+
stageDurationMs: outcome.durationMs,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (options.cliInstall !== false) {
|
|
221
281
|
state.stage = "cli_install";
|
|
222
282
|
installTinyFishCli({ verbose: true });
|
|
223
283
|
telemetry.track("checkpoint", { phase: "cli_installed" });
|
|
@@ -249,7 +309,8 @@ export async function connectOpenClaw(options) {
|
|
|
249
309
|
launchOpenClawWalkthrough();
|
|
250
310
|
}
|
|
251
311
|
catch (error) {
|
|
252
|
-
|
|
312
|
+
trackPostInstallFailure("OpenClaw", state, telemetry, error);
|
|
313
|
+
errLine(finishSetupHint("openclaw"));
|
|
253
314
|
}
|
|
254
315
|
});
|
|
255
316
|
}
|
package/dist/commands/onboard.js
CHANGED
|
@@ -87,7 +87,7 @@ async function connectAgent(id) {
|
|
|
87
87
|
return;
|
|
88
88
|
const mcpUrl = new URL("/mcp", BASE_URL).toString();
|
|
89
89
|
if (id === "openclaw") {
|
|
90
|
-
await connectOpenClaw({ mcpUrl, launch: false,
|
|
90
|
+
await connectOpenClaw({ mcpUrl, launch: false, cliInstall: false });
|
|
91
91
|
}
|
|
92
92
|
else {
|
|
93
93
|
await agent.connect({ mcpUrl, launch: false });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type UpgradeScope } from "../lib/setup-telemetry.js";
|
|
2
3
|
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
3
|
-
export declare function runUpgrade(verbose?: boolean): string | null;
|
|
4
|
+
export declare function runUpgrade(verbose?: boolean, scope?: UpgradeScope): string | null;
|
|
4
5
|
/** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
|
|
5
|
-
export declare function runUpgradeCommand(verbose?: boolean): Promise<void>;
|
|
6
|
+
export declare function runUpgradeCommand(verbose?: boolean, scope?: UpgradeScope): Promise<void>;
|
|
6
7
|
export declare function registerUpgrade(program: Command): void;
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -6,7 +6,8 @@ import { suppressNotice } from "../lib/notice.js";
|
|
|
6
6
|
import { errLine } from "../lib/output.js";
|
|
7
7
|
import { sendUpgradeCompleted, telemetryDisabled, } from "../lib/setup-telemetry.js";
|
|
8
8
|
import { installSignalGuard, SIGNAL_EXIT_CODES } from "../lib/signals.js";
|
|
9
|
-
import { installTinyFishCli
|
|
9
|
+
import { installTinyFishCli } from "../lib/cli-install.js";
|
|
10
|
+
import { updateWebSkill } from "../lib/skill-install.js";
|
|
10
11
|
import { ConnectInterruptedError } from "../lib/connect-runtime.js";
|
|
11
12
|
const VERSION_READ_TIMEOUT_MS = 2_000;
|
|
12
13
|
const INTERRUPTED_MESSAGE = "Upgrade interrupted — run `tinyfish upgrade` again to finish.";
|
|
@@ -26,16 +27,20 @@ function attempt(label, run) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
/** Returns the version now installed, so the caller reports exactly what the user was told. */
|
|
29
|
-
export function runUpgrade(verbose = false) {
|
|
30
|
+
export function runUpgrade(verbose = false, scope = "both") {
|
|
30
31
|
errLine("Upgrading TinyFish...");
|
|
31
32
|
let refreshed = false;
|
|
32
33
|
try {
|
|
33
|
-
//
|
|
34
|
+
// Every selected step runs: one failing must not stall the other.
|
|
34
35
|
const failures = [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
scope === "skill"
|
|
37
|
+
? undefined
|
|
38
|
+
: attempt("update the TinyFish CLI", () => installTinyFishCli({ verbose })),
|
|
39
|
+
scope === "cli"
|
|
40
|
+
? undefined
|
|
41
|
+
: attempt("refresh the TinyFish web skill", () => {
|
|
42
|
+
refreshed = updateWebSkill({ verbose });
|
|
43
|
+
}),
|
|
39
44
|
].filter((failure) => failure !== undefined);
|
|
40
45
|
if (failures.length > 0)
|
|
41
46
|
throw new Error(`tinyfish upgrade failed: ${failures.join("; ")}`);
|
|
@@ -48,7 +53,10 @@ export function runUpgrade(verbose = false) {
|
|
|
48
53
|
return null;
|
|
49
54
|
}
|
|
50
55
|
const version = installedCliVersion();
|
|
51
|
-
|
|
56
|
+
// --skill-only never checked the CLI, so no versioned claim.
|
|
57
|
+
const upToDate = version && scope !== "skill"
|
|
58
|
+
? `TinyFish CLI ${version} is up to date.`
|
|
59
|
+
: "TinyFish is up to date.";
|
|
52
60
|
// Only a rewritten skill needs an agent restart.
|
|
53
61
|
errLine(refreshed ? `${upToDate} Restart your agent to pick up the refreshed skill.` : upToDate);
|
|
54
62
|
return version;
|
|
@@ -70,7 +78,7 @@ function globalCliVersion() {
|
|
|
70
78
|
}
|
|
71
79
|
}
|
|
72
80
|
/** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
|
|
73
|
-
export async function runUpgradeCommand(verbose = false) {
|
|
81
|
+
export async function runUpgradeCommand(verbose = false, scope = "both") {
|
|
74
82
|
// This command is the upgrade, so a trailing "an update is available" would be noise.
|
|
75
83
|
suppressNotice();
|
|
76
84
|
let reported = false;
|
|
@@ -85,7 +93,7 @@ export async function runUpgradeCommand(verbose = false) {
|
|
|
85
93
|
return;
|
|
86
94
|
// Interrupted skips the read — racing the exit. Undercounts; see posthog-events.md.
|
|
87
95
|
const toVersion = outcome === "interrupted" ? null : (printedVersion ?? installedCliVersion());
|
|
88
|
-
await sendUpgradeCompleted(CLI_VERSION, toVersion, outcome, resolvedApiKey());
|
|
96
|
+
await sendUpgradeCompleted(CLI_VERSION, toVersion, outcome, scope, resolvedApiKey());
|
|
89
97
|
};
|
|
90
98
|
const uninstallSignalGuard = installSignalGuard(async () => {
|
|
91
99
|
errLine(INTERRUPTED_MESSAGE);
|
|
@@ -93,7 +101,7 @@ export async function runUpgradeCommand(verbose = false) {
|
|
|
93
101
|
});
|
|
94
102
|
let outcome = "updated";
|
|
95
103
|
try {
|
|
96
|
-
printedVersion = runUpgrade(verbose);
|
|
104
|
+
printedVersion = runUpgrade(verbose, scope);
|
|
97
105
|
if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
|
|
98
106
|
outcome = "interrupted";
|
|
99
107
|
}
|
|
@@ -112,7 +120,13 @@ export function registerUpgrade(program) {
|
|
|
112
120
|
.command("upgrade")
|
|
113
121
|
.description("Update the TinyFish CLI and the use-tinyfish skill to the latest versions")
|
|
114
122
|
.option("--verbose", "Print the full npm and skills output instead of only on failure")
|
|
123
|
+
.option("--cli-only", "Update only the TinyFish CLI, leaving the skill untouched")
|
|
124
|
+
.option("--skill-only", "Refresh only the use-tinyfish skill, leaving the CLI untouched")
|
|
115
125
|
.action(async (options) => {
|
|
116
|
-
|
|
126
|
+
if (options.cliOnly && options.skillOnly) {
|
|
127
|
+
throw new Error("Pass either --cli-only or --skill-only, not both.");
|
|
128
|
+
}
|
|
129
|
+
const scope = options.cliOnly ? "cli" : options.skillOnly ? "skill" : "both";
|
|
130
|
+
await runUpgradeCommand(Boolean(options.verbose), scope);
|
|
117
131
|
});
|
|
118
132
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const SKILL_INSTALL_TIMEOUT_MS = 120000;
|
|
2
|
+
export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
3
|
+
export declare const STEP_MAX_BUFFER: number;
|
|
4
|
+
/** Quiet captures subprocess output and replays it only on failure. */
|
|
5
|
+
export type InstallOptions = {
|
|
6
|
+
verbose: boolean;
|
|
7
|
+
};
|
|
8
|
+
/** Outcome of `--all`'s one npm install; error keeps ConnectStepError.failureReason. */
|
|
9
|
+
export type HoistedCliInstall = {
|
|
10
|
+
ok: true;
|
|
11
|
+
durationMs: number;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
error: Error;
|
|
15
|
+
};
|
|
16
|
+
/** Every harness reads the outcome; only the first take reports it. */
|
|
17
|
+
export type TakeHoistedCliInstall = () => {
|
|
18
|
+
outcome: HoistedCliInstall;
|
|
19
|
+
firstTake: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Carried failure an earlier harness already reported. */
|
|
22
|
+
export declare class HoistedFailureReportedError extends Error {
|
|
23
|
+
}
|
|
24
|
+
export declare function captureStdio(verbose: boolean): {
|
|
25
|
+
stdio: "inherit";
|
|
26
|
+
timeout: number;
|
|
27
|
+
encoding?: undefined;
|
|
28
|
+
maxBuffer?: undefined;
|
|
29
|
+
} | {
|
|
30
|
+
encoding: "utf8";
|
|
31
|
+
maxBuffer: number;
|
|
32
|
+
timeout: number;
|
|
33
|
+
stdio?: undefined;
|
|
34
|
+
};
|
|
35
|
+
/** Inherited stdio leaves these null; only piped runs yield text. */
|
|
36
|
+
export declare function capturedOutput(result: {
|
|
37
|
+
stdout?: unknown;
|
|
38
|
+
stderr?: unknown;
|
|
39
|
+
}): string;
|
|
40
|
+
/** A verbose run captured nothing, so this is a no-op. */
|
|
41
|
+
export declare function replay(output: string): void;
|
|
42
|
+
export declare function installTinyFishCli({ verbose }: InstallOptions): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
import { spawnStepError } from "./connect-runtime.js";
|
|
3
|
+
import { TINYFISH_CLI_PACKAGE } from "./constants.js";
|
|
4
|
+
import { installRoot } from "./install-root.js";
|
|
5
|
+
import { errLine } from "./output.js";
|
|
6
|
+
export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
7
|
+
const TINYFISH_CLI_INSTALL_SPEC = `${TINYFISH_CLI_PACKAGE}@latest`;
|
|
8
|
+
export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
9
|
+
// spawnSync caps piped output at 1 MiB by default.
|
|
10
|
+
export const STEP_MAX_BUFFER = 10 * 1024 * 1024;
|
|
11
|
+
/** Carried failure an earlier harness already reported. */
|
|
12
|
+
export class HoistedFailureReportedError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function captureStdio(verbose) {
|
|
15
|
+
return verbose
|
|
16
|
+
? { stdio: "inherit", timeout: SKILL_INSTALL_TIMEOUT_MS }
|
|
17
|
+
: { encoding: "utf8", maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS };
|
|
18
|
+
}
|
|
19
|
+
/** Inherited stdio leaves these null; only piped runs yield text. */
|
|
20
|
+
export function capturedOutput(result) {
|
|
21
|
+
return `${asText(result.stdout)}${asText(result.stderr)}`;
|
|
22
|
+
}
|
|
23
|
+
function asText(stream) {
|
|
24
|
+
return typeof stream === "string" ? stream : "";
|
|
25
|
+
}
|
|
26
|
+
/** A verbose run captured nothing, so this is a no-op. */
|
|
27
|
+
export function replay(output) {
|
|
28
|
+
if (output.trim())
|
|
29
|
+
errLine(output.trimEnd());
|
|
30
|
+
}
|
|
31
|
+
export function installTinyFishCli({ verbose }) {
|
|
32
|
+
if (verbose)
|
|
33
|
+
errLine("Installing the TinyFish CLI...");
|
|
34
|
+
// Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
|
|
35
|
+
const root = installRoot();
|
|
36
|
+
const prefixArgs = root ? ["--prefix", root.prefix] : [];
|
|
37
|
+
const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], captureStdio(verbose));
|
|
38
|
+
if (result.error || result.status !== 0) {
|
|
39
|
+
// Built first: it runs the interrupt check, and abandonment must not replay.
|
|
40
|
+
const error = spawnStepError("Could not install the TinyFish CLI", result);
|
|
41
|
+
replay(capturedOutput(result));
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -2,9 +2,10 @@ import * as readline from "node:readline/promises";
|
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
3
|
import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
|
|
4
4
|
import { validatedApiKey } from "./auth.js";
|
|
5
|
+
import { installTinyFishCli, } from "./cli-install.js";
|
|
5
6
|
import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
|
|
6
|
-
import { ensureCliAuthenticated,
|
|
7
|
-
import { commandNotFound, ConnectInterruptedError, ConnectStepError, throwIfInterrupted, } from "./connect-runtime.js";
|
|
7
|
+
import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
|
|
8
|
+
import { commandNotFound, ConnectInterruptedError, ConnectStepError, finishSetupCommand, throwIfInterrupted, } from "./connect-runtime.js";
|
|
8
9
|
import { detectInstalledHarnesses, ALL_HARNESSES, } from "./harness-detect.js";
|
|
9
10
|
import { detectHumanInitiated } from "./harness.js";
|
|
10
11
|
import { emitNotice } from "./notice.js";
|
|
@@ -46,8 +47,15 @@ const UNINSTALL_POINTERS = {
|
|
|
46
47
|
// OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
|
|
47
48
|
opencode: "remove the tinyfish entry from ~/.config/opencode/opencode.json",
|
|
48
49
|
};
|
|
49
|
-
async function runHarnessConnect(harness, opts, keyAuthOnly) {
|
|
50
|
-
const base = {
|
|
50
|
+
async function runHarnessConnect(harness, opts, keyAuthOnly, hoistedCliInstall, onPostInstallFailed) {
|
|
51
|
+
const base = {
|
|
52
|
+
apiKey: opts.apiKey,
|
|
53
|
+
mcpUrl: opts.mcpUrl,
|
|
54
|
+
launch: false,
|
|
55
|
+
keyAuthOnly,
|
|
56
|
+
hoistedCliInstall,
|
|
57
|
+
onPostInstallFailed,
|
|
58
|
+
};
|
|
51
59
|
if (harness === "claude-code")
|
|
52
60
|
return connectClaudeCode(base);
|
|
53
61
|
if (harness === "codex")
|
|
@@ -56,7 +64,12 @@ async function runHarnessConnect(harness, opts, keyAuthOnly) {
|
|
|
56
64
|
return connectHermes(base);
|
|
57
65
|
if (harness === "openclaw") {
|
|
58
66
|
// Without the key the child shells an interactive `tinyfish auth login`.
|
|
59
|
-
await connectOpenClaw({
|
|
67
|
+
await connectOpenClaw({
|
|
68
|
+
apiKey: opts.apiKey,
|
|
69
|
+
mcpUrl: opts.mcpUrl,
|
|
70
|
+
launch: false,
|
|
71
|
+
hoistedCliInstall,
|
|
72
|
+
});
|
|
60
73
|
return undefined;
|
|
61
74
|
}
|
|
62
75
|
if (harness === "opencode")
|
|
@@ -70,6 +83,20 @@ async function runHarnessConnect(harness, opts, keyAuthOnly) {
|
|
|
70
83
|
// Explicit: a new harness reaching an untested branch must fail, not write Cursor's config.
|
|
71
84
|
throw new Error(`No installer is wired for ${harness}`);
|
|
72
85
|
}
|
|
86
|
+
/** Undefined means Ctrl+C: the caller stops the whole run. */
|
|
87
|
+
function runHoistedCliInstall() {
|
|
88
|
+
const startedAt = Date.now();
|
|
89
|
+
try {
|
|
90
|
+
installTinyFishCli({ verbose: true });
|
|
91
|
+
// Clamped: a backward clock step would send a route-rejected negative.
|
|
92
|
+
return { ok: true, durationMs: Math.max(0, Date.now() - startedAt) };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof ConnectInterruptedError)
|
|
96
|
+
return undefined;
|
|
97
|
+
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
73
100
|
async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
|
|
74
101
|
const health = await verifyMcpHealth(mcpUrl);
|
|
75
102
|
// The own-OAuth harnesses are auth-checkable only in key mode (key-authed config).
|
|
@@ -117,7 +144,7 @@ function uninstallHarness(harness, configPath) {
|
|
|
117
144
|
}
|
|
118
145
|
return { ...detection, outcome: "uninstalled" };
|
|
119
146
|
}
|
|
120
|
-
async function processHarness(detection, opts, prompt) {
|
|
147
|
+
async function processHarness(detection, opts, prompt, hoistedCliInstall) {
|
|
121
148
|
const { harness, configPath } = detection;
|
|
122
149
|
if (!detection.detected) {
|
|
123
150
|
return { harness, detected: false, configPath, installed: false, outcome: "not_detected" };
|
|
@@ -157,9 +184,12 @@ async function processHarness(detection, opts, prompt) {
|
|
|
157
184
|
// runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
|
|
158
185
|
const interruptedBefore = process.exitCode === 130;
|
|
159
186
|
let authMode;
|
|
187
|
+
let postInstallFailed = false;
|
|
160
188
|
try {
|
|
161
189
|
// Headless: an install too old for key auth must fail, not open a browser.
|
|
162
|
-
authMode = await runHarnessConnect(harness, opts, !isTTY)
|
|
190
|
+
authMode = await runHarnessConnect(harness, opts, !isTTY, hoistedCliInstall, () => {
|
|
191
|
+
postInstallFailed = true;
|
|
192
|
+
});
|
|
163
193
|
}
|
|
164
194
|
catch (e) {
|
|
165
195
|
return {
|
|
@@ -183,6 +213,7 @@ async function processHarness(detection, opts, prompt) {
|
|
|
183
213
|
verifyOk: verify.ok,
|
|
184
214
|
verifyReason: verify.reason,
|
|
185
215
|
keyAuthed,
|
|
216
|
+
postInstallFailed,
|
|
186
217
|
};
|
|
187
218
|
}
|
|
188
219
|
function summaryLine(result, uninstall) {
|
|
@@ -222,6 +253,11 @@ function summaryLine(result, uninstall) {
|
|
|
222
253
|
if (result.outcome === "failed") {
|
|
223
254
|
return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
|
|
224
255
|
}
|
|
256
|
+
// The summary is what users read; stderr hints scroll away.
|
|
257
|
+
if (result.postInstallFailed) {
|
|
258
|
+
return (`⚠ ${name} — installed, but a finishing step failed. ` +
|
|
259
|
+
`Fix: ${finishSetupCommand(result.harness)}`);
|
|
260
|
+
}
|
|
225
261
|
// A keyed install has no sign-in left, so the deferred-auth warning would contradict it.
|
|
226
262
|
const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness) && !result.keyAuthed;
|
|
227
263
|
const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : "not verified";
|
|
@@ -334,13 +370,36 @@ export async function runConnectAll(opts) {
|
|
|
334
370
|
}
|
|
335
371
|
: undefined;
|
|
336
372
|
const detections = detectInstalledHarnesses();
|
|
373
|
+
const gateKey = validatedApiKey(opts.apiKey);
|
|
374
|
+
// Mirrors processHarness's skip gate: a skipped harness installs nothing today.
|
|
375
|
+
const willAttempt = (harness) => isTTY || (!!gateKey && KEY_AUTH_CAPABLE.has(harness));
|
|
376
|
+
// Machine-global, so once per invocation; cursor's connect never installs it.
|
|
377
|
+
const cliNeeded = !opts.dryRun &&
|
|
378
|
+
!opts.uninstall &&
|
|
379
|
+
detections.some((d) => d.detected && d.harness !== "cursor" && willAttempt(d.harness));
|
|
380
|
+
let takeHoistedCli;
|
|
381
|
+
if (cliNeeded) {
|
|
382
|
+
errLine("First: the TinyFish CLI, shared by every harness.");
|
|
383
|
+
const outcome = runHoistedCliInstall();
|
|
384
|
+
if (!outcome) {
|
|
385
|
+
errLine("Setup interrupted — run the command again to finish.");
|
|
386
|
+
return 130;
|
|
387
|
+
}
|
|
388
|
+
let taken = false;
|
|
389
|
+
// Every harness gets the outcome; only the first take reports it.
|
|
390
|
+
takeHoistedCli = () => {
|
|
391
|
+
const firstTake = !taken;
|
|
392
|
+
taken = true;
|
|
393
|
+
return { outcome, firstTake };
|
|
394
|
+
};
|
|
395
|
+
}
|
|
337
396
|
const results = [];
|
|
338
397
|
for (const [index, detection] of detections.entries()) {
|
|
339
398
|
const { harness } = detection;
|
|
340
399
|
errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
|
|
341
400
|
let result;
|
|
342
401
|
try {
|
|
343
|
-
result = await processHarness(detection, opts, prompt);
|
|
402
|
+
result = await processHarness(detection, opts, prompt, takeHoistedCli);
|
|
344
403
|
}
|
|
345
404
|
catch (e) {
|
|
346
405
|
// Isolation: one harness throwing never stops the others.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type AgentClient } from "./connect-runtime.js";
|
|
2
|
+
export declare const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
3
|
+
export declare function ensureCliAuthenticated(source: AgentClient | "cli", apiKey?: string): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
import { persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
|
|
3
|
+
import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
|
|
4
|
+
import { errLine } from "./output.js";
|
|
5
|
+
export const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
6
|
+
export function ensureCliAuthenticated(source, apiKey) {
|
|
7
|
+
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
8
|
+
if (envKey) {
|
|
9
|
+
if (!validateKeyFormat(envKey)) {
|
|
10
|
+
throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
|
|
11
|
+
}
|
|
12
|
+
// Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
|
|
13
|
+
writeConfig(envKey);
|
|
14
|
+
persistApiKeyToEnvironment(envKey);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const authStatus = spawn.sync("tinyfish", ["auth", "status"], {
|
|
18
|
+
stdio: "ignore",
|
|
19
|
+
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
20
|
+
});
|
|
21
|
+
if (commandNotFound(authStatus.error)) {
|
|
22
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
|
|
23
|
+
}
|
|
24
|
+
if (authStatus.status === 0)
|
|
25
|
+
return;
|
|
26
|
+
// A crashed or timed-out probe says nothing about the stored key, so it must not read as
|
|
27
|
+
// "not signed in" and drop the user into an interactive login they never asked for.
|
|
28
|
+
throwIfInterrupted(authStatus);
|
|
29
|
+
if (authStatus.error) {
|
|
30
|
+
throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
|
|
31
|
+
}
|
|
32
|
+
errLine("Signing in to the TinyFish CLI...");
|
|
33
|
+
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
|
|
34
|
+
stdio: "inherit",
|
|
35
|
+
});
|
|
36
|
+
if (commandNotFound(loginResult.error)) {
|
|
37
|
+
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
|
|
38
|
+
}
|
|
39
|
+
if (loginResult.error || loginResult.status !== 0) {
|
|
40
|
+
throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -31,6 +31,8 @@ interface SpawnStepResult {
|
|
|
31
31
|
/** Classifies a spawn failure; interrupts throw instead. */
|
|
32
32
|
export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
|
|
33
33
|
export declare function commandNotFound(error: unknown): boolean;
|
|
34
|
+
/** One command redoes every post-install step for a client. */
|
|
35
|
+
export declare function finishSetupCommand(client: AgentClient): string;
|
|
34
36
|
export interface ConnectRunState {
|
|
35
37
|
stage: ConnectFailureStage;
|
|
36
38
|
settled: boolean;
|
|
@@ -47,6 +49,8 @@ interface ConnectStageDetail {
|
|
|
47
49
|
authMode?: ConnectAuthMode;
|
|
48
50
|
/** Orthogonal to `authMode`: a stored key wins over `mcp login` on a healthy client too. */
|
|
49
51
|
harnessDegraded?: boolean;
|
|
52
|
+
/** Overrides the event-gap duration; the hoisted CLI install measures itself. */
|
|
53
|
+
stageDurationMs?: number;
|
|
50
54
|
}
|
|
51
55
|
export interface ConnectTelemetry {
|
|
52
56
|
attemptId: string;
|
|
@@ -61,6 +61,10 @@ export function spawnStepError(message, result) {
|
|
|
61
61
|
export function commandNotFound(error) {
|
|
62
62
|
return error?.code === "ENOENT";
|
|
63
63
|
}
|
|
64
|
+
/** One command redoes every post-install step for a client. */
|
|
65
|
+
export function finishSetupCommand(client) {
|
|
66
|
+
return `npx -y @tiny-fish/cli@latest connect ${client}`;
|
|
67
|
+
}
|
|
64
68
|
/** Terminal events exactly once, whichever of flow or signal handler wins. */
|
|
65
69
|
export function settle(state, telemetry, stage, detail) {
|
|
66
70
|
if (state.settled)
|
|
@@ -203,7 +207,8 @@ export function createConnectTelemetry(mcpUrl, client, opts) {
|
|
|
203
207
|
track(stage, detail) {
|
|
204
208
|
const now = Date.now();
|
|
205
209
|
// Clamped: a backward clock step would send a route-rejected negative.
|
|
206
|
-
const stageDurationMs =
|
|
210
|
+
const stageDurationMs = detail?.stageDurationMs ??
|
|
211
|
+
(lastTrackAt === undefined ? undefined : Math.max(0, now - lastTrackAt));
|
|
207
212
|
lastTrackAt = now;
|
|
208
213
|
// Absorbed here, not in flush: flush runs in runGuarded's finally, so a rejection would
|
|
209
214
|
// replace the error the flow is already reporting.
|
|
@@ -64,7 +64,9 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
|
|
|
64
64
|
export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
|
|
65
65
|
export type HarnessResult = z.infer<typeof harnessResultSchema>;
|
|
66
66
|
export type UpgradeOutcome = "updated" | "failed" | "interrupted";
|
|
67
|
+
/** Step selection; "updated" means every selected step ran. */
|
|
68
|
+
export type UpgradeScope = "both" | "cli" | "skill";
|
|
67
69
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
68
|
-
export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, apiKey?: string): Promise<void>;
|
|
70
|
+
export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
|
|
69
71
|
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
|
|
70
72
|
export {};
|
|
@@ -111,7 +111,7 @@ export const setupCompletedPayloadSchema = z.object({
|
|
|
111
111
|
cli_version: z.string(),
|
|
112
112
|
});
|
|
113
113
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
114
|
-
export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, apiKey) {
|
|
114
|
+
export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scope, apiKey) {
|
|
115
115
|
await postConnectEvent({
|
|
116
116
|
endpoint: new URL("/api/cli/connect-event", BASE_URL).toString(),
|
|
117
117
|
body: JSON.stringify({
|
|
@@ -119,6 +119,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, apiK
|
|
|
119
119
|
from_version: fromVersion,
|
|
120
120
|
to_version: toVersion,
|
|
121
121
|
outcome,
|
|
122
|
+
scope,
|
|
122
123
|
}),
|
|
123
124
|
attempts: 1,
|
|
124
125
|
label: "upgrade",
|
|
@@ -1,13 +1,5 @@
|
|
|
1
|
+
import { type InstallOptions } from "./cli-install.js";
|
|
1
2
|
import { type SkillAgent } from "./connect-clients.js";
|
|
2
|
-
import { type AgentClient } from "./connect-runtime.js";
|
|
3
|
-
export declare const SKILL_INSTALL_TIMEOUT_MS = 120000;
|
|
4
|
-
export declare const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
5
|
-
export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
6
|
-
/** Quiet captures subprocess output and replays it only on failure. */
|
|
7
|
-
export type InstallOptions = {
|
|
8
|
-
verbose: boolean;
|
|
9
|
-
};
|
|
10
|
-
export declare function installTinyFishCli({ verbose }: InstallOptions): void;
|
|
11
3
|
export declare function installWebSkill(client: {
|
|
12
4
|
skillAgent?: SkillAgent;
|
|
13
5
|
displayName: string;
|
|
@@ -23,4 +15,3 @@ export declare function installWebSkill(client: {
|
|
|
23
15
|
* Returns whether a skill was actually rewritten.
|
|
24
16
|
*/
|
|
25
17
|
export declare function updateWebSkill({ verbose }: InstallOptions): boolean;
|
|
26
|
-
export declare function ensureCliAuthenticated(source: AgentClient | "cli", apiKey?: string): void;
|
|
@@ -1,52 +1,17 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import spawn from "cross-spawn";
|
|
3
|
-
import {
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { loadConfig } from "./auth.js";
|
|
5
|
+
import { captureStdio, capturedOutput, replay, SKILL_INSTALL_TIMEOUT_MS, STEP_MAX_BUFFER, } from "./cli-install.js";
|
|
4
6
|
import { CURSOR_SKILL_TARGET, NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
|
|
5
|
-
import { commandNotFound,
|
|
6
|
-
import { CLI_AGENT_IDENTITY
|
|
7
|
-
import { installRoot } from "./install-root.js";
|
|
7
|
+
import { commandNotFound, spawnStepError, throwIfInterrupted } from "./connect-runtime.js";
|
|
8
|
+
import { CLI_AGENT_IDENTITY } from "./constants.js";
|
|
8
9
|
import { errLine } from "./output.js";
|
|
9
|
-
export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
|
10
|
-
export const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
|
|
11
|
-
const TINYFISH_CLI_INSTALL_SPEC = `${TINYFISH_CLI_PACKAGE}@latest`;
|
|
12
|
-
export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
|
|
13
10
|
// Supports Hermes without node:util.styleText, so the installer still runs on Node 20.11.
|
|
11
|
+
// A bump must re-verify SKILL_AGENT_DISPLAY_NAMES against `skills list --json`.
|
|
14
12
|
const SKILLS_CLI_PACKAGE = "skills@1.5.15";
|
|
15
13
|
const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
|
|
16
14
|
const TINYFISH_WEB_SKILL = "use-tinyfish";
|
|
17
|
-
// spawnSync caps piped output at 1 MiB by default.
|
|
18
|
-
const STEP_MAX_BUFFER = 10 * 1024 * 1024;
|
|
19
|
-
function captureStdio(verbose) {
|
|
20
|
-
return verbose
|
|
21
|
-
? { stdio: "inherit", timeout: SKILL_INSTALL_TIMEOUT_MS }
|
|
22
|
-
: { encoding: "utf8", maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS };
|
|
23
|
-
}
|
|
24
|
-
/** Inherited stdio leaves these null; only piped runs yield text. */
|
|
25
|
-
function capturedOutput(result) {
|
|
26
|
-
return `${asText(result.stdout)}${asText(result.stderr)}`;
|
|
27
|
-
}
|
|
28
|
-
function asText(stream) {
|
|
29
|
-
return typeof stream === "string" ? stream : "";
|
|
30
|
-
}
|
|
31
|
-
/** A verbose run captured nothing, so this is a no-op. */
|
|
32
|
-
function replay(output) {
|
|
33
|
-
if (output.trim())
|
|
34
|
-
errLine(output.trimEnd());
|
|
35
|
-
}
|
|
36
|
-
export function installTinyFishCli({ verbose }) {
|
|
37
|
-
if (verbose)
|
|
38
|
-
errLine("Installing the TinyFish CLI...");
|
|
39
|
-
// Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
|
|
40
|
-
const root = installRoot();
|
|
41
|
-
const prefixArgs = root ? ["--prefix", root.prefix] : [];
|
|
42
|
-
const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], captureStdio(verbose));
|
|
43
|
-
if (result.error || result.status !== 0) {
|
|
44
|
-
// Built first: it runs the interrupt check, and abandonment must not replay.
|
|
45
|
-
const error = spawnStepError("Could not install the TinyFish CLI", result);
|
|
46
|
-
replay(capturedOutput(result));
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
15
|
/** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
|
|
51
16
|
function skillAddArgs(skillAgents) {
|
|
52
17
|
return [
|
|
@@ -80,25 +45,57 @@ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
|
|
|
80
45
|
// A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
|
|
81
46
|
// than failed. Left undetected that reads as "up to date" while nothing was refreshed.
|
|
82
47
|
const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
|
|
83
|
-
// `skills
|
|
84
|
-
const
|
|
48
|
+
// `skills list --json` reports display names, never slugs (verified on 1.5.15).
|
|
49
|
+
const SKILL_AGENT_DISPLAY_NAMES = {
|
|
50
|
+
"claude-code": "Claude Code",
|
|
51
|
+
codex: "Codex",
|
|
52
|
+
cursor: "Cursor",
|
|
53
|
+
"hermes-agent": "Hermes Agent",
|
|
54
|
+
opencode: "OpenCode",
|
|
55
|
+
};
|
|
56
|
+
const skillListSchema = z.array(z.object({ name: z.string(), agents: z.array(z.string()) }));
|
|
57
|
+
// `add` exits 0 on per-skill failure; the piped list is the verdict.
|
|
58
|
+
function failedListVerdict(skillAgents) {
|
|
59
|
+
const list = spawn.sync("npx", ["-y", SKILLS_CLI_PACKAGE, "list", "--global", "--json"], {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
env: skillSpawnEnv(),
|
|
62
|
+
maxBuffer: STEP_MAX_BUFFER,
|
|
63
|
+
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
64
|
+
});
|
|
65
|
+
const verified = !list.error && list.status === 0 && verifiedForAgents(list.stdout ?? "", skillAgents);
|
|
66
|
+
return verified ? null : list;
|
|
67
|
+
}
|
|
68
|
+
// `list` folds in every agent's dir, so name-only matches stale copies.
|
|
69
|
+
function verifiedForAgents(stdout, skillAgents) {
|
|
70
|
+
const parsed = skillListSchema.safeParse(parseJson(stdout));
|
|
71
|
+
if (!parsed.success)
|
|
72
|
+
return false;
|
|
73
|
+
const agents = parsed.data
|
|
74
|
+
.filter((skill) => skill.name === TINYFISH_WEB_SKILL)
|
|
75
|
+
.flatMap((skill) => skill.agents);
|
|
76
|
+
return skillAgents.every((agent) => agents.includes(SKILL_AGENT_DISPLAY_NAMES[agent]));
|
|
77
|
+
}
|
|
78
|
+
function parseJson(text) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(text);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
85
86
|
export function installWebSkill(client) {
|
|
86
87
|
if (!client.skillAgent)
|
|
87
88
|
return;
|
|
88
89
|
errLine(`Installing the TinyFish web skill in ${client.displayName}...`);
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
encoding: "utf8",
|
|
90
|
+
// Inherited stdio renders the spinner live as one overwriting line.
|
|
91
|
+
const add = spawn.sync("npx", skillAddArgs([client.skillAgent]), {
|
|
92
|
+
stdio: "inherit",
|
|
93
93
|
env: skillSpawnEnv(),
|
|
94
|
-
maxBuffer: STEP_MAX_BUFFER,
|
|
95
94
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
96
95
|
});
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
|
|
101
|
-
throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
|
|
96
|
+
const failed = add.error || add.status !== 0 ? add : failedListVerdict([client.skillAgent]);
|
|
97
|
+
if (failed) {
|
|
98
|
+
throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed);
|
|
102
99
|
}
|
|
103
100
|
}
|
|
104
101
|
/**
|
|
@@ -163,13 +160,19 @@ function reinstallWebSkill(skillAgents, verbose) {
|
|
|
163
160
|
timeout: SKILL_INSTALL_TIMEOUT_MS,
|
|
164
161
|
});
|
|
165
162
|
const output = capturedOutput(result);
|
|
166
|
-
|
|
163
|
+
// Quiet capture stays (#4434); the list verdict replaced prose matching.
|
|
164
|
+
const addFailed = Boolean(result.error) || result.status !== 0;
|
|
165
|
+
const failedList = addFailed ? null : failedListVerdict(skillAgents);
|
|
166
|
+
const failed = addFailed || failedList !== null;
|
|
167
167
|
if (failed)
|
|
168
|
-
throwIfInterrupted(result);
|
|
168
|
+
throwIfInterrupted(failedList ?? result);
|
|
169
169
|
if (verbose || failed)
|
|
170
170
|
replay(output);
|
|
171
|
-
if (failed)
|
|
172
|
-
throw new Error("Could not refresh the TinyFish web skill", {
|
|
171
|
+
if (failed) {
|
|
172
|
+
throw new Error("Could not refresh the TinyFish web skill", {
|
|
173
|
+
cause: (failedList ?? result).error,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
173
176
|
}
|
|
174
177
|
/** Fallback when no skill-bearing harness is recorded: refresh whatever `skills` tracks. */
|
|
175
178
|
function refreshWebSkillByHash(verbose, untrackedIsExpected = false) {
|
|
@@ -203,40 +206,3 @@ function refreshWebSkillByHash(verbose, untrackedIsExpected = false) {
|
|
|
203
206
|
}
|
|
204
207
|
return !SKILL_ALREADY_CURRENT_PATTERN.test(output);
|
|
205
208
|
}
|
|
206
|
-
export function ensureCliAuthenticated(source, apiKey) {
|
|
207
|
-
const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
|
|
208
|
-
if (envKey) {
|
|
209
|
-
if (!validateKeyFormat(envKey)) {
|
|
210
|
-
throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
|
|
211
|
-
}
|
|
212
|
-
// Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
|
|
213
|
-
writeConfig(envKey);
|
|
214
|
-
persistApiKeyToEnvironment(envKey);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const authStatus = spawn.sync("tinyfish", ["auth", "status"], {
|
|
218
|
-
stdio: "ignore",
|
|
219
|
-
timeout: NON_INTERACTIVE_TIMEOUT_MS,
|
|
220
|
-
});
|
|
221
|
-
if (commandNotFound(authStatus.error)) {
|
|
222
|
-
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
|
|
223
|
-
}
|
|
224
|
-
if (authStatus.status === 0)
|
|
225
|
-
return;
|
|
226
|
-
// A crashed or timed-out probe says nothing about the stored key, so it must not read as
|
|
227
|
-
// "not signed in" and drop the user into an interactive login they never asked for.
|
|
228
|
-
throwIfInterrupted(authStatus);
|
|
229
|
-
if (authStatus.error) {
|
|
230
|
-
throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
|
|
231
|
-
}
|
|
232
|
-
errLine("Signing in to the TinyFish CLI...");
|
|
233
|
-
const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
|
|
234
|
-
stdio: "inherit",
|
|
235
|
-
});
|
|
236
|
-
if (commandNotFound(loginResult.error)) {
|
|
237
|
-
throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
|
|
238
|
-
}
|
|
239
|
-
if (loginResult.error || loginResult.status !== 0) {
|
|
240
|
-
throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
|
|
241
|
-
}
|
|
242
|
-
}
|