@tiny-fish/cli 0.15.1-next.160 → 0.16.0

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,9 +1,17 @@
1
1
  import type { Command } from "commander";
2
- import { type DoctorReport } from "../lib/doctor-report.js";
3
- import { type FiveHarness } from "../lib/harness-detect.js";
2
+ import { type DoctorRepair, type DoctorReport } from "../lib/doctor-report.js";
3
+ import { type Harness } from "../lib/harness-detect.js";
4
4
  export interface DoctorOptions {
5
5
  mcpUrl: string;
6
- harness?: FiveHarness;
6
+ harness?: Harness;
7
+ fix?: boolean;
8
+ yes?: boolean;
7
9
  }
8
10
  export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
11
+ export interface RepairOutcome {
12
+ repair: DoctorRepair;
13
+ status: "repaired" | "skipped" | "failed";
14
+ reason?: string;
15
+ }
16
+ export declare function applyRepairs(repairs: DoctorRepair[], options: DoctorOptions): Promise<RepairOutcome[]>;
9
17
  export declare function registerDoctor(program: Command): void;
@@ -1,11 +1,13 @@
1
+ import spawn from "cross-spawn";
1
2
  import { apiKeyStatus } from "../lib/auth.js";
2
3
  import { CLI_VERSION } from "../lib/constants.js";
3
4
  import { DOCTOR_COULD_NOT_RUN, DOCTOR_SCHEMA_VERSION, doctorReportSchema, exitCodeFor, renderPretty, } from "../lib/doctor-report.js";
4
- import { FIVE_HARNESSES } from "../lib/harness-detect.js";
5
+ import { ALL_HARNESSES } from "../lib/harness-detect.js";
6
+ import { detectHumanInitiated } from "../lib/harness.js";
5
7
  import { err, errLine, out, outLine } from "../lib/output.js";
6
8
  import { detectRegistrations } from "../lib/registration-detect.js";
7
9
  import { verifyMcpAuth, verifyMcpHealth } from "../lib/verify.js";
8
- import { DEFAULT_MCP_URL } from "./connect.js";
10
+ import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, DEFAULT_MCP_URL, } from "./connect.js";
9
11
  // A green auth mode with a red auth call proves nothing, so the call's verdict gates the claim.
10
12
  function provesHarnessReach(status, authCallPassed, healthOk, mcpUrl) {
11
13
  // An endpoint doctor could not reach proves nothing about a harness pointed at it.
@@ -68,7 +70,9 @@ function registrationVerdict(status, mcpUrl) {
68
70
  detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
69
71
  };
70
72
  }
71
- return { status: "pass", detail: `registered, auth mode ${status.authMode}` };
73
+ // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
74
+ const because = status.reason ? ` (${status.reason})` : "";
75
+ return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
72
76
  }
73
77
  // Connected once and now absent is a broken install; never connected is just not set up.
74
78
  return status.connectedBefore
@@ -117,6 +121,7 @@ async function checkAuthCall(key) {
117
121
  const UNATTENDED_SAFE = new Set(["cursor"]);
118
122
  function repairsFor(checks, statuses) {
119
123
  const repairs = [];
124
+ const credentialResolves = checks.some((c) => c.id === "cli-credential" && c.status === "pass");
120
125
  for (const status of statuses) {
121
126
  // `unknown` earns nothing: running connect off a failed probe contradicts the check's detail.
122
127
  // A registered-but-misaimed entry does earn one, since connect rewrites the URL.
@@ -132,7 +137,8 @@ function repairsFor(checks, statuses) {
132
137
  action: "connect",
133
138
  harness: status.harness,
134
139
  command: `tinyfish connect ${status.harness}`,
135
- unattended_safe: UNATTENDED_SAFE.has(status.harness),
140
+ // Without a credential even Cursor's repair shells an interactive `auth login`.
141
+ unattended_safe: UNATTENDED_SAFE.has(status.harness) && credentialResolves,
136
142
  });
137
143
  }
138
144
  // A revoked-but-well-formed key passes the credential check and fails the call; both need login.
@@ -178,25 +184,108 @@ export async function runDoctor(options) {
178
184
  // Nothing to scrub: every string above is authored here or enumerated at its source.
179
185
  return doctorReportSchema.parse(body);
180
186
  }
187
+ // Shell out rather than import the login flow: `auth login` owns its own browser and prompts.
188
+ // Re-invoke this same entry script, never a global `tinyfish` — under `npx` there is no such
189
+ // binary on PATH, and npx is the install-nothing path doctor exists to serve.
190
+ function runAuthLogin() {
191
+ const entry = process.argv[1];
192
+ if (!entry)
193
+ throw new Error("Could not locate the TinyFish CLI entry point to re-invoke");
194
+ const result = spawn.sync(process.execPath, [entry, "auth", "login", "--source", "doctor"], {
195
+ stdio: "inherit",
196
+ });
197
+ if (result.error)
198
+ throw new Error("Could not run `tinyfish auth login`", { cause: result.error });
199
+ if (result.status !== 0) {
200
+ throw new Error(`\`tinyfish auth login\` exited ${result.status ?? "unknown"}`);
201
+ }
202
+ }
203
+ const CONNECTORS = {
204
+ "claude-code": (opts) => connectClaudeCode({ ...opts, launch: false }),
205
+ codex: (opts) => connectCodex({ ...opts, launch: false }),
206
+ cursor: (opts) => connectCursor(opts),
207
+ hermes: (opts) => connectHermes({ ...opts, launch: false }),
208
+ openclaw: (opts) => connectOpenClaw({ ...opts, launch: false }),
209
+ opencode: (opts) => connectOpencode({ ...opts, launch: false }),
210
+ };
211
+ /** Dispatches on `action`, never on a null harness: that conflation made the credential fix a no-op. */
212
+ async function runRepair(repair, mcpUrl) {
213
+ if (repair.action === "auth-login")
214
+ return runAuthLogin();
215
+ if (repair.harness)
216
+ await CONNECTORS[repair.harness]({ mcpUrl });
217
+ }
218
+ export async function applyRepairs(repairs, options) {
219
+ const interactive = detectHumanInitiated();
220
+ const outcomes = [];
221
+ for (const repair of repairs) {
222
+ if (!interactive && !options.yes) {
223
+ outcomes.push({
224
+ repair,
225
+ status: "skipped",
226
+ reason: "no terminal; re-run with --yes to repair unattended",
227
+ });
228
+ continue;
229
+ }
230
+ if (!interactive && !repair.unattended_safe) {
231
+ outcomes.push({
232
+ repair,
233
+ status: "skipped",
234
+ reason: "needs a browser sign-in, which has no unattended path",
235
+ });
236
+ continue;
237
+ }
238
+ try {
239
+ await runRepair(repair, options.mcpUrl);
240
+ outcomes.push({ repair, status: "repaired" });
241
+ }
242
+ catch (e) {
243
+ // Connect quotes absolute config paths in its errors, so the raw text stays behind
244
+ // --debug and the user is told where to find it rather than handed a generic dead end.
245
+ if (process.env["TINYFISH_DEBUG"]) {
246
+ errLine(e instanceof Error ? (e.stack ?? e.message) : String(e));
247
+ }
248
+ outcomes.push({
249
+ repair,
250
+ status: "failed",
251
+ reason: `\`${repair.command}\` failed; re-run it directly, or --debug for the raw error`,
252
+ });
253
+ }
254
+ }
255
+ return outcomes;
256
+ }
181
257
  export function registerDoctor(program) {
182
258
  program
183
259
  .command("doctor")
184
260
  .description("Diagnose your TinyFish setup")
185
261
  .option("--pretty", "Human-readable check list instead of JSON")
186
- .option("--harness <name>", `Restrict to one harness (${FIVE_HARNESSES.join(", ")})`)
262
+ .option("--harness <name>", `Restrict to one harness (${ALL_HARNESSES.join(", ")})`)
187
263
  .option("--url <mcpUrl>", "MCP endpoint override")
264
+ .option("--fix", "Repair what can be repaired by re-running tinyfish connect")
265
+ .option("--yes", "Allow --fix to run without a terminal (unattended-safe repairs only)")
188
266
  .action(async (options) => {
189
- if (options.harness && !FIVE_HARNESSES.includes(options.harness)) {
267
+ if (options.harness && !ALL_HARNESSES.includes(options.harness)) {
190
268
  err({ error: `Unknown --harness value: ${options.harness}` });
191
269
  process.exitCode = DOCTOR_COULD_NOT_RUN;
192
270
  return;
193
271
  }
272
+ const doctorOptions = {
273
+ mcpUrl: options.url ?? DEFAULT_MCP_URL,
274
+ fix: Boolean(options.fix),
275
+ yes: Boolean(options.yes),
276
+ ...(options.harness ? { harness: options.harness } : {}),
277
+ };
194
278
  let report;
195
279
  try {
196
- report = await runDoctor({
197
- mcpUrl: options.url ?? DEFAULT_MCP_URL,
198
- ...(options.harness ? { harness: options.harness } : {}),
199
- });
280
+ report = await runDoctor(doctorOptions);
281
+ if (doctorOptions.fix && report.repairs.length > 0) {
282
+ for (const outcome of await applyRepairs(report.repairs, doctorOptions)) {
283
+ const reason = outcome.reason ? ` (${outcome.reason})` : "";
284
+ errLine(`${outcome.status}: ${outcome.repair.command}${reason}`);
285
+ }
286
+ // Re-diagnose so the emitted report describes the machine after repair, not before.
287
+ report = await runDoctor(doctorOptions);
288
+ }
200
289
  }
201
290
  catch (e) {
202
291
  // Exit 2 means doctor itself broke, a different claim from "your setup is broken".
@@ -1,8 +1,8 @@
1
1
  import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
- import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
3
+ import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
4
  import { validatedApiKey } from "./auth.js";
5
- import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
5
+ import { detectInstalledHarnesses, ALL_HARNESSES, } from "./harness-detect.js";
6
6
  import { detectHumanInitiated } from "./harness.js";
7
7
  import { emitNotice } from "./notice.js";
8
8
  import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
@@ -15,12 +15,13 @@ const DISPLAY_NAMES = {
15
15
  cursor: "Cursor",
16
16
  hermes: "Hermes",
17
17
  openclaw: "OpenClaw",
18
+ opencode: "OpenCode",
18
19
  };
19
20
  // `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
20
21
  const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
21
22
  // Harnesses that drive their own OAuth at install — a stored TinyFish key says nothing
22
23
  // about their session, so it must never unlock a headless run for them.
23
- const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes"]);
24
+ const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes", "opencode"]);
24
25
  const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
25
26
  // Real removal commands — harness id is not the binary name.
26
27
  const UNINSTALL_POINTERS = {
@@ -28,6 +29,8 @@ const UNINSTALL_POINTERS = {
28
29
  codex: "codex mcp remove tinyfish",
29
30
  hermes: "hermes mcp remove tinyfish",
30
31
  openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
32
+ // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
33
+ opencode: "remove the tinyfish entry from ~/.config/opencode/opencode.json",
31
34
  };
32
35
  async function runHarnessConnect(harness, opts) {
33
36
  const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
@@ -39,6 +42,8 @@ async function runHarnessConnect(harness, opts) {
39
42
  return connectHermes(base);
40
43
  if (harness === "openclaw")
41
44
  return connectOpenClaw({ mcpUrl: opts.mcpUrl, launch: false });
45
+ if (harness === "opencode")
46
+ return connectOpencode(base);
42
47
  return connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
43
48
  }
44
49
  async function verifyHarness(harness, mcpUrl, apiKey) {
@@ -262,7 +267,7 @@ export async function runConnectAll(opts) {
262
267
  outLine(summaryLine(result, opts.uninstall));
263
268
  if (results.every((r) => !r.detected)) {
264
269
  outLine("");
265
- outLine("No supported harness found. Install one of: " + FIVE_HARNESSES.join(", "));
270
+ outLine("No supported harness found. Install one of: " + ALL_HARNESSES.join(", "));
266
271
  if (!opts.uninstall)
267
272
  outLine("Then run: npx @tiny-fish/cli connect --all");
268
273
  return 1;
@@ -22,6 +22,7 @@ declare const doctorCheckSchema: z.ZodObject<{
22
22
  cursor: "cursor";
23
23
  codex: "codex";
24
24
  hermes: "hermes";
25
+ opencode: "opencode";
25
26
  "claude-code": "claude-code";
26
27
  }>>;
27
28
  }, z.core.$strip>;
@@ -31,6 +32,7 @@ declare const doctorHarnessSchema: z.ZodObject<{
31
32
  cursor: "cursor";
32
33
  codex: "codex";
33
34
  hermes: "hermes";
35
+ opencode: "opencode";
34
36
  "claude-code": "claude-code";
35
37
  }>;
36
38
  detected: z.ZodBoolean;
@@ -57,6 +59,7 @@ declare const doctorRepairSchema: z.ZodObject<{
57
59
  cursor: "cursor";
58
60
  codex: "codex";
59
61
  hermes: "hermes";
62
+ opencode: "opencode";
60
63
  "claude-code": "claude-code";
61
64
  }>>;
62
65
  command: z.ZodString;
@@ -81,6 +84,7 @@ export declare const doctorReportSchema: z.ZodObject<{
81
84
  cursor: "cursor";
82
85
  codex: "codex";
83
86
  hermes: "hermes";
87
+ opencode: "opencode";
84
88
  "claude-code": "claude-code";
85
89
  }>>;
86
90
  }, z.core.$strip>>;
@@ -90,6 +94,7 @@ export declare const doctorReportSchema: z.ZodObject<{
90
94
  cursor: "cursor";
91
95
  codex: "codex";
92
96
  hermes: "hermes";
97
+ opencode: "opencode";
93
98
  "claude-code": "claude-code";
94
99
  }>;
95
100
  detected: z.ZodBoolean;
@@ -116,6 +121,7 @@ export declare const doctorReportSchema: z.ZodObject<{
116
121
  cursor: "cursor";
117
122
  codex: "codex";
118
123
  hermes: "hermes";
124
+ opencode: "opencode";
119
125
  "claude-code": "claude-code";
120
126
  }>>;
121
127
  command: z.ZodString;
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { FIVE_HARNESSES } from "./harness-detect.js";
2
+ import { ALL_HARNESSES } from "./harness-detect.js";
3
3
  /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
4
  export const DOCTOR_SCHEMA_VERSION = 1;
5
5
  const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
6
- const harnessSchema = z.enum(FIVE_HARNESSES);
6
+ const harnessSchema = z.enum(ALL_HARNESSES);
7
7
  const doctorCheckSchema = z.object({
8
8
  id: z.string(),
9
9
  title: z.string(),
@@ -1,10 +1,10 @@
1
- export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
2
- export type FiveHarness = (typeof FIVE_HARNESSES)[number];
3
- export declare function harnessConfigPath(harness: FiveHarness): string;
1
+ export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw", "opencode"];
2
+ export type Harness = (typeof ALL_HARNESSES)[number];
3
+ export declare function harnessConfigPath(harness: Harness): string;
4
4
  /** For reason strings that name a location; keeps them in sync with the table above. */
5
- export declare function harnessDisplayPath(harness: FiveHarness): string;
5
+ export declare function harnessDisplayPath(harness: Harness): string;
6
6
  export interface HarnessDetection {
7
- harness: FiveHarness;
7
+ harness: Harness;
8
8
  detected: boolean;
9
9
  configPath: string;
10
10
  }
@@ -1,7 +1,14 @@
1
1
  import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
- export const FIVE_HARNESSES = ["claude-code", "codex", "cursor", "hermes", "openclaw"];
4
+ export const ALL_HARNESSES = [
5
+ "claude-code",
6
+ "codex",
7
+ "cursor",
8
+ "hermes",
9
+ "openclaw",
10
+ "opencode",
11
+ ];
5
12
  // Presence detection only — dir existence means "installed", nothing more.
6
13
  const CONFIG_DIRS = {
7
14
  "claude-code": ".claude",
@@ -9,6 +16,7 @@ const CONFIG_DIRS = {
9
16
  cursor: ".cursor",
10
17
  hermes: ".hermes",
11
18
  openclaw: ".openclaw",
19
+ opencode: ".config/opencode",
12
20
  };
13
21
  export function harnessConfigPath(harness) {
14
22
  return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
@@ -18,7 +26,7 @@ export function harnessDisplayPath(harness) {
18
26
  return `~/${CONFIG_DIRS[harness]}`;
19
27
  }
20
28
  export function detectInstalledHarnesses() {
21
- return FIVE_HARNESSES.map((harness) => {
29
+ return ALL_HARNESSES.map((harness) => {
22
30
  const configPath = harnessConfigPath(harness);
23
31
  let detected;
24
32
  try {
@@ -1,8 +1,8 @@
1
- import { type FiveHarness } from "./harness-detect.js";
1
+ import { type Harness } from "./harness-detect.js";
2
2
  export type Registered = "yes" | "no" | "unknown";
3
3
  export type AuthMode = "api-key" | "oauth" | "unknown";
4
4
  export interface RegistrationStatus {
5
- harness: FiveHarness;
5
+ harness: Harness;
6
6
  detected: boolean;
7
7
  registered: Registered;
8
8
  authMode: AuthMode;
@@ -12,4 +12,4 @@ export interface RegistrationStatus {
12
12
  reason?: string;
13
13
  }
14
14
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
15
- export declare function detectRegistrations(harnesses?: FiveHarness[]): RegistrationStatus[];
15
+ export declare function detectRegistrations(harnesses?: Harness[]): RegistrationStatus[];
@@ -24,6 +24,10 @@ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid
24
24
  // `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
25
25
  const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
26
26
  const API_KEY_HEADER = /x-api-key/i;
27
+ // OpenCode colours its list even through a pipe, putting `\x1b[34m` between the line start and
28
+ // the server name, so an unstripped probe reads a registered harness as absent.
29
+ // eslint-disable-next-line no-control-regex
30
+ const ANSI_SGR = /\x1b\[[0-9;]*m/g;
27
31
  // The report is pasted in public, so every reason is authored here. A spawn error's own
28
32
  // message quotes absolute paths, which is how a home directory reaches the payload.
29
33
  function spawnFailureReason(command, code) {
@@ -34,7 +38,13 @@ function spawnFailureReason(command, code) {
34
38
  return `could not run \`${command}\``;
35
39
  }
36
40
  function runProbe(command, args) {
37
- const result = spawn.sync(command, args, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
41
+ const result = spawn.sync(command, args, {
42
+ encoding: "utf8",
43
+ timeout: PROBE_TIMEOUT_MS,
44
+ // Belt and braces only: agents export FORCE_COLOR, and `NO_COLOR` does not fully silence
45
+ // OpenCode, so the strip below stays load-bearing rather than a fallback.
46
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
47
+ });
38
48
  if (result.error) {
39
49
  const code = result.error.code;
40
50
  // A timeout arrives as ETIMEDOUT here, not only as a signal, so it must be named before
@@ -48,12 +58,36 @@ function runProbe(command, args) {
48
58
  if (result.signal) {
49
59
  return { outcome: "unavailable", reason: `\`${command}\` was killed by ${result.signal}` };
50
60
  }
51
- const stdout = result.stdout ?? "";
61
+ // Both streams, since a colour code inside a JSON payload breaks the parse just as it
62
+ // breaks an anchored match. Valid JSON never carries a raw ESC byte, so this cannot corrupt it.
63
+ const stdout = (result.stdout ?? "").replace(ANSI_SGR, "");
52
64
  return {
53
65
  outcome: "ran",
54
66
  exitCode: result.status ?? 1,
55
67
  stdout,
56
- output: `${stdout}${result.stderr ?? ""}`,
68
+ output: `${stdout}${(result.stderr ?? "").replace(ANSI_SGR, "")}`,
69
+ };
70
+ }
71
+ // A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
72
+ // The optional prefix must end in `:`, so `not-tinyfish` is not a match.
73
+ const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)/m;
74
+ // A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
75
+ // is required to be the first word on its line, after whatever status glyph the CLI prints.
76
+ const LISTED_AS_TINYFISH = /^[^\w]*tinyfish(?![\w-])/m;
77
+ /** The plugin ships its own MCP server, so a working install can carry no `tinyfish` entry. */
78
+ function fromPluginRegistration(command) {
79
+ const probe = runProbe(command, ["mcp", "list"]);
80
+ if (probe.outcome === "unavailable" || probe.exitCode !== 0)
81
+ return undefined;
82
+ const match = MCP_LIST_TINYFISH_LINE.exec(probe.output);
83
+ if (!match)
84
+ return undefined;
85
+ // `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
86
+ return {
87
+ registered: "yes",
88
+ authMode: "unknown",
89
+ registeredUrl: match[2],
90
+ reason: `registered as ${match[1]}, which \`mcp get tinyfish\` does not resolve`,
57
91
  };
58
92
  }
59
93
  // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
@@ -70,6 +104,10 @@ function fromMcpGet(command, keyAuthPattern) {
70
104
  reason: `\`${command} mcp get\` is unsupported by this version`,
71
105
  };
72
106
  }
107
+ // Positive evidence from `mcp list` outranks anything inferred from a failed `mcp get`.
108
+ const plugin = fromPluginRegistration(command);
109
+ if (plugin)
110
+ return plugin;
73
111
  // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
74
112
  if (!NO_SUCH_SERVER.test(probe.output)) {
75
113
  return {
@@ -162,6 +200,26 @@ function probeCursor() {
162
200
  ...(entry.url ? { registeredUrl: entry.url } : {}),
163
201
  };
164
202
  }
203
+ // Both list commands print this header in every state, including "no servers configured".
204
+ // Exit 0 with output we cannot recognise is not evidence of absence: reporting `no` there would
205
+ // earn a connect repair off a parse failure, which is the bug class this command exists to kill.
206
+ const MCP_LIST_HEADER = /mcp servers/i;
207
+ // OpenCode prints the endpoint on the line under the server row; Hermes truncates its column.
208
+ const OPENCODE_ROW_URL = /^[^\w]*tinyfish(?![\w-]).*\n[^\w]*(https?:\S+)/m;
209
+ function fromMcpList(command, output, registeredMode, urlPattern) {
210
+ if (LISTED_AS_TINYFISH.test(output)) {
211
+ const url = urlPattern?.exec(output)?.[1];
212
+ return { registered: "yes", authMode: registeredMode, ...(url ? { registeredUrl: url } : {}) };
213
+ }
214
+ if (!MCP_LIST_HEADER.test(output)) {
215
+ return {
216
+ registered: "unknown",
217
+ authMode: "unknown",
218
+ reason: `could not interpret \`${command} mcp list\` output`,
219
+ };
220
+ }
221
+ return { registered: "no", authMode: "unknown" };
222
+ }
165
223
  // Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
166
224
  function probeHermes() {
167
225
  const probe = runProbe("hermes", ["mcp", "list"]);
@@ -175,10 +233,9 @@ function probeHermes() {
175
233
  reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
176
234
  };
177
235
  }
178
- return probe.output.includes("tinyfish")
179
- ? { registered: "yes", authMode: "oauth" }
180
- : { registered: "no", authMode: "unknown" };
236
+ return fromMcpList("hermes", probe.output, "oauth");
181
237
  }
238
+ const SKILL_DIR_IS_TINYFISH = /^(?:@tinyfish[/_-])?tinyfish(?![\w-])/i;
182
239
  // OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
183
240
  function probeOpenClaw() {
184
241
  const skillsDir = path.join(harnessConfigPath("openclaw"), "skills");
@@ -193,16 +250,33 @@ function probeOpenClaw() {
193
250
  reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
194
251
  };
195
252
  }
196
- return entries.some((name) => /tinyfish/i.test(name))
253
+ // A bare substring also matched a skill merely named `not-tinyfish-thing`.
254
+ return entries.some((name) => SKILL_DIR_IS_TINYFISH.test(name))
197
255
  ? { registered: "yes", authMode: "api-key" }
198
256
  : { registered: "no", authMode: "unknown" };
199
257
  }
258
+ // Connect registers with `mcp add --url` then `mcp auth`, so there is no key path today.
259
+ function probeOpencode() {
260
+ const probe = runProbe("opencode", ["mcp", "list"]);
261
+ if (probe.outcome === "unavailable") {
262
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
263
+ }
264
+ if (probe.exitCode !== 0) {
265
+ return {
266
+ registered: "unknown",
267
+ authMode: "unknown",
268
+ reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
269
+ };
270
+ }
271
+ return fromMcpList("opencode", probe.output, "oauth", OPENCODE_ROW_URL);
272
+ }
200
273
  const PROBES = {
201
274
  "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
202
275
  codex: probeCodex,
203
276
  cursor: probeCursor,
204
277
  hermes: probeHermes,
205
278
  openclaw: probeOpenClaw,
279
+ opencode: probeOpencode,
206
280
  };
207
281
  /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
208
282
  export function detectRegistrations(harnesses) {
@@ -28,6 +28,7 @@ declare const harnessResultSchema: z.ZodObject<{
28
28
  cursor: "cursor";
29
29
  codex: "codex";
30
30
  hermes: "hermes";
31
+ opencode: "opencode";
31
32
  "claude-code": "claude-code";
32
33
  }>;
33
34
  detected: z.ZodBoolean;
@@ -45,6 +46,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
45
46
  cursor: "cursor";
46
47
  codex: "codex";
47
48
  hermes: "hermes";
49
+ opencode: "opencode";
48
50
  "claude-code": "claude-code";
49
51
  }>;
50
52
  detected: z.ZodBoolean;
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { validateKeyFormat } from "./auth.js";
4
4
  import { BASE_URL, CLI_VERSION } from "./constants.js";
5
- import { FIVE_HARNESSES } from "./harness-detect.js";
5
+ import { ALL_HARNESSES } from "./harness-detect.js";
6
6
  import { captureNotice } from "./notice.js";
7
7
  import { errLine } from "./output.js";
8
8
  const CONNECT_EVENT_TIMEOUT_MS = 2_000;
@@ -95,7 +95,7 @@ export async function postConnectEvent(options) {
95
95
  }
96
96
  }
97
97
  const harnessResultSchema = z.object({
98
- harness: z.enum(FIVE_HARNESSES),
98
+ harness: z.enum(ALL_HARNESSES),
99
99
  detected: z.boolean(),
100
100
  installed: z.boolean(),
101
101
  verify_depth: z.enum(["health", "health+auth"]).nullable(),
@@ -104,8 +104,8 @@ const harnessResultSchema = z.object({
104
104
  export const setupCompletedPayloadSchema = z.object({
105
105
  harnesses: z
106
106
  .array(harnessResultSchema)
107
- .length(FIVE_HARNESSES.length)
108
- .refine((results) => new Set(results.map((r) => r.harness)).size === FIVE_HARNESSES.length, {
107
+ .length(ALL_HARNESSES.length)
108
+ .refine((results) => new Set(results.map((r) => r.harness)).size === ALL_HARNESSES.length, {
109
109
  message: "expected one result per harness",
110
110
  }),
111
111
  cli_version: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.15.1-next.160",
3
+ "version": "0.16.0",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@tiny-fish/sdk": "^0.3.0",
35
+ "@tiny-fish/sdk": "^0.4.0",
36
36
  "chrome-cookies-secure": "^3.0.2",
37
37
  "commander": "^12.0.0",
38
38
  "cross-spawn": "^7.0.6",