@tiny-fish/cli 0.25.0 → 0.25.1-next.216

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.
@@ -6,7 +6,7 @@ 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 { detectInstalledHarnesses, ALL_HARNESSES, } from "./harness-detect.js";
9
+ import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, ALL_HARNESSES, } from "./harness-detect.js";
10
10
  import { detectHumanInitiated } from "./harness.js";
11
11
  import { emitNotice } from "./notice.js";
12
12
  import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
@@ -145,11 +145,13 @@ function uninstallHarness(harness, configPath) {
145
145
  return { ...detection, outcome: "uninstalled" };
146
146
  }
147
147
  async function processHarness(detection, opts, prompt, hoistedCliInstall) {
148
- const { harness, configPath } = detection;
149
- if (!detection.detected) {
150
- return { harness, detected: false, configPath, installed: false, outcome: "not_detected" };
148
+ const { harness, configPath, detected } = detection;
149
+ const base = { harness, detected, configPath, installed: false };
150
+ if (!detected)
151
+ return { ...base, outcome: "not_detected" };
152
+ if (!opts.uninstall && isStaleConfig(detection)) {
153
+ return { ...base, outcome: "stale_config", fixCommand: `tinyfish connect ${harness}` };
151
154
  }
152
- const base = { harness, detected: true, configPath, installed: false };
153
155
  // dryRun first: --dry-run --uninstall must plan, never reach removeCursorMcpServer().
154
156
  if (opts.dryRun) {
155
157
  const plan = opts.uninstall
@@ -222,7 +224,10 @@ function summaryLine(result, uninstall) {
222
224
  const next = uninstall
223
225
  ? "nothing to remove"
224
226
  : `Install ${name}, then run: tinyfish connect ${result.harness}`;
225
- return `- ${name} — not detected (searched ${result.configPath}). ${next}`;
227
+ return `- ${name} — not detected (no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, no ${result.configPath}). ${next}`;
228
+ }
229
+ if (result.outcome === "stale_config") {
230
+ return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
226
231
  }
227
232
  if (result.outcome === "not_reached") {
228
233
  return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
@@ -269,8 +274,17 @@ function summaryLine(result, uninstall) {
269
274
  }
270
275
  return `✓ ${name} — installed, ${verifyLabel}`;
271
276
  }
277
+ /** Cursor's connect writes its config file; every other harness spawns its binary. */
278
+ // TODO(PF-3668): 4th cursor carve-out here; fold them into one harness capability table.
279
+ function isStaleConfig(detection) {
280
+ return detection.detected && !detection.binaryOnPath && detection.harness !== "cursor";
281
+ }
282
+ /** A stale config dir is a detection signal with nothing behind it. */
283
+ function actionable(result) {
284
+ return result.detected && result.outcome !== "stale_config";
285
+ }
272
286
  function computeExitCode(results) {
273
- const detected = results.filter((r) => r.detected);
287
+ const detected = results.filter(actionable);
274
288
  if (detected.length === 0)
275
289
  return 1;
276
290
  // Only real install attempts count toward the success/failure denominator.
@@ -376,7 +390,7 @@ export async function runConnectAll(opts) {
376
390
  // Machine-global, so once per invocation; cursor's connect never installs it.
377
391
  const cliNeeded = !opts.dryRun &&
378
392
  !opts.uninstall &&
379
- detections.some((d) => d.detected && d.harness !== "cursor" && willAttempt(d.harness));
393
+ detections.some((d) => d.detected && d.binaryOnPath && d.harness !== "cursor" && willAttempt(d.harness));
380
394
  let takeHoistedCli;
381
395
  if (cliNeeded) {
382
396
  errLine("First: the TinyFish CLI, shared by every harness.");
@@ -422,10 +436,14 @@ export async function runConnectAll(opts) {
422
436
  for (const detection of detections) {
423
437
  if (results.some((r) => r.harness === detection.harness))
424
438
  continue;
439
+ const { harness, detected, configPath } = detection;
440
+ const staleConfig = isStaleConfig(detection);
425
441
  results.push({
426
- ...detection,
442
+ harness,
443
+ detected,
444
+ configPath,
427
445
  installed: false,
428
- outcome: detection.detected ? "not_reached" : "not_detected",
446
+ outcome: staleConfig ? "stale_config" : detected ? "not_reached" : "not_detected",
429
447
  fixCommand: `tinyfish connect ${detection.harness}`,
430
448
  });
431
449
  }
@@ -433,7 +451,7 @@ export async function runConnectAll(opts) {
433
451
  outLine("TinyFish setup summary:");
434
452
  for (const result of results)
435
453
  outLine(summaryLine(result, opts.uninstall));
436
- if (results.every((r) => !r.detected)) {
454
+ if (!results.some(actionable)) {
437
455
  if (!opts.dryRun && !opts.uninstall) {
438
456
  try {
439
457
  return await runCliFallback(opts, prompt);
@@ -446,8 +464,11 @@ export async function runConnectAll(opts) {
446
464
  return 130;
447
465
  }
448
466
  }
467
+ const stale = results.filter((r) => r.outcome === "stale_config");
449
468
  outLine("");
450
- outLine("No supported harness found. Install one of: " + ALL_HARNESSES.join(", "));
469
+ outLine(stale.length > 0
470
+ ? `No runnable harness found. Reinstall ${stale.map((r) => DISPLAY_NAMES[r.harness]).join(", ")}, or install one of: ${ALL_HARNESSES.join(", ")}`
471
+ : "No supported harness found. Install one of: " + ALL_HARNESSES.join(", "));
451
472
  if (!opts.uninstall)
452
473
  outLine("Then run: npx @tiny-fish/cli connect --all");
453
474
  return 1;
@@ -455,7 +476,9 @@ export async function runConnectAll(opts) {
455
476
  if (!opts.dryRun && !opts.uninstall) {
456
477
  const harnessTelemetry = results.map((r) => ({
457
478
  harness: r.harness,
458
- detected: r.detected,
479
+ // TODO(PF-3668): send outcome per harness instead; the frontend strips
480
+ // unknown harness keys, so the CLI can ship it before the reader does.
481
+ detected: actionable(r),
459
482
  installed: r.installed,
460
483
  verify_depth: r.verifyDepth ?? null,
461
484
  verify_ok: r.verifyOk ?? null,
@@ -484,11 +507,12 @@ const FIRST_TASK_PROMPT = "My TinyFish setup just finished — verify it with a
484
507
  "me a two-line summary. Show which tools you call.";
485
508
  // Launchable seeded agents, best-first — cursor-agent shares ~/.cursor/mcp.json with the IDE.
486
509
  const LAUNCHERS = [
487
- { harness: "cursor", command: "cursor-agent", args: [FIRST_TASK_PROMPT] },
488
- { harness: "claude-code", command: "claude", args: [FIRST_TASK_PROMPT] },
510
+ { harness: "cursor", args: [FIRST_TASK_PROMPT] },
511
+ { harness: "claude-code", args: [FIRST_TASK_PROMPT] },
489
512
  ];
490
513
  async function launchFirstTask(results) {
491
- const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed));
514
+ const candidates = LAUNCHERS.filter((l) => results.some((r) => r.harness === l.harness && r.installed) &&
515
+ commandOnPath(HARNESS_COMMANDS[l.harness]));
492
516
  if (candidates.length === 0)
493
517
  return false;
494
518
  // Several installs: ask rather than pick a favorite; plain enter skips.
@@ -514,6 +538,6 @@ async function launchFirstTask(results) {
514
538
  if (!chosen)
515
539
  return false;
516
540
  outLine(`Starting ${DISPLAY_NAMES[chosen.harness]} with your first TinyFish task...`);
517
- const result = spawn.sync(chosen.command, chosen.args, { stdio: "inherit" });
541
+ const result = spawn.sync(HARNESS_COMMANDS[chosen.harness], chosen.args, { stdio: "inherit" });
518
542
  return !result.error;
519
543
  }
@@ -10,6 +10,8 @@ export declare enum AuthMode {
10
10
  OAuth = "oauth",
11
11
  Unknown = "unknown"
12
12
  }
13
+ export declare const HARNESS_COMMANDS: Record<Harness, string>;
14
+ export declare function commandOnPath(command: string): boolean;
13
15
  export declare function harnessConfigPath(harness: Harness): string;
14
16
  /** For reason strings that name a location; keeps them in sync with the table above. */
15
17
  export declare function harnessDisplayPath(harness: Harness): string;
@@ -17,5 +19,7 @@ export interface HarnessDetection {
17
19
  harness: Harness;
18
20
  detected: boolean;
19
21
  configPath: string;
22
+ /** False with `detected` true means a stale config dir: nothing to run. */
23
+ binaryOnPath: boolean;
20
24
  }
21
25
  export declare function detectInstalledHarnesses(): HarnessDetection[];
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
+ import which from "which";
4
5
  export const ALL_HARNESSES = [
5
6
  "claude-code",
6
7
  "codex",
@@ -23,7 +24,6 @@ export var AuthMode;
23
24
  AuthMode["OAuth"] = "oauth";
24
25
  AuthMode["Unknown"] = "unknown";
25
26
  })(AuthMode || (AuthMode = {}));
26
- // Presence detection only — dir existence means "installed", nothing more.
27
27
  const CONFIG_DIRS = {
28
28
  "claude-code": ".claude",
29
29
  codex: ".codex",
@@ -33,6 +33,26 @@ const CONFIG_DIRS = {
33
33
  openclaw: ".openclaw",
34
34
  opencode: ".config/opencode",
35
35
  };
36
+ // Harness id is not the binary name (cursor-agent).
37
+ // TODO(PF-3668): a same-named non-harness binary still reads as installed.
38
+ export const HARNESS_COMMANDS = {
39
+ "claude-code": "claude",
40
+ codex: "codex",
41
+ cursor: "cursor-agent",
42
+ grok: "grok",
43
+ hermes: "hermes",
44
+ openclaw: "openclaw",
45
+ opencode: "opencode",
46
+ };
47
+ // cross-spawn resolves the same way, so detection cannot disagree with the spawn.
48
+ export function commandOnPath(command) {
49
+ try {
50
+ return which.sync(command, { nothrow: true }) !== null;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
36
56
  export function harnessConfigPath(harness) {
37
57
  return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
38
58
  }
@@ -43,13 +63,20 @@ export function harnessDisplayPath(harness) {
43
63
  export function detectInstalledHarnesses() {
44
64
  return ALL_HARNESSES.map((harness) => {
45
65
  const configPath = harnessConfigPath(harness);
46
- let detected;
66
+ let configDirExists;
47
67
  try {
48
- detected = fs.statSync(configPath).isDirectory();
68
+ configDirExists = fs.statSync(configPath).isDirectory();
49
69
  }
50
70
  catch {
51
- detected = false;
71
+ configDirExists = false;
52
72
  }
53
- return { harness, detected, configPath };
73
+ // Config dir means ran once; PATH means runnable now.
74
+ const binaryOnPath = commandOnPath(HARNESS_COMMANDS[harness]);
75
+ return {
76
+ harness,
77
+ detected: configDirExists || binaryOnPath,
78
+ configPath,
79
+ binaryOnPath,
80
+ };
54
81
  });
55
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.1-next.216",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,12 +37,14 @@
37
37
  "commander": "^12.0.0",
38
38
  "cross-spawn": "^7.0.6",
39
39
  "globals": "^17.4.0",
40
+ "which": "^2.0.2",
40
41
  "zod": "^4.3.6"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "^10.0.1",
44
45
  "@types/cross-spawn": "^6.0.6",
45
46
  "@types/node": "^20.0.0",
47
+ "@types/which": "^2.0.2",
46
48
  "@typescript-eslint/eslint-plugin": "^8.57.2",
47
49
  "@typescript-eslint/parser": "^8.57.2",
48
50
  "eslint": "^10.1.0",