@tiny-fish/cli 0.30.1-next.251 → 0.30.1-next.252

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.
@@ -5,7 +5,7 @@ import { captureStdio, capturedOutput, HoistedFailureReportedError, installTinyF
5
5
  import { ensureCliAuthenticated } from "../lib/connect-auth.js";
6
6
  import { createStdinPrompt, runCliFallback } from "../lib/connect-fallback.js";
7
7
  import { installWebSkill } from "../lib/skill-install.js";
8
- import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, throwIfInterrupted, NON_INTERACTIVE_TIMEOUT_MS, } from "../lib/connect-runtime.js";
8
+ import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, spawnRemoval, } from "../lib/connect-runtime.js";
9
9
  import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from "../lib/cursor-config.js";
10
10
  import { runConnectAll } from "../lib/connect-all.js";
11
11
  import { AuthMode, RELOAD_ACTION } from "../lib/harness-detect.js";
@@ -18,13 +18,9 @@ import { z } from "zod";
18
18
  import { verifyMcpAuth } from "../lib/verify.js";
19
19
  export const DEFAULT_MCP_URL = "https://agent.tinyfish.ai/mcp";
20
20
  function removeExistingRegistration(client, removal, verbose) {
21
- const result = spawn.sync(client.command, removal.args, {
22
- encoding: "utf8",
23
- timeout: NON_INTERACTIVE_TIMEOUT_MS,
24
- });
21
+ const result = spawnRemoval(client.command, removal.args);
25
22
  if (result.status === 0)
26
23
  return;
27
- throwIfInterrupted(result);
28
24
  // Best-effort: a stale entry makes `mcp add` error or upsert, with a truer message.
29
25
  if (!verbose)
30
26
  return;
@@ -32,6 +32,10 @@ export declare function writeConfig(apiKey: string): void;
32
32
  export declare function saveConfig(apiKey: string): void;
33
33
  /** `authMode` omitted rewrites the entry without one: a reconnect that degraded must not keep a stale key claim. */
34
34
  export declare function saveConnectContext(client: string, attemptId: string, authMode?: RecordedAuthMode): void;
35
+ /** Distinguishes a missing entry from an unreadable config file. */
36
+ export declare function connectContextState(client: string): "present" | "absent" | "unreadable";
37
+ /** Kept marker after uninstall makes doctor offer to reinstall the harness. */
38
+ export declare function clearConnectContext(client: string): void;
35
39
  interface EnvironmentOptions {
36
40
  platform?: typeof process.platform;
37
41
  shell?: string;
package/dist/lib/auth.js CHANGED
@@ -142,6 +142,43 @@ export function saveConnectContext(client, attemptId, authMode) {
142
142
  errLine(`Warning: could not persist connect context: ${e instanceof Error ? e.message : String(e)}`);
143
143
  }
144
144
  }
145
+ /** Distinguishes a missing entry from an unreadable config file. */
146
+ export function connectContextState(client) {
147
+ let raw;
148
+ try {
149
+ raw = fs.readFileSync(configFile(), "utf8");
150
+ }
151
+ catch (e) {
152
+ return e.code === "ENOENT" ? "absent" : "unreadable";
153
+ }
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ const entry = parsed && typeof parsed === "object" ? parsed.connect?.[client] : undefined;
157
+ return entry ? "present" : "absent";
158
+ }
159
+ catch {
160
+ return "unreadable";
161
+ }
162
+ }
163
+ /** Kept marker after uninstall makes doctor offer to reinstall the harness. */
164
+ export function clearConnectContext(client) {
165
+ // Read-first: writing through an unreadable config would rewrite it as {}.
166
+ if (connectContextState(client) !== "present")
167
+ return;
168
+ try {
169
+ updateConfig((config) => {
170
+ if (!config.connect?.[client])
171
+ return config;
172
+ const remaining = Object.fromEntries(Object.entries(config.connect).filter(([key]) => key !== client));
173
+ const { connect, ...rest } = config;
174
+ void connect;
175
+ return Object.keys(remaining).length > 0 ? { ...rest, connect: remaining } : rest;
176
+ });
177
+ }
178
+ catch (e) {
179
+ errLine(`Warning: could not clear connect context: ${e instanceof Error ? e.message : String(e)}`);
180
+ }
181
+ }
145
182
  export function persistApiKeyToEnvironment(apiKey, options = {}) {
146
183
  const platform = options.platform ?? process.platform;
147
184
  if (platform === "win32") {
@@ -1,11 +1,11 @@
1
1
  import * as readline from "node:readline/promises";
2
2
  import spawn from "cross-spawn";
3
3
  import { connectClaudeCode, connectCodex, connectCursor, connectGrok, connectHermes, connectOpenClaw, connectOpencode, } from "../commands/connect.js";
4
- import { loadConfig, validatedApiKey } from "./auth.js";
4
+ import { clearConnectContext, connectContextState, loadConfig, validatedApiKey, } from "./auth.js";
5
5
  import { installTinyFishCli, } from "./cli-install.js";
6
- import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS } from "./connect-clients.js";
6
+ import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS, openclawSkillUninstall, } from "./connect-clients.js";
7
7
  import { createStdinPrompt, runCliFallback } from "./connect-fallback.js";
8
- import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, } from "./connect-runtime.js";
8
+ import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, spawnRemoval, } from "./connect-runtime.js";
9
9
  import { pickHarnesses } from "./connect-picker.js";
10
10
  import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, RELOAD_ACTION, } from "./harness-detect.js";
11
11
  import { detectHumanInitiated } from "./harness.js";
@@ -37,16 +37,8 @@ const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
37
37
  }));
38
38
  // Derived: a native add carries the key, or the CLI holds it itself.
39
39
  const KEY_AUTH_CAPABLE = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyAuth || HARNESS_CAPABILITIES[harness].keyHeldByCli));
40
- // Real removal commands — harness id is not the binary name.
41
- const UNINSTALL_POINTERS = {
42
- "claude-code": "claude mcp remove tinyfish --scope user",
43
- codex: "codex mcp remove tinyfish",
44
- grok: "grok mcp remove tinyfish",
45
- hermes: "hermes mcp remove tinyfish",
46
- openclaw: "openclaw skills uninstall @tinyfish/tinyfish --global",
47
- // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
48
- opencode: "remove the tinyfish entry from ~/.config/opencode/opencode.json",
49
- };
40
+ // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
41
+ const OPENCODE_UNINSTALL_POINTER = "remove the tinyfish entry from ~/.config/opencode/opencode.json";
50
42
  // A new harness fails to compile until wired here.
51
43
  const HARNESS_CONNECTORS = {
52
44
  "claude-code": connectClaudeCode,
@@ -110,13 +102,97 @@ function uninstallPlanText(harness) {
110
102
  if (harness === "cursor") {
111
103
  return "would remove the tinyfish entry from ~/.cursor/mcp.json";
112
104
  }
113
- return `would print the removal command \`${UNINSTALL_POINTERS[harness]}\` (config is harness-owned; the CLI removes nothing)`;
105
+ if (!uninstallRuns(harness)) {
106
+ return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
107
+ }
108
+ return `would run \`${uninstallPointer(harness)}\``;
109
+ }
110
+ /** Undefined: no harness-CLI removal exists (opencode). */
111
+ function uninstallRuns(harness) {
112
+ if (harness === "openclaw") {
113
+ const removal = openclawSkillUninstall();
114
+ return { command: removal.command, runs: [removal.args], timeoutMs: removal.timeoutMs };
115
+ }
116
+ const native = NATIVE_BY_HARNESS.get(harness);
117
+ if (!native || native.removals.length === 0)
118
+ return undefined;
119
+ return { command: native.command, runs: native.removals.map((r) => r.args) };
114
120
  }
115
- function uninstallHarness(harness, configPath) {
121
+ /** Derived from the same args the spawn runs, so the advice cannot drift. */
122
+ function uninstallPointer(harness) {
123
+ const removal = uninstallRuns(harness);
124
+ if (!removal)
125
+ return OPENCODE_UNINSTALL_POINTER;
126
+ // `;` runs every removal but breaks cmd.exe; `&&` is the reverse — see
127
+ // internal-docs/install-command-shell-quirks.md (PS 5.1 stays a known gap).
128
+ const joiner = process.platform === "win32" ? " && " : " ; ";
129
+ return removal.runs.map((args) => `${removal.command} ${args.join(" ")}`).join(joiner);
130
+ }
131
+ function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
116
132
  const detection = { harness, detected: true, configPath, installed: false };
117
133
  if (harness !== "cursor") {
118
- // Harness-owned config — removal happens via each tool's own CLI.
119
- return { ...detection, outcome: "uninstall_pointer", fixCommand: UNINSTALL_POINTERS[harness] };
134
+ const removal = uninstallRuns(harness);
135
+ if (!removal) {
136
+ return { ...detection, outcome: "uninstall_pointer", fixCommand: uninstallPointer(harness) };
137
+ }
138
+ // Stale config: the harness binary is gone, so its removal command cannot run.
139
+ // openclaw is exempt — its removal runs through npx, not the harness binary.
140
+ if (harness !== "openclaw" && !binaryOnPath) {
141
+ return {
142
+ ...detection,
143
+ outcome: "uninstall_pointer",
144
+ errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
145
+ fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
146
+ };
147
+ }
148
+ // Absent marker = never connected here, so a non-zero removal exit is a no-op.
149
+ // Unreadable config cannot prove that, so it keeps the pointer.
150
+ const connectedBefore = connectContextState(harness) !== "absent";
151
+ let removed = false;
152
+ try {
153
+ for (const args of removal.runs) {
154
+ const result = spawnRemoval(removal.command, args, removal.timeoutMs);
155
+ if (result.status !== 0 && verbose) {
156
+ const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
157
+ errLine(`${removal.command} ${args.join(" ")}: ${details}`);
158
+ }
159
+ // Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
160
+ if (result.error) {
161
+ return {
162
+ ...detection,
163
+ outcome: "uninstalled",
164
+ errorMessage: `could not run \`${removal.command} ${args.join(" ")}\` (${result.error.message}) — nothing removed.`,
165
+ fixCommand: uninstallPointer(harness),
166
+ };
167
+ }
168
+ // Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
169
+ if (result.status === 0)
170
+ removed = true;
171
+ }
172
+ }
173
+ catch (e) {
174
+ if (e instanceof ConnectInterruptedError) {
175
+ return {
176
+ ...detection,
177
+ outcome: "interrupted",
178
+ fixCommand: "tinyfish connect --all --uninstall",
179
+ };
180
+ }
181
+ throw e;
182
+ }
183
+ if (removed) {
184
+ // Kept marker makes doctor emit a registration-lost repair that undoes this.
185
+ clearConnectContext(harness);
186
+ return { ...detection, outcome: "uninstalled" };
187
+ }
188
+ if (!connectedBefore)
189
+ return { ...detection, outcome: "uninstall_noop" };
190
+ return {
191
+ ...detection,
192
+ outcome: "uninstall_pointer",
193
+ errorMessage: "could not confirm removal (command exited non-zero; there may have been nothing to remove).",
194
+ fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
195
+ };
120
196
  }
121
197
  let result;
122
198
  try {
@@ -136,6 +212,7 @@ function uninstallHarness(harness, configPath) {
136
212
  if (result.status === "unchanged") {
137
213
  return { ...detection, outcome: "uninstall_noop" };
138
214
  }
215
+ clearConnectContext("cursor");
139
216
  return { ...detection, outcome: "uninstalled" };
140
217
  }
141
218
  async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliInstall, deferOutro) {
@@ -154,7 +231,7 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
154
231
  return { ...base, outcome: "dry_run", fixCommand: plan };
155
232
  }
156
233
  if (opts.uninstall)
157
- return uninstallHarness(harness, configPath);
234
+ return uninstallHarness(harness, configPath, opts.verbose ?? false, detection.binaryOnPath);
158
235
  const isTTY = detectHumanInitiated();
159
236
  const apiKey = validatedApiKey(opts.apiKey);
160
237
  // A key only leaves the own-OAuth set where the harness can actually carry one.
@@ -285,7 +362,10 @@ function summaryLine(result, uninstall) {
285
362
  : `… ${name} — needs a browser sign-in. Run in a terminal: ${result.fixCommand}`;
286
363
  }
287
364
  if (result.outcome === "uninstall_pointer") {
288
- return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
365
+ // errorMessage set = a removal was attempted or impossible; absent = no removal command exists.
366
+ return result.errorMessage
367
+ ? `… ${name} — ${result.errorMessage} ${result.fixCommand}`
368
+ : `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
289
369
  }
290
370
  if (result.outcome === "uninstall_noop") {
291
371
  return `… ${name} — no tinyfish entry found, nothing removed`;
@@ -367,6 +447,12 @@ function actionable(result) {
367
447
  return result.detected && result.outcome !== "stale_config";
368
448
  }
369
449
  function computeExitCode(results) {
450
+ // Flag-independent: uninstall interrupts have no other 130 site.
451
+ if (results.some((r) => r.outcome === "interrupted"))
452
+ return 130;
453
+ // A removal that could not run is a failure, not a silent success.
454
+ if (results.some((r) => r.outcome === "uninstalled" && r.errorMessage))
455
+ return 1;
370
456
  const detected = results.filter(actionable);
371
457
  if (detected.length === 0)
372
458
  return 1;
@@ -1,7 +1,14 @@
1
1
  import spawn from "cross-spawn";
2
- import type { Harness } from "./harness-detect.js";
2
+ import { type Harness } from "./harness-detect.js";
3
3
  import { type AgentClient, type SupportedCommand } from "./connect-runtime.js";
4
4
  export declare const OPENCLAW_SKILL_INSTALL_ARGS: readonly string[];
5
+ /** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
6
+ export declare function openclawSkillUninstall(): {
7
+ command: string;
8
+ args: string[];
9
+ /** npx fetches the package first; the 10s removal bound starves a cold cache. */
10
+ timeoutMs: number;
11
+ };
5
12
  export declare const DEFAULT_ONBOARDING_PROMPT: string;
6
13
  export declare const OPENCLAW_ONBOARDING_PROMPT: string;
7
14
  /** The `skills` CLI's own agent names — not ours, and not every harness has one. */
@@ -1,4 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
+ import { SKILL_INSTALL_TIMEOUT_MS } from "./cli-install.js";
3
+ import { harnessConfigPath } from "./harness-detect.js";
2
4
  import { errLine, sanitizeLine } from "./output.js";
3
5
  import { spawnStepError, } from "./connect-runtime.js";
4
6
  import { TINYFISH_API_KEY_VAR } from "./constants.js";
@@ -55,6 +57,23 @@ export const OPENCLAW_SKILL_INSTALL_ARGS = [
55
57
  "--force",
56
58
  "--acknowledge-clawhub-risk",
57
59
  ];
60
+ /** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
61
+ export function openclawSkillUninstall() {
62
+ return {
63
+ command: "npx",
64
+ args: [
65
+ "-y",
66
+ // Pinned: npx would otherwise execute whatever is newest at run time.
67
+ "clawhub@0.23.3",
68
+ "--workdir",
69
+ harnessConfigPath("openclaw"),
70
+ "uninstall",
71
+ OPENCLAW_SKILL,
72
+ "--yes",
73
+ ],
74
+ timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
75
+ };
76
+ }
58
77
  export const DEFAULT_ONBOARDING_PROMPT = "I just connected TinyFish. Call the guide_next_step tool now to begin. Then follow its " +
59
78
  "instructions one step at a time. Ask for my input and wait for my reply before each subsequent " +
60
79
  "TinyFish tool call.";
@@ -253,7 +272,7 @@ export const OPENCODE = {
253
272
  keyAuth: headerKeyAuth,
254
273
  // `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
255
274
  removals: [],
256
- // OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
275
+ // OpenCode writes ~/.config/opencode/opencode.json itself; `mcp auth` is the separate OAuth step.
257
276
  addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
258
277
  loginArgs: ["mcp", "auth", "tinyfish"],
259
278
  launchWalkthrough: launchOpencode,
@@ -33,6 +33,8 @@ export interface SpawnStepResult {
33
33
  signal?: string | null;
34
34
  error?: Error;
35
35
  }
36
+ /** Bounded removal spawn shared by install cleanup and --uninstall; Ctrl+C throws. */
37
+ export declare function spawnRemoval(command: string, args: readonly string[], timeoutMs?: number): import("child_process").SpawnSyncReturns<string>;
36
38
  /** Classifies a spawn failure; interrupts throw instead. */
37
39
  export declare function spawnStepError(message: string, result: SpawnStepResult): ConnectStepError;
38
40
  export declare function commandNotFound(error: unknown): boolean;
@@ -55,6 +55,15 @@ export function throwIfInterrupted(result) {
55
55
  throw new ConnectInterruptedError("Setup interrupted");
56
56
  }
57
57
  }
58
+ /** Bounded removal spawn shared by install cleanup and --uninstall; Ctrl+C throws. */
59
+ export function spawnRemoval(command, args, timeoutMs = NON_INTERACTIVE_TIMEOUT_MS) {
60
+ const result = spawn.sync(command, [...args], {
61
+ encoding: "utf8",
62
+ timeout: timeoutMs,
63
+ });
64
+ throwIfInterrupted(result);
65
+ return result;
66
+ }
58
67
  /** Classifies a spawn failure; interrupts throw instead. */
59
68
  export function spawnStepError(message, result) {
60
69
  throwIfInterrupted(result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.30.1-next.251",
3
+ "version": "0.30.1-next.252",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {