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

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.
@@ -50,5 +50,5 @@ export declare function connectCursor(options: {
50
50
  attemptId?: string;
51
51
  verbose?: boolean;
52
52
  }): Promise<void>;
53
- export declare function launchAgent(client: AgentClient): void;
53
+ export declare function launchAgent(client: AgentClient): Promise<void>;
54
54
  export declare function registerConnect(program: Command): void;
@@ -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;
@@ -208,7 +204,7 @@ async function connectNativeMcpClient(client, options) {
208
204
  state.authMode = authMode;
209
205
  // Registration/OAuth make MCP work; later steps are cosmetic and must not fail the attempt.
210
206
  settle(state, telemetry, "completed", { authMode });
211
- runPostInstallSteps(client, options, state, telemetry, signInDeferred);
207
+ await runPostInstallSteps(client, options, state, telemetry, signInDeferred);
212
208
  });
213
209
  return state.authMode;
214
210
  }
@@ -224,13 +220,13 @@ function connectedLine(client, signInDeferred) {
224
220
  `${RELOAD_ACTION[client.connectClient]} to pick up the tools.`);
225
221
  }
226
222
  // Runs after `completed` settled: steps fail alone, the rest still run.
227
- function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
223
+ async function runPostInstallSteps(client, options, state, telemetry, signInDeferred) {
228
224
  let failed = false;
229
225
  // Modelled on upgrade's attempt(); interrupts rethrow and abandon remaining steps.
230
- const attempt = (stage, run) => {
226
+ const attempt = async (stage, run) => {
231
227
  state.stage = stage;
232
228
  try {
233
- run();
229
+ await run();
234
230
  }
235
231
  catch (error) {
236
232
  if (error instanceof ConnectInterruptedError)
@@ -241,17 +237,17 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
241
237
  };
242
238
  try {
243
239
  if (options.cliInstall !== false) {
244
- attempt("cli_install", () => runCliInstallStep(options, telemetry));
240
+ await attempt("cli_install", () => runCliInstallStep(options, telemetry));
245
241
  }
246
242
  if (client.skillAgent) {
247
- attempt("skill_install", () => {
243
+ await attempt("skill_install", () => {
248
244
  installWebSkill(client, { verbose: options.verbose ?? false });
249
245
  telemetry.track("checkpoint", { phase: "skill_installed" });
250
246
  });
251
247
  }
252
248
  // Before auth: a failed sign-in must not lose the harness record.
253
249
  saveConnectContext(client.connectClient, telemetry.attemptId, state.authMode);
254
- attempt("authentication", () => {
250
+ await attempt("authentication", () => {
255
251
  // A set timeout means headless; never shell an interactive login.
256
252
  ensureCliAuthenticated(client.connectClient, options.apiKey, {
257
253
  interactiveLogin: options.authTimeoutMs === undefined,
@@ -266,11 +262,15 @@ function runPostInstallSteps(client, options, state, telemetry, signInDeferred)
266
262
  errLine(connectedLine(client, signInDeferred));
267
263
  }
268
264
  else {
269
- attempt("walkthrough_launch", () => {
265
+ await attempt("walkthrough_launch", async () => {
270
266
  // Before the handover, or the only sign-in instructions scroll past under the agent.
271
267
  if (signInDeferred && client.signInHint)
272
268
  errLine(client.signInHint);
273
- launchNativeMcpClient(client);
269
+ await launchNativeMcpClient(client, async (outcome) => {
270
+ telemetry.track("checkpoint", { phase: walkthroughPhase(outcome) });
271
+ // Flushed pre-block; the session-long handover would strand the delivery.
272
+ await telemetry.flush();
273
+ });
274
274
  });
275
275
  }
276
276
  }
@@ -304,6 +304,9 @@ function runCliInstallStep(options, telemetry) {
304
304
  installTinyFishCli({ verbose: options.verbose ?? false, announce: true });
305
305
  telemetry.track("checkpoint", { phase: "cli_installed" });
306
306
  }
307
+ function walkthroughPhase(outcome) {
308
+ return outcome === "printed" ? "walkthrough_prompt_printed" : "walkthrough_launched";
309
+ }
307
310
  function finishSetupHint(connectClient) {
308
311
  return `Finish setup with: ${finishSetupCommand(connectClient)}`;
309
312
  }
@@ -396,7 +399,11 @@ export async function connectOpenClaw(options) {
396
399
  }
397
400
  state.stage = "walkthrough_launch";
398
401
  try {
399
- launchOpenClawWalkthrough();
402
+ await launchOpenClawWalkthrough(async (outcome) => {
403
+ telemetry.track("checkpoint", { phase: walkthroughPhase(outcome) });
404
+ // Flushed pre-block; the session-long handover would strand the delivery.
405
+ await telemetry.flush();
406
+ });
400
407
  }
401
408
  catch (error) {
402
409
  trackPostInstallFailure("OpenClaw", state, telemetry, error);
@@ -515,8 +522,8 @@ const AGENT_LAUNCHERS = {
515
522
  throw new Error("Cursor has no launchable walkthrough. Open Cursor and start chatting.");
516
523
  },
517
524
  };
518
- export function launchAgent(client) {
519
- AGENT_LAUNCHERS[client]();
525
+ export async function launchAgent(client) {
526
+ await AGENT_LAUNCHERS[client]();
520
527
  }
521
528
  // A new client fails to compile until wired here.
522
529
  const CLIENT_CONNECTORS = {
@@ -187,7 +187,7 @@ async function runAgentSteps(interactive) {
187
187
  if (!launchId)
188
188
  return;
189
189
  try {
190
- launchAgent(launchId);
190
+ await launchAgent(launchId);
191
191
  }
192
192
  catch (error) {
193
193
  errLine(` Could not open ${agentLabel(launchId)}: ${errorMessage(error)}`);
@@ -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. */
@@ -38,7 +45,10 @@ export interface NativeMcpClient extends SupportedCommand {
38
45
  label: string;
39
46
  }[];
40
47
  addArgs: (mcpUrl: string, oauthResource: string) => string[];
48
+ /** Blocking terminal walkthrough; runs only behind the TTY gate. */
41
49
  launchWalkthrough?: () => void;
50
+ /** Own window, never inherits stdio; reports its own outcome. */
51
+ detachedLaunch?: () => WalkthroughOutcome;
42
52
  /** Printed after a successful connect, on both the launch and non-launch paths. */
43
53
  postConnectNote?: string;
44
54
  }
@@ -73,5 +83,10 @@ export interface HarnessCapabilities {
73
83
  }
74
84
  /** Required booleans: a new harness or new flag fails to compile undecided. */
75
85
  export declare const HARNESS_CAPABILITIES: Record<Harness, HarnessCapabilities>;
76
- export declare function launchNativeMcpClient(client: NativeMcpClient): void;
77
- export declare function launchOpenClawWalkthrough(): void;
86
+ /** "printed" = handed to the user to paste; the walkthrough was never started for them. */
87
+ export type WalkthroughOutcome = "launched" | "printed";
88
+ /** Awaited before the handover; track+flush must land pre-block. */
89
+ type OnWalkthroughDecided = (outcome: WalkthroughOutcome) => void | Promise<void>;
90
+ export declare function launchNativeMcpClient(client: NativeMcpClient, onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
91
+ export declare function launchOpenClawWalkthrough(onDecided?: OnWalkthroughDecided): Promise<WalkthroughOutcome>;
92
+ export {};
@@ -1,4 +1,7 @@
1
1
  import spawn from "cross-spawn";
2
+ import { SKILL_INSTALL_TIMEOUT_MS } from "./cli-install.js";
3
+ import { detectHumanInitiated } from "./harness.js";
4
+ import { harnessConfigPath } from "./harness-detect.js";
2
5
  import { errLine, sanitizeLine } from "./output.js";
3
6
  import { spawnStepError, } from "./connect-runtime.js";
4
7
  import { TINYFISH_API_KEY_VAR } from "./constants.js";
@@ -55,9 +58,30 @@ export const OPENCLAW_SKILL_INSTALL_ARGS = [
55
58
  "--force",
56
59
  "--acknowledge-clawhub-risk",
57
60
  ];
58
- export const DEFAULT_ONBOARDING_PROMPT = "I just connected TinyFish. Call the guide_next_step tool now to begin. Then follow its " +
59
- "instructions one step at a time. Ask for my input and wait for my reply before each subsequent " +
60
- "TinyFish tool call.";
61
+ /** `openclaw skills` has no uninstall; ClawHub removes from the global dir. */
62
+ export function openclawSkillUninstall() {
63
+ return {
64
+ command: "npx",
65
+ args: [
66
+ "-y",
67
+ // Pinned: npx would otherwise execute whatever is newest at run time.
68
+ "clawhub@0.23.3",
69
+ "--workdir",
70
+ harnessConfigPath("openclaw"),
71
+ "uninstall",
72
+ OPENCLAW_SKILL,
73
+ "--yes",
74
+ ],
75
+ timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
76
+ };
77
+ }
78
+ export const DEFAULT_ONBOARDING_PROMPT = "I just connected TinyFish. Use the MCP server named `tinyfish` for every TinyFish tool call; " +
79
+ "if another TinyFish server is also registered, still use `tinyfish` and do not ask me which. " +
80
+ "Start by asking me what I want to look up on the web, and wait for my reply. Then call " +
81
+ "`guide_next_step` and follow its instructions one step at a time, asking for my input and " +
82
+ "waiting for my reply before each subsequent TinyFish tool call. If a tool call comes back " +
83
+ "blocked or not approved, tell me which tool needs approval and how to grant it, then wait for " +
84
+ "me instead of ending the walkthrough.";
61
85
  export const OPENCLAW_ONBOARDING_PROMPT = "I just installed the TinyFish skill. Guide me through it interactively: first ask what topic I " +
62
86
  "want to search and wait for my reply before using TinyFish Search. Show me the results, ask " +
63
87
  "which result I want to read, and wait before using TinyFish Fetch. Then ask what browser task " +
@@ -133,9 +157,12 @@ function launchCodexWalkthrough() {
133
157
  deepLink.searchParams.set("path", process.cwd());
134
158
  const result = openExternalUrl(deepLink.toString());
135
159
  if (result.error || result.status !== 0) {
136
- throw spawnStepError("Could not open Codex", result);
160
+ // Headless box: nothing can open a window; recover like the gated clients.
161
+ printPromptForHandoff("Codex", "codex", DEFAULT_ONBOARDING_PROMPT, "Codex could not be opened here (no `codex://` handler)");
162
+ return "printed";
137
163
  }
138
164
  errLine("Codex opened with the TinyFish walkthrough ready. Send the prompt to start.");
165
+ return "launched";
139
166
  }
140
167
  export const CODEX = {
141
168
  command: "codex",
@@ -154,7 +181,7 @@ export const CODEX = {
154
181
  envVar: TINYFISH_API_KEY_VAR,
155
182
  addArgs: (mcpUrl) => [...urlAddArgs(mcpUrl), "--bearer-token-env-var", TINYFISH_API_KEY_VAR],
156
183
  },
157
- launchWalkthrough: launchCodexWalkthrough,
184
+ detachedLaunch: launchCodexWalkthrough,
158
185
  // Retry only: running it after a finished inline OAuth is a second browser hop.
159
186
  loginArgs: ["mcp", "login", "tinyfish"],
160
187
  removals: [{ args: ["mcp", "remove", "tinyfish"], label: "TinyFish registration from Codex" }],
@@ -253,7 +280,7 @@ export const OPENCODE = {
253
280
  keyAuth: headerKeyAuth,
254
281
  // `opencode mcp add` upserts by name and there is no `opencode mcp remove`, so no cleanup step.
255
282
  removals: [],
256
- // OpenCode writes ~/.config/opencode/opencode.jsonc itself; `mcp auth` is the separate OAuth step.
283
+ // OpenCode writes ~/.config/opencode/opencode.json itself; `mcp auth` is the separate OAuth step.
257
284
  addArgs: (mcpUrl) => urlAddArgs(mcpUrl),
258
285
  loginArgs: ["mcp", "auth", "tinyfish"],
259
286
  launchWalkthrough: launchOpencode,
@@ -331,15 +358,40 @@ export const HARNESS_CAPABILITIES = {
331
358
  authDeferredAtInstall: false,
332
359
  },
333
360
  };
334
- export function launchNativeMcpClient(client) {
335
- errLine(`Starting the TinyFish walkthrough in ${client.displayName}...`);
336
- if (client.launchWalkthrough) {
337
- client.launchWalkthrough();
338
- return;
361
+ function printPromptForHandoff(displayName, command, prompt, reason) {
362
+ // "set up", not "connected": sign-in can still be deferred here.
363
+ errLine(`TinyFish is set up in ${displayName}.`);
364
+ errLine(`${reason}, so TinyFish did not launch it. Start ${displayName} (\`${command}\`) yourself ` +
365
+ `and paste this first message:`);
366
+ errLine("");
367
+ errLine(prompt);
368
+ }
369
+ /** One gate + one decide-before-block path for every terminal handover. */
370
+ async function deliverWalkthrough(displayName, command, prompt, handover, onDecided) {
371
+ // Inherited-stdio handover: a non-TTY parent cannot approve tool calls.
372
+ if (!detectHumanInitiated()) {
373
+ await onDecided?.("printed");
374
+ printPromptForHandoff(displayName, command, prompt, `This terminal cannot answer ${displayName}'s tool-approval prompts`);
375
+ return "printed";
376
+ }
377
+ // Decided and delivered first: the handover blocks for the whole session.
378
+ await onDecided?.("launched");
379
+ errLine(`Starting the TinyFish walkthrough in ${displayName}...`);
380
+ handover();
381
+ return "launched";
382
+ }
383
+ export async function launchNativeMcpClient(client, onDecided) {
384
+ if (client.detachedLaunch) {
385
+ // Detached: the open attempt never blocks, so decide after it.
386
+ errLine(`Starting the TinyFish walkthrough in ${client.displayName}...`);
387
+ const outcome = client.detachedLaunch();
388
+ await onDecided?.(outcome);
389
+ return outcome;
339
390
  }
340
- handOverTerminal(client.command, [DEFAULT_ONBOARDING_PROMPT], client.displayName);
391
+ return deliverWalkthrough(client.displayName, client.command, DEFAULT_ONBOARDING_PROMPT, () => client.launchWalkthrough
392
+ ? client.launchWalkthrough()
393
+ : handOverTerminal(client.command, [DEFAULT_ONBOARDING_PROMPT], client.displayName), onDecided);
341
394
  }
342
- export function launchOpenClawWalkthrough() {
343
- errLine("Starting the TinyFish walkthrough in OpenClaw...");
344
- handOverTerminal("openclaw", ["chat", "--message", OPENCLAW_ONBOARDING_PROMPT], "OpenClaw");
395
+ export function launchOpenClawWalkthrough(onDecided) {
396
+ return deliverWalkthrough("OpenClaw", "openclaw", OPENCLAW_ONBOARDING_PROMPT, () => handOverTerminal("openclaw", ["chat", "--message", OPENCLAW_ONBOARDING_PROMPT], "OpenClaw"), onDecided);
345
397
  }
@@ -6,7 +6,7 @@ export type ConnectFailureStage = "prerequisite_check" | "registration_cleanup"
6
6
  export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "key_auth_unsupported", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
7
7
  export type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
8
8
  export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
9
- export type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated" | "cli_verified";
9
+ export type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated" | "cli_verified" | "walkthrough_launched" | "walkthrough_prompt_printed";
10
10
  export type CliFallbackOutcome = "cli_verified" | "cli_install_failed" | "no_credentials" | "invalid_credentials" | "authentication_failed" | "verification_failed" | "abandoned";
11
11
  /** Ctrl+C killed a setup child — abandonment, not error. */
12
12
  export declare class ConnectInterruptedError extends Error {
@@ -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.253",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {