@tiny-fish/cli 0.24.1-next.208 → 0.24.1-next.211

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.
@@ -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,10 @@ 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
+ /** Set by `--all`: the CLI install already ran once, upfront. */
13
+ hoistedCliInstall?: TakeHoistedCliInstall;
14
+ /** Lets `--all` warn in its summary instead of all-green. */
15
+ onPostInstallFailed?: () => void;
11
16
  }
12
17
  export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
13
18
  export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
@@ -20,6 +25,8 @@ export declare function connectOpenClaw(options: {
20
25
  launch: boolean;
21
26
  installCli?: boolean;
22
27
  attemptId?: string;
28
+ /** Set by `--all`: the CLI install already ran once, upfront. */
29
+ hoistedCliInstall?: TakeHoistedCliInstall;
23
30
  }): Promise<void>;
24
31
  /** Writes mcp.json directly — no `cursor mcp add` exists. */
25
32
  export declare function connectCursor(options: {
@@ -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 { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
5
- import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
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,95 @@ 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: failures warn and report post_install_failed, exit 0.
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
- state.stage = "cli_install";
150
- // Verbose on purpose: first-run setup must not go silent.
151
- installTinyFishCli({ verbose: true });
152
- telemetry.track("checkpoint", { phase: "cli_installed" });
165
+ attempt("cli_install", () => runCliInstallStep(options, telemetry));
153
166
  if (client.skillAgent) {
154
- state.stage = "skill_install";
155
- installWebSkill(client);
156
- telemetry.track("checkpoint", { phase: "skill_installed" });
167
+ attempt("skill_install", () => {
168
+ installWebSkill(client);
169
+ telemetry.track("checkpoint", { phase: "skill_installed" });
170
+ });
157
171
  }
158
- state.stage = "authentication";
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.
172
+ // Before auth: a failed sign-in must not lose the harness record.
163
173
  saveConnectContext(client.connectClient, telemetry.attemptId);
174
+ attempt("authentication", () => {
175
+ ensureCliAuthenticated(client.connectClient, options.apiKey);
176
+ telemetry.track("checkpoint", { phase: "authenticated" });
177
+ });
164
178
  errLine(UPGRADE_HINT);
165
179
  if (client.postConnectNote)
166
180
  errLine(client.postConnectNote);
167
181
  if (!options.launch) {
168
182
  errLine(connectedLine(client, signInDeferred));
169
- return;
170
183
  }
171
- state.stage = "walkthrough_launch";
172
- // Before the handover, or the only sign-in instructions scroll past under the agent.
173
- if (signInDeferred && client.signInHint)
174
- errLine(client.signInHint);
175
- launchNativeMcpClient(client);
184
+ else {
185
+ attempt("walkthrough_launch", () => {
186
+ // Before the handover, or the only sign-in instructions scroll past under the agent.
187
+ if (signInDeferred && client.signInHint)
188
+ errLine(client.signInHint);
189
+ launchNativeMcpClient(client);
190
+ });
191
+ }
176
192
  }
177
193
  catch (error) {
178
- reportPostInstallFailure(client.connectClient, client.displayName, state, telemetry, error);
194
+ // Interrupts stop quietly; anything else escapes to runGuarded.
195
+ if (!(error instanceof ConnectInterruptedError))
196
+ throw error;
197
+ failed = true;
198
+ }
199
+ if (failed) {
200
+ errLine(finishSetupHint(client.connectClient));
201
+ options.onPostInstallFailed?.();
202
+ }
203
+ }
204
+ /** Consumes the `--all` hoist when present; installs inline otherwise. */
205
+ function runCliInstallStep(options, telemetry) {
206
+ if (options.hoistedCliInstall) {
207
+ const { outcome, firstTake } = options.hoistedCliInstall();
208
+ if (!outcome.ok) {
209
+ // Late takers fail too; only the first reports the install.
210
+ throw firstTake ? outcome.error : new HoistedFailureReportedError(outcome.error.message);
211
+ }
212
+ if (firstTake) {
213
+ telemetry.track("checkpoint", { phase: "cli_installed", stageDurationMs: outcome.durationMs });
214
+ }
215
+ return;
179
216
  }
217
+ // Verbose on purpose: first-run setup must not go silent.
218
+ installTinyFishCli({ verbose: true });
219
+ telemetry.track("checkpoint", { phase: "cli_installed" });
220
+ }
221
+ function finishSetupHint(connectClient) {
222
+ return `Finish setup with: ${finishSetupCommand(connectClient)}`;
180
223
  }
181
224
  /** Warn + report post_install_failed; interrupts are abandonment, not failure. */
182
- function reportPostInstallFailure(connectClient, displayName, state, telemetry, error) {
183
- if (!(error instanceof ConnectInterruptedError)) {
225
+ function trackPostInstallFailure(displayName, state, telemetry, error) {
226
+ if (error instanceof ConnectInterruptedError)
227
+ return;
228
+ // An earlier harness already owns the shared install's failure record.
229
+ if (!(error instanceof HoistedFailureReportedError)) {
184
230
  telemetry.track("post_install_failed", {
185
231
  failedStage: state.stage,
186
232
  ...(error instanceof ConnectStepError ? { failureReason: error.failureReason } : {}),
187
233
  });
188
- errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
189
- `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
190
234
  }
191
- errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
235
+ errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
236
+ `(${state.stage}) failed: ${error instanceof Error ? error.message : String(error)}`);
192
237
  }
193
238
  export async function connectClaudeCode(options) {
194
239
  return connectNativeMcpClient(CLAUDE_CODE, options);
@@ -217,7 +262,20 @@ export async function connectOpenClaw(options) {
217
262
  telemetry.track("started");
218
263
  telemetry.setHarnessVersion(requireCommandSupport(OPENCLAW).harnessVersion);
219
264
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
220
- if (options.installCli !== false) {
265
+ if (options.hoistedCliInstall) {
266
+ state.stage = "cli_install";
267
+ const { outcome, firstTake } = options.hoistedCliInstall();
268
+ // A carried failure fails openclaw exactly like the inline install.
269
+ if (!outcome.ok)
270
+ throw outcome.error;
271
+ if (firstTake) {
272
+ telemetry.track("checkpoint", {
273
+ phase: "cli_installed",
274
+ stageDurationMs: outcome.durationMs,
275
+ });
276
+ }
277
+ }
278
+ else if (options.installCli !== false) {
221
279
  state.stage = "cli_install";
222
280
  installTinyFishCli({ verbose: true });
223
281
  telemetry.track("checkpoint", { phase: "cli_installed" });
@@ -249,7 +307,8 @@ export async function connectOpenClaw(options) {
249
307
  launchOpenClawWalkthrough();
250
308
  }
251
309
  catch (error) {
252
- reportPostInstallFailure("openclaw", "OpenClaw", state, telemetry, error);
310
+ trackPostInstallFailure("OpenClaw", state, telemetry, error);
311
+ errLine(finishSetupHint("openclaw"));
253
312
  }
254
313
  });
255
314
  }
@@ -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, updateWebSkill } from "../lib/connect-install.js";
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.";
@@ -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
+ }
@@ -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, installTinyFishCli, TINYFISH_CLI_NOT_FOUND_MESSAGE, } from "./connect-install.js";
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 = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false, keyAuthOnly };
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({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false });
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 = lastTrackAt === undefined ? undefined : Math.max(0, now - lastTrackAt);
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.
@@ -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 { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
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, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
6
- import { CLI_AGENT_IDENTITY, TINYFISH_CLI_PACKAGE } from "./constants.js";
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 add` exits non-zero on a fatal error, but per-skill install failures only print.
84
- const SKILL_INSTALL_FAILURE_PATTERN = /Failed to install/;
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
- // Piped rather than inherited so the per-skill failure line can be matched; echoed back
90
- // so the user still sees what `skills` reported.
91
- const result = spawn.sync("npx", skillAddArgs([client.skillAgent]), {
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 output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
98
- if (output.trim())
99
- errLine(output.trimEnd());
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
- const failed = Boolean(result.error) || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output);
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", { cause: result.error });
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.24.1-next.208",
3
+ "version": "0.24.1-next.211",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {