@tiny-fish/cli 0.25.2-next.220 → 0.25.2-next.222

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 CHANGED
@@ -10,6 +10,25 @@ npm install -g @tiny-fish/cli
10
10
 
11
11
  Requires Node.js 24+.
12
12
 
13
+ ### Connect your agents
14
+
15
+ The recommended way in: run `connect` with no arguments.
16
+
17
+ ```bash
18
+ npx -y @tiny-fish/cli@latest connect
19
+ ```
20
+
21
+ It detects the agent configs on your machine (Claude Code, Codex, Cursor, Grok, Hermes,
22
+ OpenClaw, OpenCode) and opens a checklist with everything pre-selected. Press enter to connect
23
+ them all, or use the arrow keys and space to narrow the set first; `q` cancels without touching
24
+ anything. Terminals without raw-key support get a numbered prompt instead.
25
+
26
+ Without a terminal (agents, CI), bare `connect` behaves exactly like `tinyfish connect --all`
27
+ and says so on its first line: every detected harness that can authenticate headlessly (via
28
+ `TINYFISH_API_KEY` or `--api-key`) is connected, the rest are skipped with a fix command.
29
+ `tinyfish connect --all` itself is unchanged and skips the picker; `--dry-run`, `--uninstall`
30
+ and `--skip-launch` still require it.
31
+
13
32
  ### Connect Claude Code
14
33
 
15
34
  Add TinyFish MCP and the global `use-tinyfish` web skill, authenticate the CLI, and start the
@@ -63,11 +63,12 @@ async function connectNativeMcpClient(client, options) {
63
63
  const useKeyAuth = !!keyedAdd;
64
64
  if (options.keyAuthOnly && !useKeyAuth) {
65
65
  // No keyAuth at all is structural (Hermes); a missing flag is just an old install.
66
- throw new Error(client.keyAuth
67
- ? `This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
68
- `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`
69
- : `${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
70
- `terminal. Run: tinyfish connect ${client.connectClient}`);
66
+ if (client.keyAuth) {
67
+ throw new ConnectStepError(`This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
68
+ `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`, "harness_too_old");
69
+ }
70
+ throw new Error(`${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
71
+ `terminal. Run: tinyfish connect ${client.connectClient}`);
71
72
  }
72
73
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
73
74
  state.stage = "registration_cleanup";
@@ -417,7 +418,7 @@ export function registerConnect(program) {
417
418
  program
418
419
  .command("connect")
419
420
  .description("Connect TinyFish to an AI agent")
420
- .argument("[client]", "Agent client to connect (claude-code, codex, cursor, grok, hermes, openclaw, or opencode)")
421
+ .argument("[client]", "Agent client to connect (claude-code, codex, cursor, grok, hermes, openclaw, or opencode); omit to pick from the agents detected on this machine")
421
422
  .option("--all", "Detect and connect every supported harness found on this machine")
422
423
  .option("--dry-run", "Print planned writes per harness without touching anything (--all only)")
423
424
  .option("--uninstall", "Remove TinyFish entries written by connect --all")
@@ -476,7 +477,24 @@ export function registerConnect(program) {
476
477
  throw new Error("--dry-run, --uninstall and --skip-launch are only supported with --all.");
477
478
  }
478
479
  if (!client) {
479
- throw new Error("Pass a client (claude-code, codex, cursor, grok, hermes, openclaw, opencode) or --all.");
480
+ if (options.launch) {
481
+ throw new Error("--launch is not supported with --all. Connect a single client to launch it.");
482
+ }
483
+ const humanInitiated = detectHumanInitiated();
484
+ if (!humanInitiated) {
485
+ errLine("No terminal detected: connecting every detected harness (same as tinyfish connect --all).");
486
+ }
487
+ const exitCode = await runConnectAll({
488
+ apiKey: options.apiKey,
489
+ mcpUrl,
490
+ dryRun: false,
491
+ uninstall: false,
492
+ pick: humanInitiated,
493
+ });
494
+ // Preserve the 130 runGuarded set on Ctrl+C.
495
+ if (!process.exitCode)
496
+ process.exitCode = exitCode;
497
+ return;
480
498
  }
481
499
  const connectOptions = {
482
500
  apiKey: options.apiKey,
@@ -5,5 +5,7 @@ export interface ConnectAllOptions {
5
5
  uninstall: boolean;
6
6
  /** Skip the end-of-install agent launch (finale still prints the ask). */
7
7
  noLaunch?: boolean;
8
+ /** Bare connect: offer the checkbox picker over the detected harnesses. */
9
+ pick?: boolean;
8
10
  }
9
11
  export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
@@ -1,30 +1,27 @@
1
1
  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
- import { validatedApiKey } from "./auth.js";
4
+ import { loadConfig, validatedApiKey } from "./auth.js";
5
5
  import { installTinyFishCli, } from "./cli-install.js";
6
6
  import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
7
7
  import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
8
8
  import { commandNotFound, ConnectInterruptedError, ConnectStepError, finishSetupCommand, throwIfInterrupted, } from "./connect-runtime.js";
9
- import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, ALL_HARNESSES, } from "./harness-detect.js";
9
+ import { pickHarnesses } from "./connect-picker.js";
10
+ import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from "./harness-detect.js";
10
11
  import { detectHumanInitiated } from "./harness.js";
11
12
  import { emitNotice } from "./notice.js";
12
13
  import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
13
14
  import { errLine, outLine } from "./output.js";
14
15
  import { sendSetupCompleted } from "./setup-telemetry.js";
15
16
  import { verifyMcpAuth, verifyMcpHealth } from "./verify.js";
16
- const DISPLAY_NAMES = {
17
- "claude-code": "Claude Code",
18
- codex: "Codex",
19
- cursor: "Cursor",
20
- grok: "Grok",
21
- hermes: "Hermes",
22
- openclaw: "OpenClaw",
23
- opencode: "OpenCode",
24
- };
25
17
  // `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
26
18
  // TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
27
19
  const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
20
+ // A probe that exits non-zero is an install so old the subcommand is missing entirely.
21
+ const TOO_OLD_REASONS = new Set([
22
+ "harness_too_old",
23
+ "harness_command_unsupported",
24
+ ]);
28
25
  const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
29
26
  // Descriptor-derived so a harness that cannot carry a key is skipped, not failed, when headless.
30
27
  // Cursor and OpenClaw hold the key themselves rather than handing it to a harness `mcp add`.
@@ -194,6 +191,15 @@ async function processHarness(detection, opts, prompt, hoistedCliInstall) {
194
191
  });
195
192
  }
196
193
  catch (e) {
194
+ // Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
195
+ if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
196
+ return {
197
+ ...base,
198
+ outcome: "harness_too_old",
199
+ errorMessage: e.message,
200
+ fixCommand: `tinyfish connect ${harness}`,
201
+ };
202
+ }
197
203
  return {
198
204
  ...base,
199
205
  outcome: "failed",
@@ -241,6 +247,9 @@ function summaryLine(result, uninstall) {
241
247
  : "no stored credentials and no terminal to sign in";
242
248
  return `… ${name} — skipped, ${why}. Fix: ${result.fixCommand}`;
243
249
  }
250
+ if (result.outcome === "deselected") {
251
+ return `… ${name} — skipped, not selected. Fix: ${result.fixCommand}`;
252
+ }
244
253
  if (result.outcome === "uninstall_pointer") {
245
254
  return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
246
255
  }
@@ -255,6 +264,9 @@ function summaryLine(result, uninstall) {
255
264
  if (result.outcome === "interrupted") {
256
265
  return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
257
266
  }
267
+ if (result.outcome === "harness_too_old") {
268
+ return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
269
+ }
258
270
  if (result.outcome === "failed") {
259
271
  return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
260
272
  }
@@ -288,7 +300,10 @@ function computeExitCode(results) {
288
300
  if (detected.length === 0)
289
301
  return 1;
290
302
  // Only real install attempts count toward the success/failure denominator.
291
- const attempted = detected.filter((r) => r.outcome === "installed" || r.outcome === "failed" || r.outcome === "interrupted");
303
+ const attempted = detected.filter((r) => r.outcome === "installed" ||
304
+ r.outcome === "failed" ||
305
+ r.outcome === "harness_too_old" ||
306
+ r.outcome === "interrupted");
292
307
  if (attempted.length === 0)
293
308
  return 0;
294
309
  return attempted.some((r) => r.outcome === "installed") ? 0 : 1;
@@ -384,9 +399,26 @@ export async function runConnectAll(opts) {
384
399
  }
385
400
  : undefined;
386
401
  const detections = detectInstalledHarnesses();
402
+ let deselected = new Set();
403
+ let mode = "all";
404
+ // Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
405
+ // A stale config dir has no binary to connect, so never offer it as a choice.
406
+ const offerable = detections.filter((d) => d.detected && !isStaleConfig(d));
407
+ if (opts.pick && offerable.length > 0) {
408
+ const connected = new Set(Object.keys(loadConfig().connect ?? {}));
409
+ const pick = await pickHarnesses(offerable, connected, { prompt });
410
+ mode = pick.mode;
411
+ if (pick.selection === "cancel") {
412
+ // Before summary and telemetry: an explicit nothing is not a setup.
413
+ outLine("Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>");
414
+ return 0;
415
+ }
416
+ const chosen = new Set(pick.selection);
417
+ deselected = new Set(offerable.filter((d) => !chosen.has(d.harness)).map((d) => d.harness));
418
+ }
387
419
  const gateKey = validatedApiKey(opts.apiKey);
388
420
  // Mirrors processHarness's skip gate: a skipped harness installs nothing today.
389
- const willAttempt = (harness) => isTTY || (!!gateKey && KEY_AUTH_CAPABLE.has(harness));
421
+ const willAttempt = (harness) => !deselected.has(harness) && (isTTY || (!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
390
422
  // Machine-global, so once per invocation; cursor's connect never installs it.
391
423
  const cliNeeded = !opts.dryRun &&
392
424
  !opts.uninstall &&
@@ -408,9 +440,21 @@ export async function runConnectAll(opts) {
408
440
  };
409
441
  }
410
442
  const results = [];
411
- for (const [index, detection] of detections.entries()) {
443
+ const stepTotal = detections.length - deselected.size;
444
+ let step = 0;
445
+ for (const detection of detections) {
412
446
  const { harness } = detection;
413
- errLine(`step ${index + 1} of ${detections.length} — ${DISPLAY_NAMES[harness]}`);
447
+ if (deselected.has(harness)) {
448
+ results.push({
449
+ ...detection,
450
+ installed: false,
451
+ outcome: "deselected",
452
+ fixCommand: `tinyfish connect ${harness}`,
453
+ });
454
+ continue;
455
+ }
456
+ step += 1;
457
+ errLine(`step ${step} of ${stepTotal} — ${DISPLAY_NAMES[harness]}`);
414
458
  let result;
415
459
  try {
416
460
  result = await processHarness(detection, opts, prompt, takeHoistedCli);
@@ -432,7 +476,7 @@ export async function runConnectAll(opts) {
432
476
  if (result.outcome === "interrupted")
433
477
  break;
434
478
  }
435
- // The interrupt break truncates `results`; pad so the 5-entry telemetry schema still parses.
479
+ // The interrupt break truncates `results`; pad so the one-row-per-harness schema still parses.
436
480
  for (const detection of detections) {
437
481
  if (results.some((r) => r.harness === detection.harness))
438
482
  continue;
@@ -483,7 +527,7 @@ export async function runConnectAll(opts) {
483
527
  verify_depth: r.verifyDepth ?? null,
484
528
  verify_ok: r.verifyOk ?? null,
485
529
  }));
486
- await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey));
530
+ await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode);
487
531
  // After the summary and before the launch, which is where the user is reading.
488
532
  emitNotice();
489
533
  }
@@ -0,0 +1,16 @@
1
+ import type { Readable } from "node:stream";
2
+ import { type Harness, type HarnessDetection } from "./harness-detect.js";
3
+ export type PickSelection = Harness[] | "cancel";
4
+ export interface PickResult {
5
+ selection: PickSelection;
6
+ mode: "pick" | "pick_numeric";
7
+ }
8
+ export type PickerStdin = Readable & {
9
+ setRawMode?: (raw: boolean) => unknown;
10
+ };
11
+ export interface PickerIo {
12
+ stdin?: PickerStdin;
13
+ write?: (text: string) => void;
14
+ prompt?: (question: string) => Promise<string>;
15
+ }
16
+ export declare function pickHarnesses(detections: HarnessDetection[], connectedBefore: ReadonlySet<string>, io?: PickerIo): Promise<PickResult>;
@@ -0,0 +1,136 @@
1
+ import * as readline from "node:readline";
2
+ import { HARNESS_DISPLAY_NAMES } from "./harness-detect.js";
3
+ const KEY_HELP = " ↑↓ move space toggle one a none/all q cancel";
4
+ function header(count) {
5
+ return `Found ${count} agent config${count === 1 ? "" : "s"} on this machine.\n\n`;
6
+ }
7
+ function connectedNote(item) {
8
+ return item.connectedBefore ? " (previously connected)" : "";
9
+ }
10
+ function enterHint(checkedCount, total) {
11
+ if (checkedCount === 0)
12
+ return "press enter to cancel";
13
+ if (checkedCount === total)
14
+ return `press enter to connect all ${total}`;
15
+ return `press enter to connect ${checkedCount} of ${total}`;
16
+ }
17
+ function frame(items, checked, cursor) {
18
+ const rows = items.map((item, i) => ` ${i === cursor ? ">" : " "} [${checked[i] ? "x" : " "}] ${item.name}${connectedNote(item)}`);
19
+ const count = checked.filter(Boolean).length;
20
+ return [...rows, "", ` ${enterHint(count, items.length)}`, KEY_HELP, ""].join("\n");
21
+ }
22
+ async function pickWithKeys(items, stdin, write) {
23
+ const checked = items.map(() => true);
24
+ let cursor = 0;
25
+ let painted = 0;
26
+ const paint = () => {
27
+ const text = frame(items, checked, cursor);
28
+ write((painted > 0 ? `\u001b[${painted}A\u001b[0J` : "") + text);
29
+ painted = items.length + 3;
30
+ };
31
+ readline.emitKeypressEvents(stdin);
32
+ stdin.setRawMode(true);
33
+ stdin.resume();
34
+ let onKeypress;
35
+ try {
36
+ write(header(items.length));
37
+ paint();
38
+ return await new Promise((resolve, reject) => {
39
+ onKeypress = (_chunk, key = {}) => {
40
+ try {
41
+ const name = key.name ?? "";
42
+ // Raw mode eats terminal SIGINT, so Ctrl+C arrives only here.
43
+ if ((key.ctrl && (name === "c" || name === "d")) || name === "q" || name === "escape") {
44
+ resolve("cancel");
45
+ return;
46
+ }
47
+ if (name === "return" || name === "enter") {
48
+ const picked = items.filter((_, i) => checked[i]).map((item) => item.harness);
49
+ resolve(picked.length > 0 ? picked : "cancel");
50
+ return;
51
+ }
52
+ if (name === "up" || name === "k")
53
+ cursor = (cursor + items.length - 1) % items.length;
54
+ else if (name === "down" || name === "j")
55
+ cursor = (cursor + 1) % items.length;
56
+ else if (name === "space")
57
+ checked[cursor] = !checked[cursor];
58
+ else if (name === "a")
59
+ checked.fill(!checked.every(Boolean));
60
+ paint();
61
+ }
62
+ catch (error) {
63
+ reject(error instanceof Error ? error : new Error(String(error)));
64
+ }
65
+ };
66
+ stdin.on("keypress", onKeypress);
67
+ });
68
+ }
69
+ finally {
70
+ // Restore before any child spawn can inherit raw stdin (PF-3635).
71
+ if (onKeypress)
72
+ stdin.removeListener("keypress", onKeypress);
73
+ stdin.setRawMode(false);
74
+ stdin.pause();
75
+ }
76
+ }
77
+ function numberedRows(items) {
78
+ return (items.map((item, i) => ` [${i + 1}] ${item.name}${connectedNote(item)}`).join("\n") + "\n");
79
+ }
80
+ function parseSubset(answer, items) {
81
+ const tokens = answer.split(/[\s,]+/).filter(Boolean);
82
+ if (tokens.length === 0)
83
+ return undefined;
84
+ const picked = new Set();
85
+ for (const token of tokens) {
86
+ if (!/^\d+$/.test(token))
87
+ return undefined;
88
+ const index = Number.parseInt(token, 10);
89
+ if (index < 1 || index > items.length)
90
+ return undefined;
91
+ picked.add(index - 1);
92
+ }
93
+ return items.filter((_, i) => picked.has(i)).map((item) => item.harness);
94
+ }
95
+ async function pickWithNumbers(items, prompt, write) {
96
+ write(header(items.length) + numberedRows(items));
97
+ const question = `Press enter to connect all ${items.length}, numbers for a subset (e.g. 1,3), or n to cancel: `;
98
+ // One re-prompt on garbage, then cancel; never a loop.
99
+ for (let attempt = 0; attempt < 2; attempt += 1) {
100
+ let answer;
101
+ try {
102
+ answer = (await prompt(question)).trim();
103
+ }
104
+ catch {
105
+ // Closed stdin: every later question would throw too.
106
+ return "cancel";
107
+ }
108
+ if (answer === "")
109
+ return items.map((item) => item.harness);
110
+ if (/^[nq]$/i.test(answer))
111
+ return "cancel";
112
+ const subset = parseSubset(answer, items);
113
+ if (subset)
114
+ return subset;
115
+ }
116
+ return "cancel";
117
+ }
118
+ export async function pickHarnesses(detections, connectedBefore, io = {}) {
119
+ const items = detections
120
+ .filter((detection) => detection.detected)
121
+ .map((detection) => ({
122
+ harness: detection.harness,
123
+ name: HARNESS_DISPLAY_NAMES[detection.harness],
124
+ connectedBefore: connectedBefore.has(detection.harness),
125
+ }));
126
+ const stdin = io.stdin ?? process.stdin;
127
+ const write = io.write ?? ((text) => void process.stderr.write(text));
128
+ if (typeof stdin.setRawMode !== "function") {
129
+ // No prompt means nothing to ask; keep --all semantics.
130
+ const selection = io.prompt
131
+ ? await pickWithNumbers(items, io.prompt, write)
132
+ : items.map((item) => item.harness);
133
+ return { selection, mode: "pick_numeric" };
134
+ }
135
+ return { selection: await pickWithKeys(items, stdin, write), mode: "pick" };
136
+ }
@@ -4,7 +4,7 @@ export type AgentClient = "claude-code" | "codex" | "cursor" | "grok" | "hermes"
4
4
  type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
5
5
  type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
6
6
  export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
7
- type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
7
+ export type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
8
8
  export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
9
9
  type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
10
10
  /** Ctrl+C killed a setup child — abandonment, not error. */
@@ -1,5 +1,6 @@
1
1
  export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
2
2
  export type Harness = (typeof ALL_HARNESSES)[number];
3
+ export declare const HARNESS_DISPLAY_NAMES: Record<Harness, string>;
3
4
  export declare enum Registered {
4
5
  Yes = "yes",
5
6
  No = "no",
@@ -11,6 +11,15 @@ export const ALL_HARNESSES = [
11
11
  "openclaw",
12
12
  "opencode",
13
13
  ];
14
+ export const HARNESS_DISPLAY_NAMES = {
15
+ "claude-code": "Claude Code",
16
+ codex: "Codex",
17
+ cursor: "Cursor",
18
+ grok: "Grok",
19
+ hermes: "Hermes",
20
+ openclaw: "OpenClaw",
21
+ opencode: "OpenCode",
22
+ };
14
23
  // Values are the wire contract; the report schema derives from these.
15
24
  export var Registered;
16
25
  (function (Registered) {
@@ -60,13 +60,19 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
60
60
  verify_ok: z.ZodNullable<z.ZodBoolean>;
61
61
  }, z.core.$strip>>;
62
62
  cli_version: z.ZodString;
63
+ mode: z.ZodOptional<z.ZodEnum<{
64
+ all: "all";
65
+ pick: "pick";
66
+ pick_numeric: "pick_numeric";
67
+ }>>;
63
68
  }, z.core.$strip>;
64
69
  export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
65
70
  export type HarnessResult = z.infer<typeof harnessResultSchema>;
71
+ export type SetupMode = NonNullable<SetupCompletedPayload["mode"]>;
66
72
  export type UpgradeOutcome = "updated" | "failed" | "interrupted";
67
73
  /** Step selection; "updated" means every selected step ran. */
68
74
  export type UpgradeScope = "both" | "cli" | "skill";
69
75
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
70
76
  export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
71
- export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
77
+ export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode): Promise<void>;
72
78
  export {};
@@ -109,6 +109,8 @@ export const setupCompletedPayloadSchema = z.object({
109
109
  message: "expected one result per harness",
110
110
  }),
111
111
  cli_version: z.string(),
112
+ // Segments picker runs from --all runs; the server field shipped first.
113
+ mode: z.enum(["all", "pick", "pick_numeric"]).optional(),
112
114
  });
113
115
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
114
116
  export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scope, apiKey) {
@@ -126,7 +128,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scop
126
128
  apiKey,
127
129
  });
128
130
  }
129
- export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
131
+ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
130
132
  // Ahead of the parse so an opt-out run prints no telemetry debug.
131
133
  if (telemetryDisabled())
132
134
  return;
@@ -134,7 +136,11 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
134
136
  let body;
135
137
  try {
136
138
  // Parse inside try — schema violation must not crash finished setup.
137
- const payload = setupCompletedPayloadSchema.parse({ harnesses, cli_version: CLI_VERSION });
139
+ const payload = setupCompletedPayloadSchema.parse({
140
+ harnesses,
141
+ cli_version: CLI_VERSION,
142
+ ...(mode ? { mode } : {}),
143
+ });
138
144
  endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
139
145
  body = JSON.stringify({ event: "setup_completed", ...payload });
140
146
  }
@@ -8,7 +8,6 @@ import { commandNotFound, spawnStepError, throwIfInterrupted } from "./connect-r
8
8
  import { CLI_AGENT_IDENTITY } from "./constants.js";
9
9
  import { errLine } from "./output.js";
10
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`.
12
11
  const SKILLS_CLI_PACKAGE = "skills@1.5.15";
13
12
  const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
14
13
  const TINYFISH_WEB_SKILL = "use-tinyfish";
@@ -45,35 +44,23 @@ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
45
44
  // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
46
45
  // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
47
46
  const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
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()) }));
47
+ const skillListSchema = z.array(z.object({ name: z.string() }));
57
48
  // `add` exits 0 on per-skill failure; the piped list is the verdict.
58
- function failedListVerdict(skillAgents) {
49
+ function failedListVerdict() {
59
50
  const list = spawn.sync("npx", ["-y", SKILLS_CLI_PACKAGE, "list", "--global", "--json"], {
60
51
  encoding: "utf8",
61
52
  env: skillSpawnEnv(),
62
53
  maxBuffer: STEP_MAX_BUFFER,
63
54
  timeout: SKILL_INSTALL_TIMEOUT_MS,
64
55
  });
65
- const verified = !list.error && list.status === 0 && verifiedForAgents(list.stdout ?? "", skillAgents);
56
+ const verified = !list.error && list.status === 0 && skillListed(list.stdout ?? "");
66
57
  return verified ? null : list;
67
58
  }
68
- // `list` folds in every agent's dir, so name-only matches stale copies.
69
- function verifiedForAgents(stdout, skillAgents) {
59
+ // Entry presence is the whole verdict: `agents` names the harnesses `skills` detects,
60
+ // so an install for a harness with no config dir yet lists none (PF-3680).
61
+ function skillListed(stdout) {
70
62
  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]));
63
+ return parsed.success && parsed.data.some((skill) => skill.name === TINYFISH_WEB_SKILL);
77
64
  }
78
65
  function parseJson(text) {
79
66
  try {
@@ -93,7 +80,7 @@ export function installWebSkill(client) {
93
80
  env: skillSpawnEnv(),
94
81
  timeout: SKILL_INSTALL_TIMEOUT_MS,
95
82
  });
96
- const failed = add.error || add.status !== 0 ? add : failedListVerdict([client.skillAgent]);
83
+ const failed = add.error || add.status !== 0 ? add : failedListVerdict();
97
84
  if (failed) {
98
85
  throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, failed);
99
86
  }
@@ -162,7 +149,7 @@ function reinstallWebSkill(skillAgents, verbose) {
162
149
  const output = capturedOutput(result);
163
150
  // Quiet capture stays (#4434); the list verdict replaced prose matching.
164
151
  const addFailed = Boolean(result.error) || result.status !== 0;
165
- const failedList = addFailed ? null : failedListVerdict(skillAgents);
152
+ const failedList = addFailed ? null : failedListVerdict();
166
153
  const failed = addFailed || failedList !== null;
167
154
  if (failed)
168
155
  throwIfInterrupted(failedList ?? result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.25.2-next.220",
3
+ "version": "0.25.2-next.222",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {