@tiny-fish/cli 0.20.1 → 0.21.1-next.194

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.
@@ -2,6 +2,7 @@ import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
4
  import { validatedApiKey } from "./auth.js";
5
+ import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
5
6
  import { detectInstalledHarnesses, ALL_HARNESSES, } from "./harness-detect.js";
6
7
  import { detectHumanInitiated } from "./harness.js";
7
8
  import { emitNotice } from "./notice.js";
@@ -20,10 +21,14 @@ const DISPLAY_NAMES = {
20
21
  // `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
21
22
  // TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
22
23
  const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
23
- // Harnesses that drive their own OAuth at install — a stored TinyFish key says nothing
24
- // about their session, so it must never unlock a headless run for them.
25
- const HARNESS_OWN_AUTH = new Set(["claude-code", "codex", "hermes", "opencode"]);
26
24
  const AUTH_CHECK_CAPABLE = new Set(["cursor", "openclaw"]);
25
+ // Descriptor-derived so a harness that cannot carry a key is skipped, not failed, when headless.
26
+ // Cursor and OpenClaw hold the key themselves rather than handing it to a harness `mcp add`.
27
+ const KEY_AUTH_CAPABLE = new Set([
28
+ ...NATIVE_MCP_CLIENTS.filter((client) => !!client.keyAuth).map((client) => client.connectClient),
29
+ "cursor",
30
+ "openclaw",
31
+ ]);
27
32
  // Real removal commands — harness id is not the binary name.
28
33
  const UNINSTALL_POINTERS = {
29
34
  "claude-code": "claude mcp remove tinyfish --scope user",
@@ -33,24 +38,28 @@ const UNINSTALL_POINTERS = {
33
38
  // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
34
39
  opencode: "remove the tinyfish entry from ~/.config/opencode/opencode.json",
35
40
  };
36
- async function runHarnessConnect(harness, opts) {
37
- const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false };
41
+ async function runHarnessConnect(harness, opts, keyAuthOnly) {
42
+ const base = { apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false, keyAuthOnly };
38
43
  if (harness === "claude-code")
39
44
  return connectClaudeCode(base);
40
45
  if (harness === "codex")
41
46
  return connectCodex(base);
42
47
  if (harness === "hermes")
43
48
  return connectHermes(base);
44
- if (harness === "openclaw")
45
- return connectOpenClaw({ mcpUrl: opts.mcpUrl, launch: false });
49
+ if (harness === "openclaw") {
50
+ // Without the key the child shells an interactive `tinyfish auth login`.
51
+ await connectOpenClaw({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl, launch: false });
52
+ return undefined;
53
+ }
46
54
  if (harness === "opencode")
47
55
  return connectOpencode(base);
48
- return connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
56
+ await connectCursor({ apiKey: opts.apiKey, mcpUrl: opts.mcpUrl });
57
+ return undefined;
49
58
  }
50
- async function verifyHarness(harness, mcpUrl, apiKey) {
59
+ async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
51
60
  const health = await verifyMcpHealth(mcpUrl);
52
- // claude-code is auth-checkable only in key mode (header-authed config).
53
- const capable = AUTH_CHECK_CAPABLE.has(harness) || (harness === "claude-code" && !!apiKey);
61
+ // The own-OAuth harnesses are auth-checkable only in key mode (key-authed config).
62
+ const capable = AUTH_CHECK_CAPABLE.has(harness) || keyed;
54
63
  if (!capable || !health.ok)
55
64
  return health;
56
65
  if (!apiKey)
@@ -110,15 +119,18 @@ async function processHarness(detection, opts, prompt) {
110
119
  if (opts.uninstall)
111
120
  return uninstallHarness(harness, configPath);
112
121
  const isTTY = detectHumanInitiated();
113
- // claude-code leaves the own-OAuth set when a key is stored: `mcp add --header
114
- // X-API-Key` replaces its login. codex/hermes have no header path, so they keep the skip.
115
- const keyed = !!validatedApiKey(opts.apiKey);
116
- const ownAuthHarness = HARNESS_OWN_AUTH.has(harness) && !(harness === "claude-code" && keyed);
117
- const alreadyAuthed = !ownAuthHarness && keyed;
118
- if (!isTTY && !alreadyAuthed) {
119
- return { ...base, outcome: "no_tty_auth_skip", fixCommand: `tinyfish connect ${harness}` };
122
+ const apiKey = validatedApiKey(opts.apiKey);
123
+ // A key only leaves the own-OAuth set where the harness can actually carry one.
124
+ const keyed = !!apiKey && KEY_AUTH_CAPABLE.has(harness);
125
+ if (!isTTY && !keyed) {
126
+ return {
127
+ ...base,
128
+ outcome: "no_tty_auth_skip",
129
+ keyUnusable: !!apiKey,
130
+ fixCommand: `tinyfish connect ${harness}`,
131
+ };
120
132
  }
121
- if (isTTY && !alreadyAuthed && prompt) {
133
+ if (isTTY && !keyed && prompt) {
122
134
  errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[harness]}`);
123
135
  try {
124
136
  await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[harness]}... `);
@@ -130,8 +142,10 @@ async function processHarness(detection, opts, prompt) {
130
142
  }
131
143
  // runGuarded swallows Ctrl+C and sets exitCode 130 instead of throwing.
132
144
  const interruptedBefore = process.exitCode === 130;
145
+ let authMode;
133
146
  try {
134
- await runHarnessConnect(harness, opts);
147
+ // Headless: an install too old for key auth must fail, not open a browser.
148
+ authMode = await runHarnessConnect(harness, opts, !isTTY);
135
149
  }
136
150
  catch (e) {
137
151
  return {
@@ -144,7 +158,9 @@ async function processHarness(detection, opts, prompt) {
144
158
  if (!interruptedBefore && process.exitCode === 130) {
145
159
  return { ...base, outcome: "interrupted", fixCommand: `tinyfish connect ${harness}` };
146
160
  }
147
- const verify = await verifyHarness(harness, opts.mcpUrl, validatedApiKey(opts.apiKey));
161
+ // The probe, not the flag, decides: a harness that degraded to OAuth is not key-authed.
162
+ const keyAuthed = authMode ? authMode === "api-key" : keyed;
163
+ const verify = await verifyHarness(harness, opts.mcpUrl, keyAuthed, apiKey);
148
164
  return {
149
165
  ...base,
150
166
  installed: true,
@@ -152,6 +168,7 @@ async function processHarness(detection, opts, prompt) {
152
168
  verifyDepth: verify.depth,
153
169
  verifyOk: verify.ok,
154
170
  verifyReason: verify.reason,
171
+ keyAuthed,
155
172
  };
156
173
  }
157
174
  function summaryLine(result, uninstall) {
@@ -169,7 +186,10 @@ function summaryLine(result, uninstall) {
169
186
  return `… ${name} — ${result.fixCommand}`;
170
187
  }
171
188
  if (result.outcome === "no_tty_auth_skip") {
172
- return `… ${name} — skipped, no stored credentials and no terminal to sign in. Fix: ${result.fixCommand}`;
189
+ const why = result.keyUnusable
190
+ ? `${name} cannot take an API key and there is no terminal to sign in`
191
+ : "no stored credentials and no terminal to sign in";
192
+ return `… ${name} — skipped, ${why}. Fix: ${result.fixCommand}`;
173
193
  }
174
194
  if (result.outcome === "uninstall_pointer") {
175
195
  return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
@@ -188,7 +208,8 @@ function summaryLine(result, uninstall) {
188
208
  if (result.outcome === "failed") {
189
209
  return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
190
210
  }
191
- const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness);
211
+ // A keyed install has no sign-in left, so the deferred-auth warning would contradict it.
212
+ const authDeferred = AUTH_DEFERRED_AT_INSTALL.has(result.harness) && !result.keyAuthed;
192
213
  const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : "not verified";
193
214
  if (authDeferred || result.verifyOk === false) {
194
215
  const reason = authDeferred
@@ -6,8 +6,12 @@ export declare const OPENCLAW_ONBOARDING_PROMPT: string;
6
6
  export interface NativeMcpClient extends SupportedCommand {
7
7
  connectClient: Exclude<AgentClient, "openclaw">;
8
8
  skillAgent: "claude-code" | "codex" | "hermes-agent" | "opencode";
9
- /** `mcp add --header` support: a stored TinyFish key replaces the OAuth login. */
10
- headerAuthSupported?: boolean;
9
+ /** Replaces `addArgs` when a stored key is available: key auth removes the OAuth hop. */
10
+ keyAuth?: {
11
+ /** Key reaches the client through TINYFISH_API_KEY, not argv. */
12
+ viaEnv?: boolean;
13
+ addArgs: (mcpUrl: string, apiKey: string) => string[];
14
+ };
11
15
  loginArgs?: string[];
12
16
  /** Replaces the `loginArgs` step when `supportCheck.optionalPatterns` miss. */
13
17
  degradedAuthNotes?: {
@@ -35,6 +39,8 @@ export declare function openExternalUrl(url: string): ReturnType<typeof spawn.sy
35
39
  export declare const CODEX: NativeMcpClient;
36
40
  export declare const HERMES: NativeMcpClient;
37
41
  export declare const OPENCODE: NativeMcpClient;
42
+ /** Every native MCP client. Derive from this, so a new harness cannot be missed. */
43
+ export declare const NATIVE_MCP_CLIENTS: readonly NativeMcpClient[];
38
44
  export declare const OPENCLAW: SupportedCommand;
39
45
  export declare function launchNativeMcpClient(client: NativeMcpClient): boolean;
40
46
  export declare function launchOpenClawWalkthrough(): boolean;
@@ -1,6 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { errLine, sanitizeLine } from "./output.js";
3
- import { throwIfInterrupted } from "./connect-runtime.js";
3
+ import { spawnStepError, } from "./connect-runtime.js";
4
4
  import { codexOauthCompleted } from "./registration-detect.js";
5
5
  const HERMES_SEED_TIMEOUT_MS = 120_000;
6
6
  // An old install is only one reason a flag can go unseen, so no message asserts the cause.
@@ -54,6 +54,22 @@ export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill.
54
54
  "want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
55
55
  "which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
56
56
  "I want to complete and wait before using TinyFish Agent. Never skip ahead or choose for me.";
57
+ const HEADER_FLAG = /(?:^|\s)--header(?:[\s<=]|$)/m;
58
+ const claudeAddArgs = (mcpUrl) => [
59
+ "mcp",
60
+ "add",
61
+ "--scope",
62
+ "user",
63
+ "--transport",
64
+ "http",
65
+ "tinyfish",
66
+ mcpUrl,
67
+ ];
68
+ const urlAddArgs = (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl];
69
+ // OpenCode takes `--header NAME=VALUE`, not Claude Code's `NAME: VALUE`.
70
+ const headerKeyAuth = {
71
+ addArgs: (mcpUrl, apiKey) => [...urlAddArgs(mcpUrl), "--header", `X-API-Key=${apiKey}`],
72
+ };
57
73
  export const CLAUDE_CODE = {
58
74
  command: "claude",
59
75
  connectClient: "claude-code",
@@ -63,9 +79,12 @@ export const CLAUDE_CODE = {
63
79
  args: ["mcp", "--help"],
64
80
  patterns: [/^\s*add(?:\s|\[)/m],
65
81
  optionalPatterns: [/^\s*login(?:\s|\[)/m],
82
+ keyAuthPattern: HEADER_FLAG,
66
83
  unavailableMessage: MCP_ADD_UNAVAILABLE_MESSAGE,
67
84
  },
68
- headerAuthSupported: true,
85
+ keyAuth: {
86
+ addArgs: (mcpUrl, apiKey) => [...claudeAddArgs(mcpUrl), "--header", `X-API-Key: ${apiKey}`],
87
+ },
69
88
  loginArgs: ["mcp", "login", "tinyfish"],
70
89
  degradedAuthNotes: {
71
90
  keyed: CLAUDE_CODE_KEYED_FALLBACK_NOTE,
@@ -79,7 +98,7 @@ export const CLAUDE_CODE = {
79
98
  label: "local TinyFish registration",
80
99
  },
81
100
  ],
82
- addArgs: (mcpUrl) => ["mcp", "add", "--scope", "user", "--transport", "http", "tinyfish", mcpUrl],
101
+ addArgs: (mcpUrl) => claudeAddArgs(mcpUrl),
83
102
  };
84
103
  /**
85
104
  * `cmd` re-parses its command line after `/c`, so an unquoted `&` in the query string ends the
@@ -103,7 +122,7 @@ function launchCodexWalkthrough() {
103
122
  deepLink.searchParams.set("path", process.cwd());
104
123
  const result = openExternalUrl(deepLink.toString());
105
124
  if (result.error || result.status !== 0) {
106
- throw new Error("Could not open Codex", { cause: result.error });
125
+ throw spawnStepError("Could not open Codex", result);
107
126
  }
108
127
  errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
109
128
  }
@@ -115,22 +134,21 @@ export const CODEX = {
115
134
  supportCheck: {
116
135
  args: ["mcp", "add", "--help"],
117
136
  patterns: [/--oauth-resource(?:\s|<)/],
137
+ keyAuthPattern: /(?:^|\s)--bearer-token-env-var(?:[\s<=]|$)/m,
118
138
  unavailableMessage: CODEX_OAUTH_RESOURCE_UNAVAILABLE_MESSAGE,
119
139
  },
140
+ // Codex has no `--header`; the MCP server takes a key as `Bearer sk-…`.
141
+ // Omitting `--oauth-resource` is what keeps `mcp add` from running OAuth.
142
+ keyAuth: {
143
+ viaEnv: true,
144
+ addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--bearer-token-env-var", "TINYFISH_API_KEY"],
145
+ },
120
146
  launchWalkthrough: launchCodexWalkthrough,
121
147
  probeAuthenticated: codexOauthCompleted,
122
148
  // Retry only: running it after a finished inline OAuth is a second browser hop.
123
149
  loginArgs: ["mcp", "login", "tinyfish"],
124
150
  removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
125
- addArgs: (mcpUrl, oauthResource) => [
126
- "mcp",
127
- "add",
128
- "tinyfish",
129
- "--url",
130
- mcpUrl,
131
- "--oauth-resource",
132
- oauthResource,
133
- ],
151
+ addArgs: (mcpUrl, oauthResource) => [...urlAddArgs(mcpUrl), "--oauth-resource", oauthResource],
134
152
  };
135
153
  function launchHermesWalkthrough() {
136
154
  const seedResult = spawn.sync("hermes", ["chat", "-Q", "-q", DEFAULT_ONBOARDING_PROMPT], {
@@ -138,10 +156,7 @@ function launchHermesWalkthrough() {
138
156
  timeout: HERMES_SEED_TIMEOUT_MS,
139
157
  });
140
158
  if (seedResult.error || seedResult.status !== 0) {
141
- throwIfInterrupted(seedResult);
142
- throw new Error("Could not start the TinyFish walkthrough in Hermes", {
143
- cause: seedResult.error,
144
- });
159
+ throw spawnStepError("Could not start the TinyFish walkthrough in Hermes", seedResult);
145
160
  }
146
161
  // A colourised id would carry its trailing escape sequence into `--resume` (PF-3452).
147
162
  const sessionId = sanitizeLine(seedResult.stderr ?? "").match(/session_id:\s*([^\s]+)/i)?.[1];
@@ -150,12 +165,12 @@ function launchHermesWalkthrough() {
150
165
  }
151
166
  const result = spawn.sync("hermes", ["--resume", sessionId], { stdio: "inherit" });
152
167
  if (result.error) {
153
- throw new Error("Could not launch Hermes", { cause: result.error });
168
+ throw spawnStepError("Could not launch Hermes", result);
154
169
  }
155
170
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
156
171
  return;
157
172
  if (result.status !== 0) {
158
- throw new Error(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`);
173
+ throw spawnStepError(`Hermes walkthrough exited with status ${result.status ?? "unknown"}`, result);
159
174
  }
160
175
  }
161
176
  export const HERMES = {
@@ -168,9 +183,10 @@ export const HERMES = {
168
183
  patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
169
184
  unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
170
185
  },
186
+ // No `keyAuth`: `hermes mcp add` has no --header, and `--auth header` prompts interactively.
171
187
  removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
172
188
  // Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
173
- addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl, "--auth", "oauth"],
189
+ addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--auth", "oauth"],
174
190
  launchWalkthrough: launchHermesWalkthrough,
175
191
  };
176
192
  // OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
@@ -181,12 +197,12 @@ function launchOpencode() {
181
197
  stdio: "inherit",
182
198
  });
183
199
  if (result.error) {
184
- throw new Error("Could not launch OpenCode", { cause: result.error });
200
+ throw spawnStepError("Could not launch OpenCode", result);
185
201
  }
186
202
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
187
203
  return;
188
204
  if (result.status !== 0) {
189
- throw new Error(`OpenCode exited with status ${result.status ?? "unknown"}`);
205
+ throw spawnStepError(`OpenCode exited with status ${result.status ?? "unknown"}`, result);
190
206
  }
191
207
  }
192
208
  export const OPENCODE = {
@@ -197,16 +213,25 @@ export const OPENCODE = {
197
213
  supportCheck: {
198
214
  args: ["mcp", "add", "--help"],
199
215
  patterns: [/--url(?:\s|$)/m],
216
+ keyAuthPattern: HEADER_FLAG,
200
217
  unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
201
218
  },
219
+ keyAuth: headerKeyAuth,
202
220
  // `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
203
221
  removals: [],
204
222
  // OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
205
- addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl],
223
+ addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
206
224
  loginArgs: ["mcp", "auth", "tinyfish"],
207
225
  launchWalkthrough: launchOpencode,
208
226
  postConnectNote: OPENCODE_MODEL_NOTE,
209
227
  };
228
+ /** Every native MCP client. Derive from this, so a new harness cannot be missed. */
229
+ export const NATIVE_MCP_CLIENTS = [
230
+ CLAUDE_CODE,
231
+ CODEX,
232
+ HERMES,
233
+ OPENCODE,
234
+ ];
210
235
  export const OPENCLAW = {
211
236
  command: "openclaw",
212
237
  displayName: "OpenClaw",
@@ -224,12 +249,12 @@ export function launchNativeMcpClient(client) {
224
249
  }
225
250
  const result = spawn.sync(client.command, [DEFAULT_ONBOARDING_PROMPT], { stdio: "inherit" });
226
251
  if (result.error) {
227
- throw new Error(`Could not launch ${client.displayName}`, { cause: result.error });
252
+ throw spawnStepError(`Could not launch ${client.displayName}`, result);
228
253
  }
229
254
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
230
255
  return false;
231
256
  if (result.status !== 0) {
232
- throw new Error(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`);
257
+ throw spawnStepError(`${client.displayName} walkthrough exited with status ${result.status ?? "unknown"}`, result);
233
258
  }
234
259
  return true;
235
260
  }
@@ -239,12 +264,12 @@ export function launchOpenClawWalkthrough() {
239
264
  stdio: "inherit",
240
265
  });
241
266
  if (result.error) {
242
- throw new Error("Could not launch OpenClaw", { cause: result.error });
267
+ throw spawnStepError("Could not launch OpenClaw", result);
243
268
  }
244
269
  if (result.signal === "SIGINT" || result.signal === "SIGTERM")
245
270
  return false;
246
271
  if (result.status !== 0) {
247
- throw new Error(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`);
272
+ throw spawnStepError(`OpenClaw walkthrough exited with status ${result.status ?? "unknown"}`, result);
248
273
  }
249
274
  return true;
250
275
  }
@@ -1,9 +1,10 @@
1
1
  import * as path from "node:path";
2
2
  import spawn from "cross-spawn";
3
3
  import { loadConfig, persistApiKeyToEnvironment, validateKeyFormat, writeConfig } from "./auth.js";
4
- import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, } from "./connect-clients.js";
5
- import { commandNotFound, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
4
+ import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
5
+ import { commandNotFound, ConnectStepError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "./connect-runtime.js";
6
6
  import { TINYFISH_CLI_PACKAGE } from "./constants.js";
7
+ import { installRoot } from "./install-root.js";
7
8
  import { errLine } from "./output.js";
8
9
  export const SKILL_INSTALL_TIMEOUT_MS = 120_000;
9
10
  export const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
@@ -15,13 +16,15 @@ const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
15
16
  const TINYFISH_WEB_SKILL = "use-tinyfish";
16
17
  export function installTinyFishCli() {
17
18
  errLine("Installing the TinyFish CLI...");
18
- const result = spawn.sync("npm", ["install", "--global", TINYFISH_CLI_INSTALL_SPEC], {
19
+ // Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
20
+ const root = installRoot();
21
+ const prefixArgs = root ? ["--prefix", root.prefix] : [];
22
+ const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], {
19
23
  stdio: "inherit",
20
24
  timeout: SKILL_INSTALL_TIMEOUT_MS,
21
25
  });
22
26
  if (result.error || result.status !== 0) {
23
- throwIfInterrupted(result);
24
- throw new Error("Could not install the TinyFish CLI", { cause: result.error });
27
+ throw spawnStepError("Could not install the TinyFish CLI", result);
25
28
  }
26
29
  }
27
30
  /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
@@ -68,10 +71,7 @@ export function installWebSkill(client) {
68
71
  if (output.trim())
69
72
  errLine(output.trimEnd());
70
73
  if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
71
- throwIfInterrupted(result);
72
- throw new Error(`Could not install the TinyFish web skill in ${client.displayName}`, {
73
- cause: result.error,
74
- });
74
+ throw spawnStepError(`Could not install the TinyFish web skill in ${client.displayName}`, result);
75
75
  }
76
76
  }
77
77
  /**
@@ -85,7 +85,7 @@ export function installWebSkill(client) {
85
85
  export function updateWebSkill() {
86
86
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
87
87
  const connected = loadConfig().connect ?? {};
88
- const skillAgents = [CLAUDE_CODE, CODEX, HERMES, OPENCODE]
88
+ const skillAgents = NATIVE_MCP_CLIENTS
89
89
  .filter((client) => connected[client.connectClient])
90
90
  .map((client) => client.skillAgent);
91
91
  const hasOpenClaw = Boolean(connected["openclaw"]);
@@ -160,7 +160,7 @@ export function ensureCliAuthenticated(source, apiKey) {
160
160
  const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
161
161
  if (envKey) {
162
162
  if (!validateKeyFormat(envKey)) {
163
- throw new Error("TINYFISH_API_KEY has invalid format");
163
+ throw new ConnectStepError("TINYFISH_API_KEY has invalid format", "invalid_config");
164
164
  }
165
165
  // Throwing variant: a write failure must reach connect's catch/flush, not exit(1) past it.
166
166
  writeConfig(envKey);
@@ -172,7 +172,7 @@ export function ensureCliAuthenticated(source, apiKey) {
172
172
  timeout: NON_INTERACTIVE_TIMEOUT_MS,
173
173
  });
174
174
  if (commandNotFound(authStatus.error)) {
175
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: authStatus.error });
175
+ throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, authStatus);
176
176
  }
177
177
  if (authStatus.status === 0)
178
178
  return;
@@ -180,17 +180,16 @@ export function ensureCliAuthenticated(source, apiKey) {
180
180
  // "not signed in" and drop the user into an interactive login they never asked for.
181
181
  throwIfInterrupted(authStatus);
182
182
  if (authStatus.error) {
183
- throw new Error("Could not read TinyFish CLI auth status", { cause: authStatus.error });
183
+ throw spawnStepError("Could not read TinyFish CLI auth status", authStatus);
184
184
  }
185
185
  errLine("Signing in to the TinyFish CLI...");
186
186
  const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", source], {
187
187
  stdio: "inherit",
188
188
  });
189
189
  if (commandNotFound(loginResult.error)) {
190
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
190
+ throw spawnStepError(TINYFISH_CLI_NOT_FOUND_MESSAGE, loginResult);
191
191
  }
192
192
  if (loginResult.error || loginResult.status !== 0) {
193
- throwIfInterrupted(loginResult);
194
- throw new Error("Could not authenticate the TinyFish CLI", { cause: loginResult.error });
193
+ throw spawnStepError("Could not authenticate the TinyFish CLI", loginResult);
195
194
  }
196
195
  }
@@ -1,24 +1,43 @@
1
- import { type AuthMode } from "./registration-detect.js";
1
+ import { AuthMode } from "./harness-detect.js";
2
2
  export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
3
3
  export type AgentClient = "claude-code" | "codex" | "cursor" | "hermes" | "openclaw" | "opencode";
4
4
  type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
5
5
  type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
6
- type ConnectFailureReason = "harness_not_installed" | "harness_command_unsupported" | "harness_too_old";
7
- export type ConnectAuthMode = Extract<AuthMode, "oauth" | "api-key"> | "deferred";
6
+ export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
7
+ type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
8
+ export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
8
9
  type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
9
10
  /** Ctrl+C/SIGTERM killed a setup child — abandonment, not error. */
10
11
  export declare class ConnectInterruptedError extends Error {
11
12
  }
13
+ /** A classified step failure; runGuarded forwards the reason. */
14
+ export declare class ConnectStepError extends Error {
15
+ readonly failureReason: ConnectFailureReason;
16
+ readonly harnessVersion?: string;
17
+ constructor(message: string, failureReason: ConnectFailureReason, opts?: {
18
+ harnessVersion?: string;
19
+ cause?: unknown;
20
+ });
21
+ }
12
22
  export declare function throwIfInterrupted(result: {
13
23
  signal?: string | null;
14
24
  error?: Error;
15
25
  }): void;
26
+ interface SpawnStepResult {
27
+ status?: number | null;
28
+ signal?: string | null;
29
+ error?: Error;
30
+ }
31
+ /** Classifies a spawn failure; interrupts throw instead. */
32
+ export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
16
33
  export declare function commandNotFound(error: unknown): boolean;
17
34
  export interface ConnectRunState {
18
35
  stage: ConnectFailureStage;
19
36
  settled: boolean;
20
37
  /** On state, not per-call: otherwise degraded installs never appear to fail. */
21
38
  harnessDegraded?: boolean;
39
+ /** Resolved after the probe, so callers read real key use rather than key presence. */
40
+ authMode?: ConnectAuthMode;
22
41
  }
23
42
  interface ConnectStageDetail {
24
43
  failedStage?: ConnectFailureStage;
@@ -32,6 +51,7 @@ interface ConnectStageDetail {
32
51
  export interface ConnectTelemetry {
33
52
  attemptId: string;
34
53
  track(stage: ConnectStage, detail?: ConnectStageDetail): void;
54
+ setHarnessVersion(version?: string): void;
35
55
  flush(): Promise<void>;
36
56
  }
37
57
  /** Terminal events exactly once, whichever of flow or signal handler wins. */
@@ -47,11 +67,15 @@ export interface SupportedCommand {
47
67
  patterns: RegExp[];
48
68
  /** Missing → degrade to key or deferred auth, never refuse. */
49
69
  optionalPatterns?: RegExp[];
70
+ /** Missing → this install cannot take a stored key; fall back to OAuth. */
71
+ keyAuthPattern?: RegExp;
50
72
  unavailableMessage: string;
51
73
  };
52
74
  }
53
75
  export declare function requireCommandSupport(client: SupportedCommand): {
54
76
  optionalSupported: boolean;
77
+ keyAuthSupported: boolean;
78
+ harnessVersion?: string;
55
79
  };
56
80
  export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
57
81
  apiKey?: string;