@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.
- package/dist/commands/doctor.d.ts +11 -3
- package/dist/commands/doctor.js +99 -10
- package/dist/lib/connect-all.js +9 -4
- package/dist/lib/doctor-report.d.ts +6 -0
- package/dist/lib/doctor-report.js +2 -2
- package/dist/lib/harness-detect.d.ts +5 -5
- package/dist/lib/harness-detect.js +10 -2
- package/dist/lib/registration-detect.d.ts +3 -3
- package/dist/lib/registration-detect.js +81 -7
- package/dist/lib/setup-telemetry.d.ts +2 -0
- package/dist/lib/setup-telemetry.js +4 -4
- package/package.json +2 -2
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import { type DoctorReport } from "../lib/doctor-report.js";
|
|
3
|
-
import { type
|
|
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?:
|
|
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;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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 && !
|
|
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
|
-
|
|
198
|
-
|
|
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".
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -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,
|
|
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: " +
|
|
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 {
|
|
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(
|
|
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
|
|
2
|
-
export type
|
|
3
|
-
export declare function harnessConfigPath(harness:
|
|
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:
|
|
5
|
+
export declare function harnessDisplayPath(harness: Harness): string;
|
|
6
6
|
export interface HarnessDetection {
|
|
7
|
-
harness:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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?:
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
108
|
-
.refine((results) => new Set(results.map((r) => r.harness)).size ===
|
|
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.
|
|
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.
|
|
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",
|