@tiny-fish/cli 0.14.1-next.154 → 0.15.1-next.159

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,5 +1,5 @@
1
1
  import { err, errLine, out } from "../lib/output.js";
2
- import { loadConfig, validateKeyFormat } from "../lib/auth.js";
2
+ import { validatedApiKey } from "../lib/auth.js";
3
3
  import { readSettingsJson, writeSettingsJson, readClaudeMd, writeClaudeMd, mergeSettings, removeFromSettings, mergeClaudeMd, removeFromClaudeMd, claudeSettingsPath, claudeMdPath, isTinyfishConfiguredInSettings, isTinyfishConfiguredInClaudeMd, } from "../lib/claude-config.js";
4
4
  function loadExistingConfig() {
5
5
  let settings;
@@ -25,11 +25,7 @@ function loadExistingConfig() {
25
25
  return { settings, claudeMd };
26
26
  }
27
27
  function isSignedIn() {
28
- const envKey = process.env.TINYFISH_API_KEY;
29
- if (envKey && validateKeyFormat(envKey))
30
- return true;
31
- const config = loadConfig();
32
- return !!config.api_key && validateKeyFormat(config.api_key);
28
+ return !!validatedApiKey();
33
29
  }
34
30
  function install(settings, claudeMd) {
35
31
  errLine("Configuring Claude Code to use TinyFish...\n");
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import spawn from "cross-spawn";
4
- import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, writeConfig, } from "../lib/auth.js";
4
+ import { CONNECT_SOURCE, loadConfig, persistApiKeyToEnvironment, resolvedApiKey, saveConnectContext, validateKeyFormat, validatedApiKey, writeConfig, } from "../lib/auth.js";
5
5
  import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
7
7
  import { runConnectAll } from "../lib/connect-all.js";
@@ -16,17 +16,29 @@ export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
16
16
  const NON_INTERACTIVE_TIMEOUT_MS = 10_000;
17
17
  const SKILL_INSTALL_TIMEOUT_MS = 120_000;
18
18
  const HERMES_SEED_TIMEOUT_MS = 120_000;
19
- const MCP_LOGIN_UNAVAILABLE_MESSAGE = "This Claude Code installation does not expose `claude mcp login`, which is required for " +
20
- "one-command MCP authentication. Update Claude Code and retry, or authenticate TinyFish " +
21
- "manually from /mcp.";
22
- const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "This Codex installation does not support one-command MCP authentication. Update Codex and " +
23
- "retry, or add TinyFish manually with `codex mcp add`.";
24
- const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "This Hermes installation does not support one-command MCP authentication. Update Hermes and " +
25
- "retry, or add TinyFish manually with `hermes mcp add`.";
26
- const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "This OpenClaw installation does not support global skill installation. Update OpenClaw and " +
27
- "retry.";
28
- const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "This OpenCode installation does not support one-command MCP setup (`opencode mcp add --url`). " +
29
- "Update OpenCode and retry, or add TinyFish manually with `opencode mcp add`.";
19
+ // An old install is only one reason a flag can go unseen, so no message asserts the cause.
20
+ const SUPPORT_CHECK_DEBUG_HINT = " Set TINYFISH_DEBUG=1 and retry to print the help output TinyFish read.";
21
+ const MCP_LOGIN_UNAVAILABLE_MESSAGE = "Could not confirm this Claude Code installation exposes `claude mcp login`: `claude mcp " +
22
+ "--help` did not list it. Update Claude Code and retry, or authenticate TinyFish manually " +
23
+ "from /mcp." +
24
+ SUPPORT_CHECK_DEBUG_HINT;
25
+ const CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE = "Could not confirm this Codex installation supports one-command MCP authentication: `codex " +
26
+ "mcp add --help` did not list `--oauth-resource`. Update Codex and retry, or add TinyFish " +
27
+ "manually with `codex mcp add`." +
28
+ SUPPORT_CHECK_DEBUG_HINT;
29
+ const HERMES_OAUTH_UNAVAILABLE_MESSAGE = "Could not confirm this Hermes installation supports one-command MCP authentication: `hermes " +
30
+ "mcp add --help` did not list `--auth oauth`. Update Hermes and retry, or add TinyFish " +
31
+ "manually with `hermes mcp add`." +
32
+ SUPPORT_CHECK_DEBUG_HINT;
33
+ const OPENCLAW_SKILL_UNAVAILABLE_MESSAGE = "Could not confirm this OpenClaw installation supports global skill installation: `openclaw " +
34
+ "skills install --help` did not list `--global` and `--acknowledge-clawhub-risk`. Update " +
35
+ "OpenClaw and retry, or install manually with `openclaw skills install @tinyfish/tinyfish " +
36
+ "--global --acknowledge-clawhub-risk`." +
37
+ SUPPORT_CHECK_DEBUG_HINT;
38
+ const OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE = "Could not confirm this OpenCode installation supports one-command MCP setup: `opencode mcp " +
39
+ "add --help` did not list `--url`. Update OpenCode and retry, or add TinyFish manually with " +
40
+ "`opencode mcp add`." +
41
+ SUPPORT_CHECK_DEBUG_HINT;
30
42
  // OpenCode is model-agnostic (unlike Claude Code / Codex), so a fresh install can default to a
31
43
  // model with no tool support — TinyFish runs entirely on tool calls, so it would fail there.
32
44
  const OPENCODE_MODEL_NOTE = "Note: TinyFish runs on tool calls, so OpenCode needs a model that supports tool use. Image-only " +
@@ -192,7 +204,8 @@ function launchHermesWalkthrough() {
192
204
  cause: seedResult.error,
193
205
  });
194
206
  }
195
- const sessionId = seedResult.stderr?.match(/session_id:\s*([^\s]+)/i)?.[1];
207
+ // A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
208
+ const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
196
209
  if (!sessionId) {
197
210
  throw new Error("Hermes did not return a walkthrough session ID");
198
211
  }
@@ -297,6 +310,8 @@ function createConnectTelemetry(mcpUrl, client, opts) {
297
310
  runtime_platform: process.platform,
298
311
  node_version: process.version,
299
312
  cli_version: CLI_VERSION,
313
+ // Same signal the usage events carry, so TTY and headless connects can be split.
314
+ is_human_initiated: detectHumanInitiated(),
300
315
  })));
301
316
  },
302
317
  // Awaited in finally so in-flight events land before exit.
@@ -308,6 +323,8 @@ function createConnectTelemetry(mcpUrl, client, opts) {
308
323
  function requireCommandSupport(client) {
309
324
  const result = spawn.sync(client.command, client.supportCheck.args, {
310
325
  encoding: "utf8",
326
+ // Agent harnesses export FORCE_COLOR, which makes clients colourise even a piped --help.
327
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
311
328
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
312
329
  });
313
330
  if (commandNotFound(result.error)) {
@@ -317,8 +334,12 @@ function requireCommandSupport(client) {
317
334
  throwIfInterrupted(result);
318
335
  throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_command_unsupported", { cause: result.error });
319
336
  }
320
- const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
337
+ // Belt and braces with the colour env: a client that ignores NO_COLOR still has to match.
338
+ const output = sanitizeLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
321
339
  if (!client.supportCheck.patterns.every((pattern) => pattern.test(output))) {
340
+ if (process.env["TINYFISH_DEBUG"]) {
341
+ errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
342
+ }
322
343
  throw new PrerequisiteError(client.supportCheck.unavailableMessage, "harness_too_old", {
323
344
  harnessVersion: probeHarnessVersion(client.command),
324
345
  });
@@ -572,8 +593,8 @@ async function connectNativeMcpClient(client, options) {
572
593
  mcpUrl.searchParams.set("client", client.connectClient);
573
594
  mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
574
595
  // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
575
- const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
576
- const useKeyAuth = client.headerAuthSupported === true && !!storedKey && validateKeyFormat(storedKey);
596
+ const storedKey = validatedApiKey(options.apiKey);
597
+ const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
577
598
  state.stage = client.loginArgs ? "registration" : "registration_or_authentication";
578
599
  errLine(`Adding TinyFish to ${client.displayName}...`);
579
600
  const addResult = spawn.sync(client.command, [
@@ -747,9 +768,11 @@ export async function connectCursor(options) {
747
768
  state.stage = "authentication";
748
769
  ensureCliAuthenticated("cursor", options.apiKey);
749
770
  telemetry.track("checkpoint", { phase: "authenticated" });
750
- // loadConfig() is unvalidated on read — a hand-edited key must not reach mcp.json.
751
- const storedKey = options.apiKey ?? process.env["TINYFISH_API_KEY"] ?? loadConfig().api_key;
752
- const resolvedKey = storedKey && validateKeyFormat(storedKey) ? storedKey : undefined;
771
+ const resolvedKey = validatedApiKey(options.apiKey);
772
+ // ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
773
+ if (!resolvedKey) {
774
+ errLine("Ignoring the stored API key: invalid format. Run: tinyfish auth login");
775
+ }
753
776
  state.stage = "registration";
754
777
  errLine("Adding TinyFish to Cursor...");
755
778
  const mcpUrl = new URL(options.mcpUrl);
@@ -20,8 +20,26 @@ interface EnvironmentOptions {
20
20
  }
21
21
  export declare function persistApiKeyToEnvironment(apiKey: string, options?: EnvironmentOptions): void;
22
22
  export declare function clearConfig(): boolean;
23
+ /** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
24
+ export declare function isHeaderSafe(key: string): boolean;
23
25
  export declare function validateKeyFormat(key: string): boolean;
24
26
  export declare function maskKey(key: string): string;
27
+ export type KeySource = "explicit" | "env" | "config" | "none";
28
+ export interface ResolvedKey {
29
+ key: string;
30
+ source: Exclude<KeySource, "none">;
31
+ }
32
+ export interface UnresolvedKey {
33
+ error: string;
34
+ source: KeySource;
35
+ }
25
36
  /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
26
37
  export declare function resolvedApiKey(explicitApiKey?: string): string | undefined;
38
+ /** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
39
+ export declare function validatedApiKey(explicitApiKey?: string): string | undefined;
40
+ /**
41
+ * The diagnosis, not the key: doctor must report a bad credential and say where it came from
42
+ * rather than exit like getApiKey does.
43
+ */
44
+ export declare function apiKeyStatus(): ResolvedKey | UnresolvedKey;
27
45
  export declare function getApiKey(): string;
package/dist/lib/auth.js CHANGED
@@ -132,52 +132,62 @@ export function clearConfig() {
132
132
  return false;
133
133
  }
134
134
  }
135
- export function validateKeyFormat(key) {
136
- // Reject control characters (covers CR, LF, and others) — guards against header injection
135
+ /** The one rule that is ours to enforce: the key becomes an HTTP header, so CR/LF cannot pass. */
136
+ export function isHeaderSafe(key) {
137
137
  for (let i = 0; i < key.length; i += 1) {
138
138
  if (key.charCodeAt(i) < 0x20)
139
139
  return false;
140
140
  }
141
- return KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
141
+ return true;
142
+ }
143
+ export function validateKeyFormat(key) {
144
+ return isHeaderSafe(key) && KEY_PREFIXES.some((p) => key.startsWith(p)) && key.length > MIN_KEY_LENGTH;
142
145
  }
143
146
  export function maskKey(key) {
144
147
  if (key.length <= 8)
145
148
  return "***";
146
149
  return key.slice(0, 12) + "..." + key.slice(-4);
147
150
  }
148
- /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
149
- export function resolvedApiKey(explicitApiKey) {
151
+ // The one flag/env/config precedence chain; `source` says which place a caller must name.
152
+ function lookupApiKey(explicitApiKey) {
150
153
  if (explicitApiKey)
151
- return explicitApiKey;
154
+ return { value: explicitApiKey, source: "explicit" };
152
155
  const envKey = process.env["TINYFISH_API_KEY"];
153
156
  if (envKey)
154
- return envKey;
155
- try {
156
- return loadConfig().api_key;
157
+ return { value: envKey, source: "env" };
158
+ // No try/catch: loadConfig swallows its own errors and returns {}.
159
+ const stored = loadConfig().api_key;
160
+ return stored ? { value: stored, source: "config" } : { source: "none" };
161
+ }
162
+ /** Key for optional paths (telemetry, auth probes) — absent is a valid answer, so never exits. */
163
+ export function resolvedApiKey(explicitApiKey) {
164
+ return lookupApiKey(explicitApiKey).value;
165
+ }
166
+ /** resolvedApiKey for keys that reach a config file or the wire — loadConfig() is unvalidated on read. */
167
+ export function validatedApiKey(explicitApiKey) {
168
+ const key = resolvedApiKey(explicitApiKey);
169
+ return key && validateKeyFormat(key) ? key : undefined;
170
+ }
171
+ /**
172
+ * The diagnosis, not the key: doctor must report a bad credential and say where it came from
173
+ * rather than exit like getApiKey does.
174
+ */
175
+ export function apiKeyStatus() {
176
+ const { value, source } = lookupApiKey();
177
+ if (!value || source === "none") {
178
+ return { error: "No API key found. Run: tinyfish auth login", source: "none" };
157
179
  }
158
- catch {
159
- return undefined;
180
+ // Shape is the server's call. A local prefix rule turns a key format we have not shipped
181
+ // support for yet into a hard failure the user cannot work around, and the 401 says more.
182
+ if (!isHeaderSafe(value)) {
183
+ return { error: "The API key contains control characters and cannot be sent", source };
160
184
  }
185
+ return { key: value, source };
161
186
  }
162
187
  export function getApiKey() {
163
- const envKey = process.env.TINYFISH_API_KEY;
164
- if (envKey) {
165
- if (!validateKeyFormat(envKey)) {
166
- err({
167
- error: "TINYFISH_API_KEY has invalid format. Expected sk-tinyfish-... or sk-mino-...",
168
- });
169
- process.exit(1);
170
- }
171
- return envKey;
172
- }
173
- const config = loadConfig();
174
- if (config.api_key) {
175
- if (!validateKeyFormat(config.api_key)) {
176
- err({ error: "Stored API key has invalid format. Run: tinyfish auth login" });
177
- process.exit(1);
178
- }
179
- return config.api_key;
180
- }
181
- err({ error: "No API key found. Run: tinyfish auth login" });
188
+ const resolved = apiKeyStatus();
189
+ if ("key" in resolved)
190
+ return resolved.key;
191
+ err({ error: resolved.error });
182
192
  process.exit(1);
183
193
  }
@@ -1,7 +1,7 @@
1
1
  import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, } from "../commands/connect.js";
4
- import { resolvedApiKey, validateKeyFormat } from "./auth.js";
4
+ import { validatedApiKey } from "./auth.js";
5
5
  import { detectInstalledHarnesses, FIVE_HARNESSES, } from "./harness-detect.js";
6
6
  import { detectHumanInitiated } from "./harness.js";
7
7
  import { emitNotice } from "./notice.js";
@@ -29,10 +29,6 @@ const UNINSTALL_POINTERS = {
29
29
  hermes: "hermes mcp remove tinyfish",
30
30
  openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
31
31
  };
32
- function hasStoredAuth(explicitApiKey) {
33
- const key = resolvedApiKey(explicitApiKey);
34
- return !!key && validateKeyFormat(key);
35
- }
36
32
  async function runHarnessConnect(harness, opts) {
37
33
  const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
38
34
  if (harness === "claude-code")
@@ -102,7 +98,7 @@ async function processHarness(detection, opts, prompt) {
102
98
  if (opts.dryRun) {
103
99
  const plan = opts.uninstall
104
100
  ? uninstallPlanText(harness)
105
- : planText(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
101
+ : planText(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
106
102
  return { ...base, outcome: "dry_run", fixCommand: plan };
107
103
  }
108
104
  if (opts.uninstall)
@@ -110,7 +106,7 @@ async function processHarness(detection, opts, prompt) {
110
106
  const isTTY = detectHumanInitiated();
111
107
  // claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
112
108
  // X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
113
- const keyed = hasStoredAuth(opts.apiKey);
109
+ const keyed = !!validatedApiKey(opts.apiKey);
114
110
  const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
115
111
  const alreadyAuthed = !ownAuthHarness && keyed;
116
112
  if (!isTTY && !alreadyAuthed) {
@@ -142,7 +138,7 @@ async function processHarness(detection, opts, prompt) {
142
138
  if (!interruptedBefore && process.exitCode === 130) {
143
139
  return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
144
140
  }
145
- const verify = await verifyHarness(harness, opts.mcpUrl, resolvedApiKey(opts.apiKey));
141
+ const verify = await verifyHarness(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
146
142
  return {
147
143
  ...base,
148
144
  installed: true,
@@ -279,7 +275,7 @@ export async function runConnectAll(opts) {
279
275
  verify_depth: r.verifyDepth ?? null,
280
276
  verify_ok: r.verifyOk ?? null,
281
277
  }));
282
- await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, resolvedApiKey(opts.apiKey));
278
+ await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey));
283
279
  // After the summary and before the launch, which is where the user is reading.
284
280
  emitNotice();
285
281
  }
@@ -9,6 +9,15 @@ export interface CursorMcpWriteResult {
9
9
  }
10
10
  /** Dry-run description of the pending write; touches nothing. */
11
11
  export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
12
+ export interface CursorTinyfishEntry {
13
+ present: boolean;
14
+ hasApiKeyHeader: boolean;
15
+ /** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
16
+ url?: string;
17
+ error?: string;
18
+ }
19
+ /** Read-only probe for doctor — reports shape, never the header value. */
20
+ export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
12
21
  /** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
13
22
  export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
14
23
  /** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
@@ -65,6 +65,25 @@ export function planCursorWrite(mcpUrl, apiKey) {
65
65
  ? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
66
66
  : `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
67
67
  }
68
+ /** Read-only probe for doctor — reports shape, never the header value. */
69
+ export function readCursorTinyfishEntry() {
70
+ const existing = readExisting();
71
+ if ("error" in existing)
72
+ return { present: false, hasApiKeyHeader: false, error: existing.error };
73
+ const servers = existing.parsed.mcpServers;
74
+ const entry = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
75
+ if (!isPlainRecord(entry))
76
+ return { present: false, hasApiKeyHeader: false };
77
+ const headers = entry.headers;
78
+ // Header names are case-insensitive, and this file is hand-editable — matching only the
79
+ // casing we write would understate auth mode for a user who typed it differently.
80
+ return {
81
+ present: true,
82
+ hasApiKeyHeader: isPlainRecord(headers) &&
83
+ Object.entries(headers).some(([name, value]) => name.toLowerCase() === "x-api-key" && typeof value === "string"),
84
+ ...(typeof entry.url === "string" ? { url: entry.url } : {}),
85
+ };
86
+ }
68
87
  // Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
69
88
  function commitServers(filePath, existing, servers) {
70
89
  fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
3
+ export declare const DOCTOR_SCHEMA_VERSION = 1;
4
+ declare const checkStatusSchema: z.ZodEnum<{
5
+ pass: "pass";
6
+ fail: "fail";
7
+ warn: "warn";
8
+ skip: "skip";
9
+ }>;
10
+ declare const doctorCheckSchema: z.ZodObject<{
11
+ id: z.ZodString;
12
+ title: z.ZodString;
13
+ status: z.ZodEnum<{
14
+ pass: "pass";
15
+ fail: "fail";
16
+ warn: "warn";
17
+ skip: "skip";
18
+ }>;
19
+ detail: z.ZodString;
20
+ harness: z.ZodNullable<z.ZodEnum<{
21
+ openclaw: "openclaw";
22
+ cursor: "cursor";
23
+ codex: "codex";
24
+ hermes: "hermes";
25
+ "claude-code": "claude-code";
26
+ }>>;
27
+ }, z.core.$strip>;
28
+ declare const doctorHarnessSchema: z.ZodObject<{
29
+ harness: z.ZodEnum<{
30
+ openclaw: "openclaw";
31
+ cursor: "cursor";
32
+ codex: "codex";
33
+ hermes: "hermes";
34
+ "claude-code": "claude-code";
35
+ }>;
36
+ detected: z.ZodBoolean;
37
+ registered: z.ZodEnum<{
38
+ unknown: "unknown";
39
+ yes: "yes";
40
+ no: "no";
41
+ }>;
42
+ auth_mode: z.ZodEnum<{
43
+ unknown: "unknown";
44
+ oauth: "oauth";
45
+ "api-key": "api-key";
46
+ }>;
47
+ proves_harness_reach: z.ZodBoolean;
48
+ }, z.core.$strip>;
49
+ declare const doctorRepairSchema: z.ZodObject<{
50
+ for: z.ZodString;
51
+ action: z.ZodEnum<{
52
+ connect: "connect";
53
+ "auth-login": "auth-login";
54
+ }>;
55
+ harness: z.ZodNullable<z.ZodEnum<{
56
+ openclaw: "openclaw";
57
+ cursor: "cursor";
58
+ codex: "codex";
59
+ hermes: "hermes";
60
+ "claude-code": "claude-code";
61
+ }>>;
62
+ command: z.ZodString;
63
+ unattended_safe: z.ZodBoolean;
64
+ }, z.core.$strip>;
65
+ export declare const doctorReportSchema: z.ZodObject<{
66
+ schema_version: z.ZodInt;
67
+ cli_version: z.ZodString;
68
+ ok: z.ZodBoolean;
69
+ checks: z.ZodArray<z.ZodObject<{
70
+ id: z.ZodString;
71
+ title: z.ZodString;
72
+ status: z.ZodEnum<{
73
+ pass: "pass";
74
+ fail: "fail";
75
+ warn: "warn";
76
+ skip: "skip";
77
+ }>;
78
+ detail: z.ZodString;
79
+ harness: z.ZodNullable<z.ZodEnum<{
80
+ openclaw: "openclaw";
81
+ cursor: "cursor";
82
+ codex: "codex";
83
+ hermes: "hermes";
84
+ "claude-code": "claude-code";
85
+ }>>;
86
+ }, z.core.$strip>>;
87
+ harnesses: z.ZodArray<z.ZodObject<{
88
+ harness: z.ZodEnum<{
89
+ openclaw: "openclaw";
90
+ cursor: "cursor";
91
+ codex: "codex";
92
+ hermes: "hermes";
93
+ "claude-code": "claude-code";
94
+ }>;
95
+ detected: z.ZodBoolean;
96
+ registered: z.ZodEnum<{
97
+ unknown: "unknown";
98
+ yes: "yes";
99
+ no: "no";
100
+ }>;
101
+ auth_mode: z.ZodEnum<{
102
+ unknown: "unknown";
103
+ oauth: "oauth";
104
+ "api-key": "api-key";
105
+ }>;
106
+ proves_harness_reach: z.ZodBoolean;
107
+ }, z.core.$strip>>;
108
+ repairs: z.ZodArray<z.ZodObject<{
109
+ for: z.ZodString;
110
+ action: z.ZodEnum<{
111
+ connect: "connect";
112
+ "auth-login": "auth-login";
113
+ }>;
114
+ harness: z.ZodNullable<z.ZodEnum<{
115
+ openclaw: "openclaw";
116
+ cursor: "cursor";
117
+ codex: "codex";
118
+ hermes: "hermes";
119
+ "claude-code": "claude-code";
120
+ }>>;
121
+ command: z.ZodString;
122
+ unattended_safe: z.ZodBoolean;
123
+ }, z.core.$strip>>;
124
+ }, z.core.$strip>;
125
+ export type CheckStatus = z.infer<typeof checkStatusSchema>;
126
+ export type DoctorCheck = z.infer<typeof doctorCheckSchema>;
127
+ export type DoctorHarness = z.infer<typeof doctorHarnessSchema>;
128
+ export type DoctorRepair = z.infer<typeof doctorRepairSchema>;
129
+ export type DoctorReport = z.infer<typeof doctorReportSchema>;
130
+ /** Doctor itself broke, which is a different claim from "your setup is broken". */
131
+ export declare const DOCTOR_COULD_NOT_RUN = 2;
132
+ /** Skips never fail the run; a skip means not applicable, not broken. */
133
+ export declare function exitCodeFor(checks: DoctorCheck[]): 0 | 1;
134
+ export declare function renderPretty(report: DoctorReport): string;
135
+ export {};
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { FIVE_HARNESSES } from "./harness-detect.js";
3
+ /** Bumped whenever a consumer could misread the payload; the cookbook skill releases separately. */
4
+ export const DOCTOR_SCHEMA_VERSION = 1;
5
+ const checkStatusSchema = z.enum(["pass", "fail", "warn", "skip"]);
6
+ const harnessSchema = z.enum(FIVE_HARNESSES);
7
+ const doctorCheckSchema = z.object({
8
+ id: z.string(),
9
+ title: z.string(),
10
+ status: checkStatusSchema,
11
+ detail: z.string(),
12
+ harness: harnessSchema.nullable(),
13
+ });
14
+ const doctorHarnessSchema = z.object({
15
+ harness: harnessSchema,
16
+ detected: z.boolean(),
17
+ registered: z.enum(["yes", "no", "unknown"]),
18
+ auth_mode: z.enum(["api-key", "oauth", "unknown"]),
19
+ proves_harness_reach: z.boolean(),
20
+ });
21
+ // `action` is what `--fix` dispatches on, not the harness field: keying off a null harness
22
+ // conflated "run auth login" with a non-executable repair and the credential fix never ran.
23
+ const doctorRepairSchema = z.object({
24
+ for: z.string(),
25
+ action: z.enum(["connect", "auth-login"]),
26
+ harness: harnessSchema.nullable(),
27
+ command: z.string(),
28
+ unattended_safe: z.boolean(),
29
+ });
30
+ export const doctorReportSchema = z
31
+ .object({
32
+ // Not `z.literal`: a consumer must be able to read a future version and say so.
33
+ schema_version: z.int().positive(),
34
+ cli_version: z.string(),
35
+ ok: z.boolean(),
36
+ checks: z.array(doctorCheckSchema),
37
+ harnesses: z.array(doctorHarnessSchema),
38
+ repairs: z.array(doctorRepairSchema),
39
+ })
40
+ // A report that says ok while a check failed is worse than no report.
41
+ .refine((report) => !report.ok || report.checks.every((check) => check.status !== "fail"), {
42
+ message: "ok cannot be true while a check has failed",
43
+ path: ["ok"],
44
+ });
45
+ const GLYPHS = { pass: "✓", fail: "✗", warn: "⚠", skip: "…" };
46
+ /** Doctor itself broke, which is a different claim from "your setup is broken". */
47
+ export const DOCTOR_COULD_NOT_RUN = 2;
48
+ /** Skips never fail the run; a skip means not applicable, not broken. */
49
+ export function exitCodeFor(checks) {
50
+ return checks.some((check) => check.status === "fail") ? 1 : 0;
51
+ }
52
+ export function renderPretty(report) {
53
+ const lines = report.checks.map((c) => `${GLYPHS[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
54
+ if (report.repairs.length > 0) {
55
+ lines.push("", "Repairs:");
56
+ for (const repair of report.repairs)
57
+ lines.push(` ${repair.command}`);
58
+ }
59
+ return lines.join("\n");
60
+ }
@@ -1,6 +1,8 @@
1
1
  export declare const FIVE_HARNESSES: readonly ["claude-code", "codex", "cursor", "hermes", "openclaw"];
2
2
  export type FiveHarness = (typeof FIVE_HARNESSES)[number];
3
3
  export declare function harnessConfigPath(harness: FiveHarness): string;
4
+ /** For reason strings that name a location; keeps them in sync with the table above. */
5
+ export declare function harnessDisplayPath(harness: FiveHarness): string;
4
6
  export interface HarnessDetection {
5
7
  harness: FiveHarness;
6
8
  detected: boolean;
@@ -13,6 +13,10 @@ const CONFIG_DIRS = {
13
13
  export function harnessConfigPath(harness) {
14
14
  return path.join(os.homedir(), CONFIG_DIRS[harness]); // nosemgrep: path-join-resolve-traversal -- fixed dir names under os.homedir()
15
15
  }
16
+ /** For reason strings that name a location; keeps them in sync with the table above. */
17
+ export function harnessDisplayPath(harness) {
18
+ return `~/${CONFIG_DIRS[harness]}`;
19
+ }
16
20
  export function detectInstalledHarnesses() {
17
21
  return FIVE_HARNESSES.map((harness) => {
18
22
  const configPath = harnessConfigPath(harness);
@@ -0,0 +1,15 @@
1
+ import { type FiveHarness } from "./harness-detect.js";
2
+ export type Registered = "yes" | "no" | "unknown";
3
+ export type AuthMode = "api-key" | "oauth" | "unknown";
4
+ export interface RegistrationStatus {
5
+ harness: FiveHarness;
6
+ detected: boolean;
7
+ registered: Registered;
8
+ authMode: AuthMode;
9
+ connectedBefore: boolean;
10
+ /** The endpoint the harness is actually pointed at, when the probe exposes it. */
11
+ registeredUrl?: string;
12
+ reason?: string;
13
+ }
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[];
@@ -0,0 +1,239 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import spawn from "cross-spawn";
4
+ import { z } from "zod";
5
+ import { loadConfig } from "./auth.js";
6
+ import { errLine } from "./output.js";
7
+ import { readCursorTinyfishEntry } from "./cursor-config.js";
8
+ import { detectInstalledHarnesses, harnessConfigPath, harnessDisplayPath, } from "./harness-detect.js";
9
+ // 2s tripped on a cold `hermes mcp list` (0.3s warm), reporting `unknown` on a healthy install.
10
+ const PROBE_TIMEOUT_MS = 6_000;
11
+ // A list, verified against codex 0.146. A name-keyed map was accepted here too, but every
12
+ // field is optional, so that arm parsed *any* object-of-objects: a wrapper like
13
+ // `{"servers": {...}}` decoded as one entry named `servers` and reported TinyFish absent.
14
+ const codexEntrySchema = z.looseObject({
15
+ name: z.string().optional(),
16
+ auth_status: z.string().optional(),
17
+ transport: z
18
+ .looseObject({ url: z.string().optional(), bearer_token_env_var: z.string().nullish() })
19
+ .optional(),
20
+ });
21
+ const codexListSchema = z.array(codexEntrySchema);
22
+ // An old harness CLI without the subcommand exits non-zero too; that is unknown, not absent.
23
+ const UNSUPPORTED_SUBCOMMAND = /unknown command|unrecognized subcommand|invalid subcommand/i;
24
+ // `claude mcp get` exits 1 for a usage error too, so only this message is evidence of absence.
25
+ const NO_SUCH_SERVER = /no (?:mcp )?server named/i;
26
+ const API_KEY_HEADER = /x-api-key/i;
27
+ // The report is pasted in public, so every reason is authored here. A spawn error's own
28
+ // message quotes absolute paths, which is how a home directory reaches the payload.
29
+ function spawnFailureReason(command, code) {
30
+ if (code === "ENOENT")
31
+ return `\`${command}\` is not on PATH`;
32
+ if (code === "EACCES")
33
+ return `\`${command}\` is not executable`;
34
+ return `could not run \`${command}\``;
35
+ }
36
+ function runProbe(command, args) {
37
+ const result = spawn.sync(command, args, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
38
+ if (result.error) {
39
+ const code = result.error.code;
40
+ // A timeout arrives as ETIMEDOUT here, not only as a signal, so it must be named before
41
+ // the generic spawn failure; otherwise a timeout reports "could not run".
42
+ if (code === "ETIMEDOUT") {
43
+ return { outcome: "unavailable", reason: `\`${command} ${args.join(" ")}\` timed out` };
44
+ }
45
+ return { outcome: "unavailable", reason: spawnFailureReason(command, code) };
46
+ }
47
+ // Reached only when something else killed the child, so this is a crash, not the timeout.
48
+ if (result.signal) {
49
+ return { outcome: "unavailable", reason: `\`${command}\` was killed by ${result.signal}` };
50
+ }
51
+ const stdout = result.stdout ?? "";
52
+ return {
53
+ outcome: "ran",
54
+ exitCode: result.status ?? 1,
55
+ stdout,
56
+ output: `${stdout}${result.stderr ?? ""}`,
57
+ };
58
+ }
59
+ // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
60
+ function fromMcpGet(command, keyAuthPattern) {
61
+ const probe = runProbe(command, ["mcp", "get", "tinyfish"]);
62
+ if (probe.outcome === "unavailable") {
63
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
64
+ }
65
+ if (probe.exitCode !== 0) {
66
+ if (UNSUPPORTED_SUBCOMMAND.test(probe.output)) {
67
+ return {
68
+ registered: "unknown",
69
+ authMode: "unknown",
70
+ reason: `\`${command} mcp get\` is unsupported by this version`,
71
+ };
72
+ }
73
+ // A broken CLI also exits nonzero, and reading that as absence earns a spurious repair.
74
+ if (!NO_SUCH_SERVER.test(probe.output)) {
75
+ return {
76
+ registered: "unknown",
77
+ authMode: "unknown",
78
+ reason: `\`${command} mcp get\` failed without reporting the server as absent`,
79
+ };
80
+ }
81
+ return { registered: "no", authMode: "unknown" };
82
+ }
83
+ // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
84
+ // of the pattern is `unknown`, never proof of OAuth.
85
+ const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
86
+ return {
87
+ registered: "yes",
88
+ authMode: keyAuthPattern.test(probe.output) ? "api-key" : "unknown",
89
+ ...(url ? { registeredUrl: url } : {}),
90
+ };
91
+ }
92
+ // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
93
+ // its exit code; `mcp list --json` is the only trustworthy read-back.
94
+ function probeCodex() {
95
+ const probe = runProbe("codex", ["mcp", "list", "--json"]);
96
+ if (probe.outcome === "unavailable") {
97
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
98
+ }
99
+ // Presence is never inferred from exit code 0, but a nonzero exit means the output cannot
100
+ // be trusted either — that is `unknown`, not "no server".
101
+ if (probe.exitCode !== 0) {
102
+ return {
103
+ registered: "unknown",
104
+ authMode: "unknown",
105
+ reason: `\`codex mcp list --json\` exited ${probe.exitCode}`,
106
+ };
107
+ }
108
+ let servers;
109
+ try {
110
+ // stdout only: an update banner on stderr would otherwise break the parse and report a
111
+ // healthy install as `unknown`.
112
+ servers = JSON.parse(probe.stdout);
113
+ }
114
+ catch {
115
+ return {
116
+ registered: "unknown",
117
+ authMode: "unknown",
118
+ reason: "`codex mcp list --json` returned unparseable output",
119
+ };
120
+ }
121
+ // A scalar or a wrapper object decodes fine and then silently reports "no server", which
122
+ // would suggest a spurious repair. Anything but a list is unknown.
123
+ const shape = codexListSchema.safeParse(servers);
124
+ if (!shape.success) {
125
+ return {
126
+ registered: "unknown",
127
+ authMode: "unknown",
128
+ reason: "`codex mcp list --json` returned an unexpected shape",
129
+ };
130
+ }
131
+ const entry = shape.data.find((s) => s.name === "tinyfish");
132
+ if (!entry)
133
+ return { registered: "no", authMode: "unknown" };
134
+ // auth_status is the configured auth type, not whether a login succeeded — exactly auth_mode.
135
+ const codexUrl = entry.transport?.url;
136
+ return {
137
+ registered: "yes",
138
+ // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
139
+ // the whole payload reports api-key for TinyFish because some other server carries a key.
140
+ authMode: entry.auth_status === "o_auth"
141
+ ? "oauth"
142
+ : entry.transport?.bearer_token_env_var
143
+ ? "api-key"
144
+ : "unknown",
145
+ ...(codexUrl ? { registeredUrl: codexUrl } : {}),
146
+ };
147
+ }
148
+ function probeCursor() {
149
+ const entry = readCursorTinyfishEntry();
150
+ if (entry.error) {
151
+ return {
152
+ registered: "unknown",
153
+ authMode: "unknown",
154
+ reason: "mcp.json exists but could not be read or parsed",
155
+ };
156
+ }
157
+ if (!entry.present)
158
+ return { registered: "no", authMode: "unknown" };
159
+ return {
160
+ registered: "yes",
161
+ authMode: entry.hasApiKeyHeader ? "api-key" : "unknown",
162
+ ...(entry.url ? { registeredUrl: entry.url } : {}),
163
+ };
164
+ }
165
+ // Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
166
+ function probeHermes() {
167
+ const probe = runProbe("hermes", ["mcp", "list"]);
168
+ if (probe.outcome === "unavailable") {
169
+ return { registered: "unknown", authMode: "unknown", reason: probe.reason };
170
+ }
171
+ if (probe.exitCode !== 0) {
172
+ return {
173
+ registered: "unknown",
174
+ authMode: "unknown",
175
+ reason: `\`hermes mcp list\` exited ${probe.exitCode}`,
176
+ };
177
+ }
178
+ return probe.output.includes("tinyfish")
179
+ ? { registered: "yes", authMode: "oauth" }
180
+ : { registered: "no", authMode: "unknown" };
181
+ }
182
+ // OpenClaw installs a skill, not an MCP server; the skill shells the CLI, so auth is the CLI key.
183
+ function probeOpenClaw() {
184
+ const skillsDir = path.join(harnessConfigPath("openclaw"), "skills");
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(skillsDir);
188
+ }
189
+ catch {
190
+ return {
191
+ registered: "unknown",
192
+ authMode: "unknown",
193
+ reason: `no global skills directory at ${harnessDisplayPath("openclaw")}/skills; OpenClaw layout is unverified`,
194
+ };
195
+ }
196
+ return entries.some((name) => /tinyfish/i.test(name))
197
+ ? { registered: "yes", authMode: "api-key" }
198
+ : { registered: "no", authMode: "unknown" };
199
+ }
200
+ const PROBES = {
201
+ "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
202
+ codex: probeCodex,
203
+ cursor: probeCursor,
204
+ hermes: probeHermes,
205
+ openclaw: probeOpenClaw,
206
+ };
207
+ /** Never throws; a failed probe is `unknown` with a reason, so no caller can read it as healthy. */
208
+ export function detectRegistrations(harnesses) {
209
+ const connect = loadConfig().connect ?? {};
210
+ return detectInstalledHarnesses()
211
+ .filter((detection) => !harnesses || harnesses.includes(detection.harness))
212
+ .map((detection) => {
213
+ const base = {
214
+ harness: detection.harness,
215
+ detected: detection.detected,
216
+ connectedBefore: detection.harness in connect,
217
+ };
218
+ // Undetected means nothing to probe: no spawn cost, and no misleading "unknown".
219
+ if (!detection.detected) {
220
+ return { ...base, registered: "no", authMode: "unknown" };
221
+ }
222
+ try {
223
+ return { ...base, ...PROBES[detection.harness]() };
224
+ }
225
+ catch (e) {
226
+ // An arbitrary throw is the one message we cannot author, so it never ships. Full
227
+ // fidelity stays on stderr behind --debug, which is not the pasted artifact.
228
+ if (process.env["TINYFISH_DEBUG"]) {
229
+ errLine(`probe ${detection.harness} threw: ${e instanceof Error ? e.message : String(e)}`);
230
+ }
231
+ return {
232
+ ...base,
233
+ registered: "unknown",
234
+ authMode: "unknown",
235
+ reason: "the probe failed unexpectedly",
236
+ };
237
+ }
238
+ });
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.14.1-next.154",
3
+ "version": "0.15.1-next.159",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {