@tiny-fish/cli 0.20.1 → 0.20.2-next.190

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/README.md CHANGED
@@ -40,6 +40,10 @@ One-command OAuth requires a Codex release whose `codex mcp add` command support
40
40
  `--oauth-resource`. Update Codex if support is unavailable, or add TinyFish manually with
41
41
  `codex mcp add`.
42
42
 
43
+ With `--api-key`, Codex reads the key from `TINYFISH_API_KEY` instead of signing in — the command
44
+ exports it to your shell profile, so restart Codex (or open a new terminal) before using it. Needs
45
+ `codex mcp add --bearer-token-env-var`; without that flag the command falls back to OAuth.
46
+
43
47
  ### Connect Hermes
44
48
 
45
49
  Add TinyFish MCP and the global `use-tinyfish` web skill, authenticate the CLI, and start the
@@ -49,8 +53,10 @@ interactive walkthrough with one command:
49
53
  npx -y @tiny-fish/cli@latest connect hermes --launch --api-key sk-tinyfish-...
50
54
  ```
51
55
 
52
- Hermes completes OAuth while adding the MCP server. After setup, the command starts the walkthrough
53
- in a Hermes session and leaves that session open for your replies.
56
+ Hermes completes OAuth while adding the MCP server. `hermes mcp add` accepts no header flag, so
57
+ `--api-key` cannot replace the Hermes sign-in its only header path, `--auth header`, prompts
58
+ interactively. After setup, the command starts the walkthrough in a Hermes session and leaves that
59
+ session open for your replies.
54
60
 
55
61
  ### Connect OpenClaw
56
62
 
@@ -1,17 +1,20 @@
1
1
  import { Command } from "commander";
2
- import { type AgentClient } from "../lib/connect-runtime.js";
2
+ import { type AgentClient, type ConnectAuthMode } from "../lib/connect-runtime.js";
3
3
  export declare const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
4
4
  interface NativeConnectOptions {
5
5
  apiKey?: string;
6
6
  mcpUrl: string;
7
7
  launch: boolean;
8
8
  attemptId?: string;
9
+ /** Headless: refuse rather than fall back to an unfinishable browser sign-in. */
10
+ keyAuthOnly?: boolean;
9
11
  }
10
- export declare function connectClaudeCode(options: NativeConnectOptions): Promise<void>;
11
- export declare function connectCodex(options: NativeConnectOptions): Promise<void>;
12
- export declare function connectHermes(options: NativeConnectOptions): Promise<void>;
13
- export declare function connectOpencode(options: NativeConnectOptions): Promise<void>;
12
+ export declare function connectClaudeCode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
13
+ export declare function connectCodex(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
14
+ export declare function connectHermes(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
15
+ export declare function connectOpencode(options: NativeConnectOptions): Promise<ConnectAuthMode | undefined>;
14
16
  export declare function connectOpenClaw(options: {
17
+ apiKey?: string;
15
18
  mcpUrl: string;
16
19
  launch: boolean;
17
20
  installCli?: boolean;
@@ -1,8 +1,8 @@
1
1
  import spawn from "cross-spawn";
2
- import { CONNECT_SOURCE, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
2
+ import { CONNECT_SOURCE, persistApiKeyToEnvironment, saveConnectContext, validateKeyFormat, validatedApiKey, } from "../lib/auth.js";
3
3
  import { CLAUDE_CODE, CODEX, HERMES, OPENCLAW, OPENCLAW_SKILL_INSTALL_ARGS, OPENCODE, launchNativeMcpClient, launchOpenClawWalkthrough, openExternalUrl, } from "../lib/connect-clients.js";
4
- import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, TINYFISH_CLI_NOT_FOUND_MESSAGE, UPGRADE_HINT, } from "../lib/connect-install.js";
5
- import { commandNotFound, ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
4
+ import { ensureCliAuthenticated, installTinyFishCli, installWebSkill, SKILL_INSTALL_TIMEOUT_MS, UPGRADE_HINT, } from "../lib/connect-install.js";
5
+ import { ConnectInterruptedError, createConnectTelemetry, requireCommandSupport, runGuarded, settle, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
6
6
  import { cursorInstallDeeplink, cursorMcpPath, writeCursorMcpConfig, } from "../lib/cursor-config.js";
7
7
  import { runConnectAll } from "../lib/connect-all.js";
8
8
  import { detectHumanInitiated } from "../lib/harness.js";
@@ -29,13 +29,46 @@ function removeExistingRegistration(client, removal) {
29
29
  }
30
30
  throw new Error(`Could not remove existing ${removal.label}: ${details}`);
31
31
  }
32
+ /** false: fall back to OAuth — a config naming an unset variable authenticates nothing. */
33
+ function exportApiKey(apiKey, displayName) {
34
+ try {
35
+ persistApiKeyToEnvironment(apiKey);
36
+ }
37
+ catch (error) {
38
+ errLine(`Could not export TINYFISH_API_KEY (${error instanceof Error ? error.message : String(error)}); ` +
39
+ "signing in instead.");
40
+ return false;
41
+ }
42
+ process.env["TINYFISH_API_KEY"] = apiKey;
43
+ errLine(`Exported TINYFISH_API_KEY to your shell profile — ${displayName} reads the key from there, ` +
44
+ "so restart it (or open a new terminal) before use.");
45
+ return true;
46
+ }
32
47
  async function connectNativeMcpClient(client, options) {
33
48
  const telemetry = createConnectTelemetry(options.mcpUrl, client.connectClient, options);
34
49
  const state = { stage: "prerequisite_check", settled: false };
35
50
  await runGuarded(state, telemetry, () => {
36
51
  telemetry.track("started");
37
- const { optionalSupported } = requireCommandSupport(client);
52
+ const { optionalSupported, keyAuthSupported } = requireCommandSupport(client);
38
53
  state.harnessDegraded = !!client.loginArgs && !optionalSupported;
54
+ // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
55
+ const storedKey = validatedApiKey(options.apiKey);
56
+ let keyedAdd = storedKey && keyAuthSupported && client.keyAuth
57
+ ? { key: storedKey, spec: client.keyAuth }
58
+ : undefined;
59
+ // Before the removals: a failed export must not strand the old registration.
60
+ if (keyedAdd?.spec.viaEnv && !exportApiKey(keyedAdd.key, client.displayName)) {
61
+ keyedAdd = undefined;
62
+ }
63
+ const useKeyAuth = !!keyedAdd;
64
+ if (options.keyAuthOnly && !useKeyAuth) {
65
+ // No keyAuth at all is structural (Hermes); a missing flag is just an old install.
66
+ throw new Error(client.keyAuth
67
+ ? `This ${client.displayName} install cannot take a TinyFish API key, and signing in ` +
68
+ `needs a terminal. Update it, then re-run: tinyfish connect ${client.connectClient}`
69
+ : `${client.displayName} cannot take a TinyFish API key, and signing in needs a ` +
70
+ `terminal. Run: tinyfish connect ${client.connectClient}`);
71
+ }
39
72
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
40
73
  state.stage = "registration_cleanup";
41
74
  for (const removal of client.removals)
@@ -44,9 +77,6 @@ async function connectNativeMcpClient(client, options) {
44
77
  mcpUrl.searchParams.set("source", CONNECT_SOURCE);
45
78
  mcpUrl.searchParams.set("client", client.connectClient);
46
79
  mcpUrl.searchParams.set("connect_attempt_id", telemetry.attemptId);
47
- // Prod MCP accepts X-API-Key, so a stored key replaces the browser OAuth hop.
48
- const storedKey = validatedApiKey(options.apiKey);
49
- const useKeyAuth = client.headerAuthSupported === true && !!storedKey;
50
80
  // Undefining `loginArgs` reuses the deferred-auth path codex/hermes already take.
51
81
  const loginDegraded = state.harnessDegraded === true;
52
82
  const loginArgs = loginDegraded ? undefined : client.loginArgs;
@@ -54,12 +84,11 @@ async function connectNativeMcpClient(client, options) {
54
84
  state.stage =
55
85
  loginArgs && !client.probeAuthenticated ? "registration" : "registration_or_authentication";
56
86
  errLine(`Adding TinyFish to ${client.displayName}...`);
57
- const addResult = spawn.sync(client.command, [
58
- ...client.addArgs(mcpUrl.toString(), options.mcpUrl),
59
- // Key rides argv — readable via /proc/<pid>/cmdline. Accepted because these CLIs
60
- // take headers no other way.
61
- ...(useKeyAuth ? ["--header", `X-API-Key: ${storedKey}`] : []),
62
- ], {
87
+ // Header keys ride argv (/proc/<pid>/cmdline); these CLIs take headers no other way.
88
+ const addArgs = keyedAdd
89
+ ? keyedAdd.spec.addArgs(mcpUrl.toString(), keyedAdd.key)
90
+ : client.addArgs(mcpUrl.toString(), options.mcpUrl);
91
+ const addResult = spawn.sync(client.command, addArgs, {
63
92
  stdio: "inherit",
64
93
  });
65
94
  if (addResult.error || addResult.status !== 0) {
@@ -105,10 +134,12 @@ async function connectNativeMcpClient(client, options) {
105
134
  : signInDeferred
106
135
  ? "deferred"
107
136
  : "oauth";
137
+ state.authMode = authMode;
108
138
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
109
139
  settle(state, telemetry, "completed", { authMode });
110
140
  runPostInstallSteps(client, options, state, telemetry, signInDeferred);
111
141
  });
142
+ return state.authMode;
112
143
  }
113
144
  function connectedLine(client, signInDeferred) {
114
145
  // The CLI never observes deferred auth, so don't claim "connected".
@@ -157,16 +188,16 @@ function reportPostInstallFailure(connectClient, displayName, state, telemetry,
157
188
  errLine(`Finish setup with: npx -y @tiny-fish/cli@latest connect ${connectClient}`);
158
189
  }
159
190
  export async function connectClaudeCode(options) {
160
- await connectNativeMcpClient(CLAUDE_CODE, options);
191
+ return connectNativeMcpClient(CLAUDE_CODE, options);
161
192
  }
162
193
  export async function connectCodex(options) {
163
- await connectNativeMcpClient(CODEX, options);
194
+ return connectNativeMcpClient(CODEX, options);
164
195
  }
165
196
  export async function connectHermes(options) {
166
- await connectNativeMcpClient(HERMES, options);
197
+ return connectNativeMcpClient(HERMES, options);
167
198
  }
168
199
  export async function connectOpencode(options) {
169
- await connectNativeMcpClient(OPENCODE, options);
200
+ return connectNativeMcpClient(OPENCODE, options);
170
201
  }
171
202
  export async function connectOpenClaw(options) {
172
203
  const telemetry = createConnectTelemetry(options.mcpUrl, "openclaw", options);
@@ -198,29 +229,9 @@ export async function connectOpenClaw(options) {
198
229
  });
199
230
  }
200
231
  telemetry.track("checkpoint", { phase: "skill_installed" });
232
+ // Stores the key before probing, so a passed --api-key never reaches an interactive login.
201
233
  state.stage = "authentication";
202
- const authStatus = spawn.sync("tinyfish", ["auth", "status"], {
203
- stdio: "ignore",
204
- timeout: NON_INTERACTIVE_TIMEOUT_MS,
205
- });
206
- if (commandNotFound(authStatus.error)) {
207
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: authStatus.error });
208
- }
209
- if (authStatus.status !== 0) {
210
- errLine("Signing in to TinyFish...");
211
- const loginResult = spawn.sync("tinyfish", ["auth", "login", "--source", "openclaw"], {
212
- stdio: "inherit",
213
- });
214
- if (commandNotFound(loginResult.error)) {
215
- throw new Error(TINYFISH_CLI_NOT_FOUND_MESSAGE, { cause: loginResult.error });
216
- }
217
- if (loginResult.error || loginResult.status !== 0) {
218
- throwIfInterrupted(loginResult);
219
- throw new Error("Could not authenticate TinyFish for OpenClaw", {
220
- cause: loginResult.error,
221
- });
222
- }
223
- }
234
+ ensureCliAuthenticated("openclaw", options.apiKey);
224
235
  telemetry.track("checkpoint", { phase: "authenticated" });
225
236
  saveConnectContext("openclaw", telemetry.attemptId);
226
237
  errLine(UPGRADE_HINT);
@@ -400,6 +411,8 @@ export function registerConnect(program) {
400
411
  mcpUrl,
401
412
  launch: options.launch ?? false,
402
413
  attemptId,
414
+ // A key that the install cannot use leaves only a browser hop no agent can finish.
415
+ keyAuthOnly: !detectHumanInitiated() && !!validatedApiKey(options.apiKey),
403
416
  };
404
417
  if (client === "claude-code") {
405
418
  await connectClaudeCode(connectOptions);
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
- export declare function runUpgrade(): void;
2
+ /** Returns the version now installed, so the caller reports exactly what the user was told. */
3
+ export declare function runUpgrade(): string | null;
3
4
  /** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
4
5
  export declare function runUpgradeCommand(): Promise<void>;
5
6
  export declare function registerUpgrade(program: Command): void;
@@ -1,6 +1,7 @@
1
1
  import spawn from "cross-spawn";
2
2
  import { resolvedApiKey } from "../lib/auth.js";
3
3
  import { CLI_VERSION, TINYFISH_CLI_PACKAGE } from "../lib/constants.js";
4
+ import { installRoot, readInstalledVersion } from "../lib/install-root.js";
4
5
  import { suppressNotice } from "../lib/notice.js";
5
6
  import { errLine } from "../lib/output.js";
6
7
  import { sendUpgradeCompleted, telemetryDisabled, } from "../lib/setup-telemetry.js";
@@ -24,6 +25,7 @@ function attempt(label, run) {
24
25
  return message;
25
26
  }
26
27
  }
28
+ /** Returns the version now installed, so the caller reports exactly what the user was told. */
27
29
  export function runUpgrade() {
28
30
  errLine("Upgrading TinyFish...");
29
31
  try {
@@ -40,12 +42,21 @@ export function runUpgrade() {
40
42
  throw error;
41
43
  errLine(INTERRUPTED_MESSAGE);
42
44
  process.exitCode = SIGNAL_EXIT_CODES.SIGINT;
43
- return;
45
+ return null;
44
46
  }
45
- errLine("TinyFish is up to date. Restart your agent to pick up the refreshed skill.");
47
+ const version = installedCliVersion();
48
+ errLine(version
49
+ ? `TinyFish CLI ${version} is up to date. Restart your agent to pick up the refreshed skill.`
50
+ : "TinyFish is up to date. Restart your agent to pick up the refreshed skill.");
51
+ return version;
46
52
  }
47
53
  /** The version now on disk. Only the upgrade's own npm call knows what "latest" resolved to. */
48
54
  function installedCliVersion() {
55
+ const root = installRoot();
56
+ return (root ? readInstalledVersion(root.nodeModules) : null) ?? globalCliVersion();
57
+ }
58
+ /** Fallback when no prefix was detected: npm's own global tree is the only place left to look. */
59
+ function globalCliVersion() {
49
60
  try {
50
61
  const result = spawn.sync("npm", ["ls", "--global", "--depth=0", "--json", TINYFISH_CLI_PACKAGE], { encoding: "utf8", timeout: VERSION_READ_TIMEOUT_MS });
51
62
  const parsed = JSON.parse(result.stdout ?? "");
@@ -60,17 +71,17 @@ export async function runUpgradeCommand() {
60
71
  // This command is the upgrade, so a trailing "an update is available" would be noise.
61
72
  suppressNotice();
62
73
  let reported = false;
74
+ let printedVersion = null;
63
75
  // Read on the failed path too: a failed skill refresh still leaves an upgraded CLI, so
64
76
  // `outcome` reports whether every step ran and `to_version` whether the binary moved.
65
77
  const report = async (outcome) => {
66
78
  if (reported)
67
79
  return;
68
80
  reported = true;
69
- // Ahead of the version and key reads; an opted-out run does neither.
70
81
  if (telemetryDisabled())
71
82
  return;
72
83
  // Interrupted skips the read — racing the exit. Undercounts; see posthog-events.md.
73
- const toVersion = outcome === "interrupted" ? null : installedCliVersion();
84
+ const toVersion = outcome === "interrupted" ? null : (printedVersion ?? installedCliVersion());
74
85
  await sendUpgradeCompleted(CLI_VERSION, toVersion, outcome, resolvedApiKey());
75
86
  };
76
87
  const uninstallSignalGuard = installSignalGuard(async () => {
@@ -79,7 +90,7 @@ export async function runUpgradeCommand() {
79
90
  });
80
91
  let outcome = "updated";
81
92
  try {
82
- runUpgrade();
93
+ printedVersion = runUpgrade();
83
94
  if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
84
95
  outcome = "interrupted";
85
96
  }
@@ -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;
@@ -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
@@ -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], {
@@ -168,9 +186,10 @@ export const HERMES = {
168
186
  patterns: [/--auth\s+\{[^}]*oauth[^}]*\}/],
169
187
  unavailableMessage: HERMES_OAUTH_UNAVAILABLE_MESSAGE,
170
188
  },
189
+ // No `keyAuth`: `hermes mcp add` has no --header, and `--auth header` prompts interactively.
171
190
  removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Hermes" }],
172
191
  // Hermes completes OAuth while adding the server; a separate `mcp login` would authenticate twice.
173
- addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl, "--auth", "oauth"],
192
+ addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--auth", "oauth"],
174
193
  launchWalkthrough: launchHermesWalkthrough,
175
194
  };
176
195
  // OpenCode's TUI takes a positional as a project directory, so a bare `opencode "<prompt>"`
@@ -197,16 +216,25 @@ export const OPENCODE = {
197
216
  supportCheck: {
198
217
  args: ["mcp", "add", "--help"],
199
218
  patterns: [/--url(?:\s|$)/m],
219
+ keyAuthPattern: HEADER_FLAG,
200
220
  unavailableMessage: OPENCODE_MCP_ADD_UNAVAILABLE_MESSAGE,
201
221
  },
222
+ keyAuth: headerKeyAuth,
202
223
  // `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
203
224
  removals: [],
204
225
  // OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
205
- addArgs: (mcpUrl) => ["mcp", "add", "tinyfish", "--url", mcpUrl],
226
+ addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
206
227
  loginArgs: ["mcp", "auth", "tinyfish"],
207
228
  launchWalkthrough: launchOpencode,
208
229
  postConnectNote: OPENCODE_MODEL_NOTE,
209
230
  };
231
+ /** Every native MCP client. Derive from this, so a new harness cannot be missed. */
232
+ export const NATIVE_MCP_CLIENTS = [
233
+ CLAUDE_CODE,
234
+ CODEX,
235
+ HERMES,
236
+ OPENCODE,
237
+ ];
210
238
  export const OPENCLAW = {
211
239
  command: "openclaw",
212
240
  displayName: "OpenClaw",
@@ -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";
4
+ import { NATIVE_MCP_CLIENTS, OPENCLAW_SKILL_INSTALL_ARGS, } from "./connect-clients.js";
5
5
  import { commandNotFound, 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,7 +16,10 @@ 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
  });
@@ -85,7 +89,7 @@ export function installWebSkill(client) {
85
89
  export function updateWebSkill() {
86
90
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
87
91
  const connected = loadConfig().connect ?? {};
88
- const skillAgents = [CLAUDE_CODE, CODEX, HERMES, OPENCODE]
92
+ const skillAgents = NATIVE_MCP_CLIENTS
89
93
  .filter((client) => connected[client.connectClient])
90
94
  .map((client) => client.skillAgent);
91
95
  const hasOpenClaw = Boolean(connected["openclaw"]);
@@ -19,6 +19,8 @@ export interface ConnectRunState {
19
19
  settled: boolean;
20
20
  /** On state, not per-call: otherwise degraded installs never appear to fail. */
21
21
  harnessDegraded?: boolean;
22
+ /** Resolved after the probe, so callers read real key use rather than key presence. */
23
+ authMode?: ConnectAuthMode;
22
24
  }
23
25
  interface ConnectStageDetail {
24
26
  failedStage?: ConnectFailureStage;
@@ -47,11 +49,14 @@ export interface SupportedCommand {
47
49
  patterns: RegExp[];
48
50
  /** Missing → degrade to key or deferred auth, never refuse. */
49
51
  optionalPatterns?: RegExp[];
52
+ /** Missing → this install cannot take a stored key; fall back to OAuth. */
53
+ keyAuthPattern?: RegExp;
50
54
  unavailableMessage: string;
51
55
  };
52
56
  }
53
57
  export declare function requireCommandSupport(client: SupportedCommand): {
54
58
  optionalSupported: boolean;
59
+ keyAuthSupported: boolean;
55
60
  };
56
61
  export declare function createConnectTelemetry(mcpUrl: string, client: AgentClient, opts?: {
57
62
  apiKey?: string;
@@ -95,7 +95,9 @@ export function requireCommandSupport(client) {
95
95
  const listed = (patterns) => patterns.every((pattern) => pattern.test(output));
96
96
  const essential = listed(client.supportCheck.patterns);
97
97
  const optionalSupported = listed(client.supportCheck.optionalPatterns ?? []);
98
- if ((!essential || !optionalSupported) && process.env["TINYFISH_DEBUG"]) {
98
+ const keyAuthPattern = client.supportCheck.keyAuthPattern;
99
+ const keyAuthSupported = keyAuthPattern ? keyAuthPattern.test(output) : true;
100
+ if ((!essential || !optionalSupported || !keyAuthSupported) && process.env["TINYFISH_DEBUG"]) {
99
101
  errLine(`${client.command} ${client.supportCheck.args.join(" ")} printed:\n${output.trim()}`);
100
102
  }
101
103
  if (!essential) {
@@ -103,7 +105,7 @@ export function requireCommandSupport(client) {
103
105
  harnessVersion: probeHarnessVersion(client.command),
104
106
  });
105
107
  }
106
- return { optionalSupported };
108
+ return { optionalSupported, keyAuthSupported };
107
109
  }
108
110
  /** Best-effort; the server rejects non-printable characters and >32 chars. */
109
111
  function probeHarnessVersion(command) {
@@ -0,0 +1,15 @@
1
+ /** Where this CLI is installed: `prefix` for npm, `nodeModules` for reading the version back. */
2
+ export type InstallRoot = {
3
+ prefix: string;
4
+ nodeModules: string;
5
+ };
6
+ /**
7
+ * The prefix to upgrade in place, or null when the layout is not npm's.
8
+ *
9
+ * Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
10
+ * installing into their trees would report a success the PATH binary never sees.
11
+ */
12
+ export declare function resolveInstallRoot(packageRoot: string, platform: typeof process.platform, onDisk?: (candidate: string) => boolean): InstallRoot | null;
13
+ export declare function installRoot(): InstallRoot | null;
14
+ /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
15
+ export declare function readInstalledVersion(nodeModules: string): string | null;
@@ -0,0 +1,49 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { TINYFISH_CLI_PACKAGE } from "./constants.js";
5
+ function isDirectory(candidate) {
6
+ try {
7
+ return fs.statSync(candidate).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * The prefix to upgrade in place, or null when the layout is not npm's.
15
+ *
16
+ * Guarded on purpose: npx, pnpm and bun all nest the package under a bare `node_modules`, and
17
+ * installing into their trees would report a success the PATH binary never sees.
18
+ */
19
+ export function resolveInstallRoot(packageRoot, platform, onDisk = isDirectory) {
20
+ const p = platform === "win32" ? path.win32 : path.posix;
21
+ // npx's win32 cache uses the same bare layout as a global install.
22
+ if (packageRoot.split(p.sep).includes("_npx"))
23
+ return null;
24
+ const nodeModules = p.resolve(packageRoot, "..", "..");
25
+ if (p.basename(nodeModules) !== "node_modules")
26
+ return null;
27
+ const container = p.dirname(nodeModules);
28
+ // Only win32 puts node_modules directly under the prefix.
29
+ if (platform !== "win32" && p.basename(container) !== "lib")
30
+ return null;
31
+ if (!onDisk(nodeModules))
32
+ return null;
33
+ return { prefix: platform === "win32" ? container : p.dirname(container), nodeModules };
34
+ }
35
+ export function installRoot() {
36
+ // Two levels up from dist/lib/ is the package root, and tsc mirrors src/ into dist/.
37
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
38
+ return resolveInstallRoot(packageRoot, process.platform);
39
+ }
40
+ /** The version on disk under a prefix, which is the only account of what `@latest` resolved to. */
41
+ export function readInstalledVersion(nodeModules) {
42
+ try {
43
+ const manifest = fs.readFileSync(path.join(nodeModules, ...TINYFISH_CLI_PACKAGE.split("/"), "package.json"), "utf8");
44
+ return JSON.parse(manifest).version ?? null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
@@ -224,7 +224,7 @@ function fromMcpList(command, output, registeredMode, urlPattern) {
224
224
  }
225
225
  return { registered: "no", authMode: "unknown" };
226
226
  }
227
- // Hermes registers with `--auth oauth` and has no key path today, so mode is fixed.
227
+ // connect only ever writes an OAuth Hermes entry, so `oauth` is a fact, not a detection gap.
228
228
  function probeHermes() {
229
229
  const probe = runProbe("hermes", ["mcp", "list"]);
230
230
  if (probe.outcome === "unavailable") {
@@ -259,7 +259,7 @@ function probeOpenClaw() {
259
259
  ? { registered: "yes", authMode: "api-key" }
260
260
  : { registered: "no", authMode: "unknown" };
261
261
  }
262
- // Connect registers with `mcp add --url` then `mcp auth`, so there is no key path today.
262
+ // `opencode mcp list` prints no headers, so a key-authed registration is indistinguishable.
263
263
  function probeOpencode() {
264
264
  const probe = runProbe("opencode", ["mcp", "list"]);
265
265
  if (probe.outcome === "unavailable") {
@@ -272,7 +272,7 @@ function probeOpencode() {
272
272
  reason: `\`opencode mcp list\` exited ${probe.exitCode}`,
273
273
  };
274
274
  }
275
- return fromMcpList("opencode", probe.output, "oauth", OPENCODE_ROW_URL);
275
+ return fromMcpList("opencode", probe.output, "unknown", OPENCODE_ROW_URL);
276
276
  }
277
277
  const PROBES = {
278
278
  "claude-code": () => fromMcpGet("claude", API_KEY_HEADER),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.20.1",
3
+ "version": "0.20.2-next.190",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {