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

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
@@ -80,6 +80,9 @@ Brings both halves of an install current: the global `@tiny-fish/cli` package an
80
80
  `use-tinyfish` skill. Use this rather than remembering `npm i -g @tiny-fish/cli@latest` and
81
81
  `skills update -g` separately.
82
82
 
83
+ Successful steps are summarised in one line each. Add `--verbose` to see the full npm and skills
84
+ output; a failing step prints its output either way.
85
+
83
86
  The skill step refreshes only the TinyFish skill, and only for the agents you ran
84
87
  `tinyfish connect` against. Other global skills, and agents you never connected, are left alone.
85
88
  The refresh overwrites your copy of the skill, including any local edits to it. Restart your
@@ -144,7 +144,8 @@ function connectedLine(client, signInDeferred) {
144
144
  function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
145
145
  try {
146
146
  state.stage = "cli_install";
147
- installTinyFishCli();
147
+ // Verbose on purpose: first-run setup must not go silent.
148
+ installTinyFishCli({ verbose: true });
148
149
  telemetry.track("checkpoint", { phase: "cli_installed" });
149
150
  state.stage = "skill_install";
150
151
  installWebSkill(client);
@@ -207,7 +208,7 @@ export async function connectOpenClaw(options) {
207
208
  telemetry.track("checkpoint", { phase: "prerequisite_ok" });
208
209
  if (options.installCli !== false) {
209
210
  state.stage = "cli_install";
210
- installTinyFishCli();
211
+ installTinyFishCli({ verbose: true });
211
212
  telemetry.track("checkpoint", { phase: "cli_installed" });
212
213
  }
213
214
  state.stage = "skill_install";
@@ -15,9 +15,12 @@ function provesHarnessReach(status, keyAuth, healthOk, mcpUrl) {
15
15
  return false;
16
16
  if (pointsElsewhere(status.registeredUrl, mcpUrl))
17
17
  return false;
18
- return (status.registered === Registered.Yes &&
19
- status.authMode === AuthMode.ApiKey &&
20
- keyAuth?.ok === true);
18
+ if (status.registered !== Registered.Yes)
19
+ return false;
20
+ if (status.connected === true)
21
+ return true;
22
+ // The harness's own key must have verified; the CLI's verdict says nothing about it.
23
+ return status.authMode === AuthMode.ApiKey && keyAuth?.ok === true;
21
24
  }
22
25
  // Reports the version, never judges it: staleness is the server's call via X-TF-Notice.
23
26
  function checkCliVersion() {
@@ -57,9 +60,12 @@ function pointsElsewhere(registeredUrl, mcpUrl) {
57
60
  return "an unparseable endpoint";
58
61
  }
59
62
  }
60
- function keyedVerdict(keyAuth) {
61
- if (!keyAuth)
62
- return { status: "warn", detail: "registered, API key present but unverified" };
63
+ function keyedVerdict(status, keyAuth) {
64
+ if (!keyAuth) {
65
+ // A bare warn beside exit 0 reads as a pass.
66
+ const because = status.keyReason ?? "its key is not readable here";
67
+ return { status: "warn", detail: `registered, but ${because}, so its reach is unproven` };
68
+ }
63
69
  if (keyAuth.ok) {
64
70
  return {
65
71
  status: "pass",
@@ -67,6 +73,13 @@ function keyedVerdict(keyAuth) {
67
73
  };
68
74
  }
69
75
  if (keyAuth.status === 401) {
76
+ // Resolved in doctor's shell, not codex's, so a 401 proves nothing.
77
+ if (status.keyIsIndirect) {
78
+ return {
79
+ status: "warn",
80
+ detail: "registered, but the key doctor resolved for it is rejected",
81
+ };
82
+ }
70
83
  return { status: "fail", detail: "registered, but TinyFish rejects its API key" };
71
84
  }
72
85
  // Only 401 blames the key; connect would loop on the rest.
@@ -90,9 +103,20 @@ function registrationVerdict(status, mcpUrl, keyAuth) {
90
103
  detail: `registered, but points at ${elsewhere} rather than ${endpointOf(mcpUrl)}`,
91
104
  };
92
105
  }
106
+ // Ordered before the key check: the harness's own verdict wins.
107
+ if (status.connected === false) {
108
+ return {
109
+ status: "warn",
110
+ detail: "registered, but the harness reports it cannot reach TinyFish; sign in again there",
111
+ };
112
+ }
113
+ // Symmetric with provesHarnessReach, which already reads a handshake as decisive.
114
+ if (status.connected === true) {
115
+ return { status: "pass", detail: "registered, and the harness reports it reaches TinyFish" };
116
+ }
93
117
  // Widening past api-key would warn every healthy oauth install.
94
118
  if (status.authMode === AuthMode.ApiKey)
95
- return keyedVerdict(keyAuth);
119
+ return keyedVerdict(status, keyAuth);
96
120
  // A probe that had to explain itself to reach `yes` is the only thing that explains `unknown`.
97
121
  const because = status.reason ? ` (${status.reason})` : "";
98
122
  return { status: "pass", detail: `registered, auth mode ${status.authMode}${because}` };
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  /** Returns the version now installed, so the caller reports exactly what the user was told. */
3
- export declare function runUpgrade(): string | null;
3
+ export declare function runUpgrade(verbose?: boolean): string | null;
4
4
  /** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
5
- export declare function runUpgradeCommand(): Promise<void>;
5
+ export declare function runUpgradeCommand(verbose?: boolean): Promise<void>;
6
6
  export declare function registerUpgrade(program: Command): void;
@@ -26,13 +26,16 @@ function attempt(label, run) {
26
26
  }
27
27
  }
28
28
  /** Returns the version now installed, so the caller reports exactly what the user was told. */
29
- export function runUpgrade() {
29
+ export function runUpgrade(verbose = false) {
30
30
  errLine("Upgrading TinyFish...");
31
+ let refreshed = false;
31
32
  try {
32
33
  // Both steps always run: neither artifact should stay stale because the other failed.
33
34
  const failures = [
34
- attempt("update the TinyFish CLI", installTinyFishCli),
35
- attempt("refresh the TinyFish web skill", updateWebSkill),
35
+ attempt("update the TinyFish CLI", () => installTinyFishCli({ verbose })),
36
+ attempt("refresh the TinyFish web skill", () => {
37
+ refreshed = updateWebSkill({ verbose });
38
+ }),
36
39
  ].filter((failure) => failure !== undefined);
37
40
  if (failures.length > 0)
38
41
  throw new Error(`tinyfish upgrade failed: ${failures.join("; ")}`);
@@ -45,9 +48,9 @@ export function runUpgrade() {
45
48
  return null;
46
49
  }
47
50
  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
+ const upToDate = version ? `TinyFish CLI ${version} is up to date.` : "TinyFish is up to date.";
52
+ // Only a rewritten skill needs an agent restart.
53
+ errLine(refreshed ? `${upToDate} Restart your agent to pick up the refreshed skill.` : upToDate);
51
54
  return version;
52
55
  }
53
56
  /** The version now on disk. Only the upgrade's own npm call knows what "latest" resolved to. */
@@ -67,7 +70,7 @@ function globalCliVersion() {
67
70
  }
68
71
  }
69
72
  /** `runUpgrade` plus its outcome telemetry, which must never change what the upgrade does. */
70
- export async function runUpgradeCommand() {
73
+ export async function runUpgradeCommand(verbose = false) {
71
74
  // This command is the upgrade, so a trailing "an update is available" would be noise.
72
75
  suppressNotice();
73
76
  let reported = false;
@@ -90,7 +93,7 @@ export async function runUpgradeCommand() {
90
93
  });
91
94
  let outcome = "updated";
92
95
  try {
93
- printedVersion = runUpgrade();
96
+ printedVersion = runUpgrade(verbose);
94
97
  if (process.exitCode === SIGNAL_EXIT_CODES.SIGINT)
95
98
  outcome = "interrupted";
96
99
  }
@@ -108,7 +111,8 @@ export function registerUpgrade(program) {
108
111
  program
109
112
  .command("upgrade")
110
113
  .description("Update the TinyFish CLI and the use-tinyfish skill to the latest versions")
111
- .action(async () => {
112
- await runUpgradeCommand();
114
+ .option("--verbose", "Print the full npm and skills output instead of only on failure")
115
+ .action(async (options) => {
116
+ await runUpgradeCommand(Boolean(options.verbose));
113
117
  });
114
118
  }
@@ -3,7 +3,11 @@ import { type AgentClient } from "./connect-runtime.js";
3
3
  export declare const SKILL_INSTALL_TIMEOUT_MS = 120000;
4
4
  export declare const TINYFISH_CLI_NOT_FOUND_MESSAGE = "TinyFish CLI installed but is not available on PATH. Open a new terminal and retry.";
5
5
  export declare const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI and skill.";
6
- export declare function installTinyFishCli(): void;
6
+ /** Quiet captures subprocess output and replays it only on failure. */
7
+ export type InstallOptions = {
8
+ verbose: boolean;
9
+ };
10
+ export declare function installTinyFishCli({ verbose }: InstallOptions): void;
7
11
  export declare function installWebSkill(client: NativeMcpClient): void;
8
12
  /**
9
13
  * Refresh the skill we installed, for the harnesses we installed it in.
@@ -12,6 +16,8 @@ export declare function installWebSkill(client: NativeMcpClient): void;
12
16
  * only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
13
17
  * skill to agents the user never connected (`update` re-adds with no --agent, which targets
14
18
  * every agent on the machine).
19
+ *
20
+ * Returns whether a skill was actually rewritten.
15
21
  */
16
- export declare function updateWebSkill(): void;
22
+ export declare function updateWebSkill({ verbose }: InstallOptions): boolean;
17
23
  export declare function ensureCliAuthenticated(source: AgentClient, apiKey?: string): void;
@@ -14,17 +14,37 @@ export const UPGRADE_HINT = "Run `tinyfish upgrade` any time to update the CLI a
14
14
  const SKILLS_CLI_PACKAGE = "skills@1.5.15";
15
15
  const TINYFISH_WEB_SKILL_SOURCE = "tinyfish-io/tinyfish-cookbook";
16
16
  const TINYFISH_WEB_SKILL = "use-tinyfish";
17
- export function installTinyFishCli() {
18
- errLine("Installing the TinyFish CLI...");
17
+ // spawnSync caps piped output at 1 MiB by default.
18
+ const STEP_MAX_BUFFER = 10 * 1024 * 1024;
19
+ function captureStdio(verbose) {
20
+ return verbose
21
+ ? { stdio: "inherit", timeout: SKILL_INSTALL_TIMEOUT_MS }
22
+ : { encoding: "utf8", maxBuffer: STEP_MAX_BUFFER, timeout: SKILL_INSTALL_TIMEOUT_MS };
23
+ }
24
+ /** Inherited stdio leaves these null; only piped runs yield text. */
25
+ function capturedOutput(result) {
26
+ return `${asText(result.stdout)}${asText(result.stderr)}`;
27
+ }
28
+ function asText(stream) {
29
+ return typeof stream === "string" ? stream : "";
30
+ }
31
+ /** A verbose run captured nothing, so this is a no-op. */
32
+ function replay(output) {
33
+ if (output.trim())
34
+ errLine(output.trimEnd());
35
+ }
36
+ export function installTinyFishCli({ verbose }) {
37
+ if (verbose)
38
+ errLine("Installing the TinyFish CLI...");
19
39
  // Without the prefix, a curl install upgrades npm's global tree and never the PATH binary.
20
40
  const root = installRoot();
21
41
  const prefixArgs = root ? ["--prefix", root.prefix] : [];
22
- const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], {
23
- stdio: "inherit",
24
- timeout: SKILL_INSTALL_TIMEOUT_MS,
25
- });
42
+ const result = spawn.sync("npm", ["install", "--global", ...prefixArgs, TINYFISH_CLI_INSTALL_SPEC], captureStdio(verbose));
26
43
  if (result.error || result.status !== 0) {
27
- throw spawnStepError("Could not install the TinyFish CLI", result);
44
+ // Built first: it runs the interrupt check, and abandonment must not replay.
45
+ const error = spawnStepError("Could not install the TinyFish CLI", result);
46
+ replay(capturedOutput(result));
47
+ throw error;
28
48
  }
29
49
  }
30
50
  /** `skills add` is an unconditional overwrite, so it doubles as the refresh path. */
@@ -53,6 +73,8 @@ function skillSpawnEnv() {
53
73
  // Safe to match on because SKILLS_CLI_PACKAGE is pinned.
54
74
  const SKILL_UPDATE_FAILURE_PATTERN = /Failed to (?:update|check|fetch)/;
55
75
  const SKILL_NOT_INSTALLED_PATTERN = /No installed skills found matching/;
76
+ // Nothing was rewritten, so nothing needs an agent restart.
77
+ const SKILL_ALREADY_CURRENT_PATTERN = /All global skills are up to date/;
56
78
  // A lock entry with no recorded hash is untrackable, so `skills` reports it as skipped rather
57
79
  // than failed. Left undetected that reads as "up to date" while nothing was refreshed.
58
80
  const SKILL_UNCHECKABLE_PATTERN = /cannot be checked automatically/;
@@ -65,6 +87,7 @@ export function installWebSkill(client) {
65
87
  const result = spawn.sync("npx", skillAddArgs([client.skillAgent]), {
66
88
  encoding: "utf8",
67
89
  env: skillSpawnEnv(),
90
+ maxBuffer: STEP_MAX_BUFFER,
68
91
  timeout: SKILL_INSTALL_TIMEOUT_MS,
69
92
  });
70
93
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
@@ -81,8 +104,10 @@ export function installWebSkill(client) {
81
104
  * only in prose, it repairs a copy whose lock entry lost its hash, and it cannot spread the
82
105
  * skill to agents the user never connected (`update` re-adds with no --agent, which targets
83
106
  * every agent on the machine).
107
+ *
108
+ * Returns whether a skill was actually rewritten.
84
109
  */
85
- export function updateWebSkill() {
110
+ export function updateWebSkill({ verbose }) {
86
111
  // One entry per `tinyfish connect <client>`, persisted by PF-3169.
87
112
  const connected = loadConfig().connect ?? {};
88
113
  const skillAgents = NATIVE_MCP_CLIENTS
@@ -90,71 +115,82 @@ export function updateWebSkill() {
90
115
  .map((client) => client.skillAgent);
91
116
  const hasOpenClaw = Boolean(connected["openclaw"]);
92
117
  // Installs predating PF-3169 recorded nothing; ask `skills` to refresh what it tracks.
93
- if (skillAgents.length === 0 && !hasOpenClaw) {
94
- refreshWebSkillByHash();
95
- return;
118
+ if (skillAgents.length === 0 && !hasOpenClaw)
119
+ return refreshWebSkillByHash(verbose);
120
+ let refreshed = false;
121
+ if (skillAgents.length > 0) {
122
+ reinstallWebSkill(skillAgents, verbose);
123
+ refreshed = true;
96
124
  }
97
- if (skillAgents.length > 0)
98
- reinstallWebSkill(skillAgents);
99
125
  // OpenClaw keeps its skill under its own CLI, so it needs its own refresh.
100
126
  if (hasOpenClaw)
101
- reinstallOpenClawSkill();
127
+ refreshed = reinstallOpenClawSkill(verbose) || refreshed;
128
+ return refreshed;
102
129
  }
103
- function reinstallOpenClawSkill() {
104
- errLine("Refreshing the TinyFish skill in OpenClaw...");
105
- const result = spawn.sync("openclaw", OPENCLAW_SKILL_INSTALL_ARGS, {
106
- stdio: "inherit",
107
- timeout: SKILL_INSTALL_TIMEOUT_MS,
108
- });
130
+ function reinstallOpenClawSkill(verbose) {
131
+ if (verbose)
132
+ errLine("Refreshing the TinyFish skill in OpenClaw...");
133
+ const result = spawn.sync("openclaw", OPENCLAW_SKILL_INSTALL_ARGS, captureStdio(verbose));
109
134
  // OpenClaw may have been removed since connect; a stale context entry must not fail an
110
135
  // upgrade that otherwise succeeded.
111
136
  if (commandNotFound(result.error)) {
112
137
  errLine("OpenClaw is not on PATH, so its TinyFish skill was left unchanged.");
113
- return;
138
+ return false;
114
139
  }
115
140
  if (result.error || result.status !== 0) {
116
141
  throwIfInterrupted(result);
142
+ replay(capturedOutput(result));
117
143
  throw new Error("Could not refresh the TinyFish skill in OpenClaw", { cause: result.error });
118
144
  }
145
+ return true;
119
146
  }
120
- function reinstallWebSkill(skillAgents) {
121
- errLine(`Refreshing the TinyFish web skill for ${skillAgents.join(", ")}...`);
147
+ function reinstallWebSkill(skillAgents, verbose) {
148
+ if (verbose)
149
+ errLine(`Refreshing the TinyFish web skill for ${skillAgents.join(", ")}...`);
122
150
  const result = spawn.sync("npx", skillAddArgs(skillAgents), {
123
151
  encoding: "utf8",
124
152
  env: skillSpawnEnv(),
153
+ maxBuffer: STEP_MAX_BUFFER,
125
154
  timeout: SKILL_INSTALL_TIMEOUT_MS,
126
155
  });
127
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
128
- if (output.trim())
129
- errLine(output.trimEnd());
130
- if (result.error || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output)) {
156
+ const output = capturedOutput(result);
157
+ const failed = Boolean(result.error) || result.status !== 0 || SKILL_INSTALL_FAILURE_PATTERN.test(output);
158
+ if (failed)
131
159
  throwIfInterrupted(result);
160
+ if (verbose || failed)
161
+ replay(output);
162
+ if (failed)
132
163
  throw new Error("Could not refresh the TinyFish web skill", { cause: result.error });
133
- }
134
164
  }
135
165
  /** Fallback when no harness is recorded: ask `skills` to refresh whatever it tracks. */
136
- function refreshWebSkillByHash() {
137
- errLine("Refreshing the TinyFish web skill...");
166
+ function refreshWebSkillByHash(verbose) {
167
+ if (verbose)
168
+ errLine("Refreshing the TinyFish web skill...");
138
169
  const result = spawn.sync("npx", ["-y", SKILLS_CLI_PACKAGE, "update", TINYFISH_WEB_SKILL, "--global", "--yes"], {
139
170
  encoding: "utf8",
140
171
  env: skillSpawnEnv(),
172
+ maxBuffer: STEP_MAX_BUFFER,
141
173
  timeout: SKILL_INSTALL_TIMEOUT_MS,
142
174
  });
143
- // Piped rather than inherited so the output can be inspected; echoed back for the user.
144
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
145
- if (output.trim())
146
- errLine(output.trimEnd());
147
- if (result.error || result.status !== 0 || SKILL_UPDATE_FAILURE_PATTERN.test(output)) {
175
+ // Piped so prose-only failures can be matched; skills exits 0 anyway.
176
+ const output = capturedOutput(result);
177
+ const unchecked = SKILL_UNCHECKABLE_PATTERN.test(output);
178
+ const failed = Boolean(result.error) || result.status !== 0 || SKILL_UPDATE_FAILURE_PATTERN.test(output);
179
+ if (failed)
148
180
  throwIfInterrupted(result);
181
+ if (verbose || failed || unchecked)
182
+ replay(output);
183
+ if (failed)
149
184
  throw new Error("Could not refresh the TinyFish web skill", { cause: result.error });
150
- }
151
- if (SKILL_UNCHECKABLE_PATTERN.test(output)) {
185
+ if (unchecked) {
152
186
  errLine("Run `tinyfish connect <client>` to reinstall the skill and restore update tracking.");
153
187
  throw new Error("The TinyFish web skill cannot be checked for updates");
154
188
  }
155
189
  if (SKILL_NOT_INSTALLED_PATTERN.test(output)) {
156
190
  errLine("No use-tinyfish skill installed. Run `tinyfish connect <client>` to add it.");
191
+ return false;
157
192
  }
193
+ return !SKILL_ALREADY_CURRENT_PATTERN.test(output);
158
194
  }
159
195
  export function ensureCliAuthenticated(source, apiKey) {
160
196
  const envKey = apiKey ?? process.env["TINYFISH_API_KEY"];
@@ -33,10 +33,13 @@ export class ConnectStepError extends Error {
33
33
  }
34
34
  class PrerequisiteError extends ConnectStepError {
35
35
  }
36
+ // A maxBuffer overflow is also killed with SIGTERM, and is a failure too.
37
+ const NOT_INTERRUPTIONS = new Set(["ETIMEDOUT", "ENOBUFS"]);
36
38
  export function throwIfInterrupted(result) {
37
39
  // spawn.sync kills a timed-out child with SIGTERM, so signal alone would misread a slow
38
40
  // network as the user walking away. A timeout is a failure and must report as one.
39
- if (result.error?.code === "ETIMEDOUT")
41
+ const code = result.error?.code;
42
+ if (code && NOT_INTERRUPTIONS.has(code))
40
43
  return;
41
44
  if (result.signal === "SIGINT" || result.signal === "SIGTERM") {
42
45
  throw new ConnectInterruptedError("Setup interrupted");
@@ -9,6 +9,11 @@ export interface RegistrationStatus {
9
9
  registeredUrl?: string;
10
10
  /** For doctor to verify; never for the report. */
11
11
  apiKey?: string;
12
+ /** From the environment, not the config, so a 401 is ambiguous. */
13
+ keyIsIndirect?: boolean;
14
+ connected?: boolean;
15
+ /** Why a keyed registration's key could not be read; not `reason`. */
16
+ keyReason?: string;
12
17
  reason?: string;
13
18
  }
14
19
  export declare function codexOauthCompleted(): boolean | undefined;
@@ -76,10 +76,23 @@ function runProbe(command, args) {
76
76
  }
77
77
  // A plugin registers its server as `plugin:<plugin>:<server>`, which `mcp get tinyfish` misses.
78
78
  // The optional prefix must end in `:`, so `not-tinyfish` is not a match.
79
- const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)/m;
79
+ const MCP_LIST_TINYFISH_LINE = /^((?:\S*:)?tinyfish):\s+(https?:\S+)(.*)$/m;
80
+ const SERVER_NAMED_TINYFISH = /^(?:\S*:)?tinyfish$/;
80
81
  // A bare substring also matches `not-tinyfish` and any row quoting a tinyfish URL, so the name
81
82
  // is required to be the first word on its line, after whatever status glyph the CLI prints.
82
83
  const LISTED_AS_TINYFISH = /^[^\w]*tinyfish(?![\w-])/m;
84
+ // Claude Code reports its own live handshake; a config file cannot.
85
+ const MCP_GET_STATUS = /^\s*Status:\s*(.+)$/m;
86
+ // `mcp get <missing>` names every configured server, with no health check.
87
+ const CONFIGURED_SERVERS = /Configured servers:\s*(.+)$/m;
88
+ /** Ordered: "Connected · tools fetch failed" carries both words; the failure wins. */
89
+ function connectionState(status) {
90
+ if (/fail|error|needs auth|not configured/i.test(status))
91
+ return false;
92
+ if (/\bconnected\b/i.test(status))
93
+ return true;
94
+ return undefined;
95
+ }
83
96
  /** The plugin ships its own MCP server, so a working install can carry no `tinyfish` entry. */
84
97
  function fromPluginRegistration(command) {
85
98
  const probe = runProbe(command, ["mcp", "list"]);
@@ -88,12 +101,37 @@ function fromPluginRegistration(command) {
88
101
  const match = MCP_LIST_TINYFISH_LINE.exec(probe.output);
89
102
  if (!match)
90
103
  return undefined;
104
+ const connected = connectionState(match[3] ?? "");
91
105
  // `mcp list` prints no headers, so the credential behind it is unproven, never assumed.
92
106
  return {
93
107
  registered: Registered.Yes,
94
108
  authMode: AuthMode.Unknown,
95
109
  registeredUrl: match[2],
96
- reason: `registered as ${match[1]}, which \`mcp get tinyfish\` does not resolve`,
110
+ ...(connected === undefined ? {} : { connected }),
111
+ reason: `registered as ${match[1]}`,
112
+ };
113
+ }
114
+ /** Names can contain spaces, so each candidate is matched whole. */
115
+ function tinyfishServerName(output) {
116
+ const listed = CONFIGURED_SERVERS.exec(output)?.[1];
117
+ return listed
118
+ ?.split(",")
119
+ .map((name) => name.trim())
120
+ .find((name) => SERVER_NAMED_TINYFISH.test(name));
121
+ }
122
+ function parseMcpGet(output, keyAuthPattern) {
123
+ // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
124
+ // of the pattern is `unknown`, never proof of OAuth.
125
+ const url = /^\s*URL:\s*(\S+)/m.exec(output)?.[1];
126
+ const key = extractableKey(API_KEY_HEADER_VALUE.exec(output)?.[1]);
127
+ const status = MCP_GET_STATUS.exec(output)?.[1];
128
+ const connected = status === undefined ? undefined : connectionState(status);
129
+ return {
130
+ registered: Registered.Yes,
131
+ authMode: keyAuthPattern.test(output) ? AuthMode.ApiKey : AuthMode.Unknown,
132
+ ...(url ? { registeredUrl: url } : {}),
133
+ ...(key ? { apiKey: key } : {}),
134
+ ...(connected === undefined ? {} : { connected }),
97
135
  };
98
136
  }
99
137
  // claude-code only. Codex cannot use this: its `mcp get` exits 0 on a missing server.
@@ -110,6 +148,17 @@ function fromMcpGet(command, keyAuthPattern) {
110
148
  reason: `\`${command} mcp get\` is unsupported by this version`,
111
149
  };
112
150
  }
151
+ // The roster is free; `mcp list` health-checks every server.
152
+ const scoped = tinyfishServerName(probe.output);
153
+ if (scoped) {
154
+ const rescoped = runProbe(command, ["mcp", "get", scoped]);
155
+ if (rescoped.outcome === "ran" && rescoped.exitCode === 0) {
156
+ return {
157
+ ...parseMcpGet(rescoped.output, keyAuthPattern),
158
+ reason: `registered as ${scoped}`,
159
+ };
160
+ }
161
+ }
113
162
  // Positive evidence from `mcp list` outranks anything inferred from a failed `mcp get`.
114
163
  const plugin = fromPluginRegistration(command);
115
164
  if (plugin)
@@ -124,16 +173,7 @@ function fromMcpGet(command, keyAuthPattern) {
124
173
  }
125
174
  return { registered: Registered.No, authMode: AuthMode.Unknown };
126
175
  }
127
- // Positive evidence only: whether `mcp get` echoes headers at all is unverified, so absence
128
- // of the pattern is `unknown`, never proof of OAuth.
129
- const url = /^\s*URL:\s*(\S+)/m.exec(probe.output)?.[1];
130
- const key = extractableKey(API_KEY_HEADER_VALUE.exec(probe.output)?.[1]);
131
- return {
132
- registered: Registered.Yes,
133
- authMode: keyAuthPattern.test(probe.output) ? AuthMode.ApiKey : AuthMode.Unknown,
134
- ...(url ? { registeredUrl: url } : {}),
135
- ...(key ? { apiKey: key } : {}),
136
- };
176
+ return parseMcpGet(probe.output, keyAuthPattern);
137
177
  }
138
178
  // `codex mcp get <missing>` prints an error and exits 0, so presence must never be read from
139
179
  // its exit code; `mcp list --json` is the only trustworthy read-back.
@@ -175,6 +215,22 @@ export function codexOauthCompleted() {
175
215
  return false;
176
216
  return undefined;
177
217
  }
218
+ // This reaches the pasted report, so its shape is checked first.
219
+ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
220
+ /** Codex stores a variable name, not a value, so resolve it. */
221
+ function codexEnvKey(envVar) {
222
+ if (!ENV_VAR_NAME.test(envVar)) {
223
+ return { keyReason: "its key comes from an unusable variable name" };
224
+ }
225
+ const key = extractableKey(process.env[envVar]);
226
+ if (key)
227
+ return { apiKey: key, keyIsIndirect: true };
228
+ return {
229
+ keyReason: process.env[envVar]
230
+ ? `$${envVar} does not hold a TinyFish key here`
231
+ : `$${envVar} is not set here`,
232
+ };
233
+ }
178
234
  function probeCodex() {
179
235
  const read = readCodexEntry();
180
236
  if ("reason" in read)
@@ -183,16 +239,18 @@ function probeCodex() {
183
239
  if (!entry)
184
240
  return { registered: Registered.No, authMode: AuthMode.Unknown };
185
241
  const codexUrl = entry.transport?.url;
242
+ // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
243
+ // the whole payload reports api-key for TinyFish because some other server carries a key.
244
+ const envVar = entry.auth_status === "o_auth" ? undefined : entry.transport?.bearer_token_env_var;
186
245
  return {
187
246
  registered: Registered.Yes,
188
- // Read from this entry: codex emits `bearer_token_env_var` on every HTTP server, so testing
189
- // the whole payload reports api-key for TinyFish because some other server carries a key.
190
247
  authMode: entry.auth_status === "o_auth"
191
248
  ? AuthMode.OAuth
192
- : entry.transport?.bearer_token_env_var
249
+ : envVar
193
250
  ? AuthMode.ApiKey
194
251
  : AuthMode.Unknown,
195
252
  ...(codexUrl ? { registeredUrl: codexUrl } : {}),
253
+ ...(envVar ? codexEnvKey(envVar) : {}),
196
254
  };
197
255
  }
198
256
  function probeCursor() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.21.1-next.194",
3
+ "version": "0.21.1-next.196",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {