@tiny-fish/cli 0.31.1-next.269 → 0.31.1-next.272

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.
@@ -665,13 +665,14 @@ function parseMcpUrlOption(options) {
665
665
  }
666
666
  return { mcpUrl, attemptId };
667
667
  }
668
- async function connectAllPath(options, mcpUrl) {
668
+ async function connectAllPath(options, mcpUrl, attemptId) {
669
669
  if (options.launch) {
670
670
  throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
671
671
  }
672
672
  const exitCode = await runConnectAll({
673
673
  apiKey: options.apiKey,
674
674
  mcpUrl,
675
+ attemptId,
675
676
  dryRun: options.dryRun ?? false,
676
677
  uninstall: options.uninstall ?? false,
677
678
  noLaunch: options.skipLaunch === true,
@@ -681,7 +682,7 @@ async function connectAllPath(options, mcpUrl) {
681
682
  if (!process.exitCode)
682
683
  process.exitCode = exitCode;
683
684
  }
684
- async function connectPickPath(options, mcpUrl) {
685
+ async function connectPickPath(options, mcpUrl, attemptId) {
685
686
  if (options.launch) {
686
687
  throw new Error('--launch is not supported with --all. Connect a single client to launch it.');
687
688
  }
@@ -692,6 +693,7 @@ async function connectPickPath(options, mcpUrl) {
692
693
  const exitCode = await runConnectAll({
693
694
  apiKey: options.apiKey,
694
695
  mcpUrl,
696
+ attemptId,
695
697
  dryRun: false,
696
698
  uninstall: false,
697
699
  pick: humanInitiated,
@@ -743,14 +745,14 @@ export function registerConnect(program) {
743
745
  if (client) {
744
746
  throw new Error('Pass either a client or --all, not both.');
745
747
  }
746
- await connectAllPath(options, mcpUrl);
748
+ await connectAllPath(options, mcpUrl, attemptId);
747
749
  return;
748
750
  }
749
751
  if (options.dryRun || options.uninstall || options.skipLaunch) {
750
752
  throw new Error('--dry-run, --uninstall and --skip-launch are only supported with --all.');
751
753
  }
752
754
  if (!client) {
753
- await connectPickPath(options, mcpUrl);
755
+ await connectPickPath(options, mcpUrl, attemptId);
754
756
  return;
755
757
  }
756
758
  await connectSingleClient(client, options, mcpUrl, attemptId);
@@ -0,0 +1,19 @@
1
+ import { type Prompt } from './connect-fallback.js';
2
+ import type { HarnessResultBase, HarnessRunResult } from './connect-all.js';
3
+ export declare const OAUTH_SIGN_IN_TIMEOUT_MS = 90000;
4
+ interface AuthGateOutcome {
5
+ result?: HarnessRunResult;
6
+ headlessAuth: boolean;
7
+ spentBrowserSlot: boolean;
8
+ }
9
+ export declare function mapConnectError(base: HarnessResultBase, e: unknown, oauthBudget: {
10
+ spent: boolean;
11
+ }, spentBrowserSlot: boolean, fixCommand: string): HarnessRunResult;
12
+ export declare function authGate(base: HarnessResultBase, oauthBudget: {
13
+ spent: boolean;
14
+ }, fixCommand: string, ctx: {
15
+ prompt: Prompt | undefined;
16
+ isTTY: boolean;
17
+ keyed: boolean;
18
+ }): Promise<AuthGateOutcome>;
19
+ export {};
@@ -0,0 +1,110 @@
1
+ import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS } from './connect-clients.js';
2
+ import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, } from './connect-runtime.js';
3
+ import { ALL_HARNESSES, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, } from './harness-detect.js';
4
+ import { errLine } from './output.js';
5
+ // Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
6
+ export const OAUTH_SIGN_IN_TIMEOUT_MS = 90_000;
7
+ // A probe that exits non-zero is an install so old the subcommand is missing entirely.
8
+ const TOO_OLD_REASONS = new Set([
9
+ 'harness_too_old',
10
+ 'harness_command_unsupported',
11
+ ]);
12
+ // These failures land before any browser could have opened.
13
+ const NO_BROWSER_REASONS = new Set([
14
+ ...TOO_OLD_REASONS,
15
+ 'harness_not_installed',
16
+ 'command_not_found',
17
+ ]);
18
+ // Derived: these block `connect` on a browser sign-in when keyless.
19
+ const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
20
+ const native = NATIVE_BY_HARNESS.get(harness);
21
+ return !!native?.loginArgs || !!native?.oauthInAdd;
22
+ }));
23
+ // Derived: these refuse a keyless install, so no sign-in helps.
24
+ const KEY_REQUIRED = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyRequired));
25
+ function headlessGate(base, oauthBudget, fixCommand) {
26
+ const { harness } = base;
27
+ // A CLI-login sign-in is interactive-only — unboundable headless.
28
+ if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
29
+ return {
30
+ result: { ...base, outcome: 'auth_pending', fixCommand },
31
+ headlessAuth: false,
32
+ spentBrowserSlot: false,
33
+ };
34
+ }
35
+ let spentBrowserSlot = false;
36
+ if (BROWSER_SIGN_IN.has(harness)) {
37
+ // One browser sign-in per run; the rest wait for a re-run.
38
+ if (oauthBudget.spent) {
39
+ return {
40
+ result: { ...base, outcome: 'auth_pending', fixCommand },
41
+ headlessAuth: false,
42
+ spentBrowserSlot: false,
43
+ };
44
+ }
45
+ oauthBudget.spent = true;
46
+ spentBrowserSlot = true;
47
+ errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
48
+ `opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
49
+ }
50
+ return { headlessAuth: true, spentBrowserSlot };
51
+ }
52
+ /** Null: proceed. A result: settle with it. */
53
+ async function awaitSignInPrompt(base, prompt, fixCommand) {
54
+ errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[base.harness]}`);
55
+ try {
56
+ await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[base.harness]}... `);
57
+ return null;
58
+ }
59
+ catch (error) {
60
+ // Ctrl+C stops the run; Ctrl+D only skips this harness.
61
+ if (error instanceof ConnectInterruptedError) {
62
+ errLine('Setup interrupted — run the command again to finish.');
63
+ process.exitCode = 130;
64
+ return { ...base, outcome: 'interrupted', fixCommand };
65
+ }
66
+ return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
67
+ }
68
+ }
69
+ export function mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand) {
70
+ if (e instanceof SignInTimeoutError) {
71
+ return {
72
+ ...base,
73
+ outcome: 'auth_pending',
74
+ errorMessage: 'sign-in did not finish in time',
75
+ fixCommand,
76
+ };
77
+ }
78
+ // A pre-browser failure must not burn the run's only browser slot.
79
+ if (spentBrowserSlot &&
80
+ e instanceof ConnectStepError &&
81
+ NO_BROWSER_REASONS.has(e.failureReason)) {
82
+ oauthBudget.spent = false;
83
+ }
84
+ // Detection saw a name on PATH; the probe proved nothing is behind it.
85
+ if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
86
+ return { ...base, detected: false, outcome: 'not_installed' };
87
+ }
88
+ // Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
89
+ if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
90
+ return { ...base, outcome: 'harness_too_old', errorMessage: e.message, fixCommand };
91
+ }
92
+ return {
93
+ ...base,
94
+ outcome: 'failed',
95
+ errorMessage: e instanceof Error ? e.message : String(e),
96
+ fixCommand,
97
+ };
98
+ }
99
+ export async function authGate(base, oauthBudget, fixCommand, ctx) {
100
+ if (!ctx.isTTY && !ctx.keyed) {
101
+ return headlessGate(base, oauthBudget, fixCommand);
102
+ }
103
+ // Key-required harnesses refuse below; offering a browser sign-in would mislead.
104
+ if (ctx.isTTY && !ctx.keyed && ctx.prompt && !KEY_REQUIRED.has(base.harness)) {
105
+ const settled = await awaitSignInPrompt(base, ctx.prompt, fixCommand);
106
+ if (settled)
107
+ return { result: settled, headlessAuth: false, spentBrowserSlot: false };
108
+ }
109
+ return { headlessAuth: false, spentBrowserSlot: false };
110
+ }
@@ -0,0 +1,8 @@
1
+ import type { HarnessRunResult } from './connect-all.js';
2
+ /** A stale config dir is a detection signal with nothing behind it. */
3
+ export declare function actionable(result: HarnessRunResult): boolean;
4
+ /** One line for every harness: seven near-identical paragraphs is what this replaces. */
5
+ export declare function reloadHint(results: HarnessRunResult[]): string | undefined;
6
+ /** Absent and unpicked harnesses are noise once something real happened. */
7
+ export declare function renderSummary(results: HarnessRunResult[], uninstall: boolean): string[];
8
+ export declare function computeExitCode(results: HarnessRunResult[]): number;
@@ -0,0 +1,148 @@
1
+ import { HARNESS_CAPABILITIES } from './connect-clients.js';
2
+ import { finishSetupCommand } from './connect-runtime.js';
3
+ import { HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, RELOAD_ACTION, } from './harness-detect.js';
4
+ // Naming a harness the user does not have is the noise PF-3708 cut.
5
+ const HIDDEN_ONCE_SOMETHING_WORKED = new Set([
6
+ 'deselected',
7
+ 'not_detected',
8
+ 'not_installed',
9
+ ]);
10
+ /** A stale config dir is a detection signal with nothing behind it. */
11
+ export function actionable(result) {
12
+ return result.detected && result.outcome !== 'stale_config';
13
+ }
14
+ function absentSummaryLine(result, uninstall) {
15
+ const name = DISPLAY_NAMES[result.harness];
16
+ const next = uninstall
17
+ ? 'nothing to remove'
18
+ : `Install ${name}, then run: tinyfish connect ${result.harness}`;
19
+ if (result.outcome === 'not_installed') {
20
+ return `- ${name} — not installed (\`${HARNESS_COMMANDS[result.harness]}\` is on PATH but does not run). ${next}`;
21
+ }
22
+ if (!result.detected) {
23
+ return `- ${name} — not detected (no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, no ${result.configPath}). ${next}`;
24
+ }
25
+ return undefined;
26
+ }
27
+ function skippedSummaryLine(result) {
28
+ const name = DISPLAY_NAMES[result.harness];
29
+ if (result.outcome === 'stale_config') {
30
+ return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
31
+ }
32
+ if (result.outcome === 'not_reached') {
33
+ return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
34
+ }
35
+ if (result.outcome === 'dry_run') {
36
+ return `… ${name} — ${result.fixCommand}`;
37
+ }
38
+ if (result.outcome === 'no_tty_auth_skip') {
39
+ return `… ${name} — skipped, no stored credentials and no terminal to sign in. Fix: ${result.fixCommand}`;
40
+ }
41
+ if (result.outcome === 'auth_pending') {
42
+ return result.errorMessage
43
+ ? `… ${name} — ${result.errorMessage}. Finish it in a terminal: ${result.fixCommand}`
44
+ : `… ${name} — needs a browser sign-in. Run in a terminal: ${result.fixCommand}`;
45
+ }
46
+ if (result.outcome === 'interrupted') {
47
+ return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
48
+ }
49
+ if (result.outcome === 'harness_too_old') {
50
+ return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
51
+ }
52
+ if (result.outcome === 'failed') {
53
+ return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
54
+ }
55
+ return undefined;
56
+ }
57
+ function uninstallSummaryLine(result) {
58
+ const name = DISPLAY_NAMES[result.harness];
59
+ if (result.outcome === 'uninstall_pointer') {
60
+ // errorMessage set = a removal was attempted or impossible; absent = no removal command exists.
61
+ return result.errorMessage
62
+ ? `… ${name} — ${result.errorMessage} ${result.fixCommand}`
63
+ : `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
64
+ }
65
+ if (result.outcome === 'uninstall_noop') {
66
+ return `… ${name} — no tinyfish entry found, nothing removed`;
67
+ }
68
+ if (result.outcome === 'uninstalled') {
69
+ if (!result.errorMessage)
70
+ return `✓ ${name} — removed`;
71
+ // The removal itself worked; the note is advice, not a failed removal.
72
+ if (!result.removalFailed)
73
+ return `⚠ ${name} — removed, but ${result.errorMessage}`;
74
+ return `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`;
75
+ }
76
+ return undefined;
77
+ }
78
+ function installedSummaryLine(result) {
79
+ const name = DISPLAY_NAMES[result.harness];
80
+ // The summary is what users read; stderr hints scroll away.
81
+ if (result.postInstallFailed) {
82
+ return (`⚠ ${name} — installed, but a finishing step failed. ` +
83
+ `Fix: ${finishSetupCommand(result.harness)}`);
84
+ }
85
+ // A keyed install has no sign-in left, so the deferred-auth warning would contradict it.
86
+ const authDeferred = HARNESS_CAPABILITIES[result.harness].authDeferredAtInstall && !result.keyAuthed;
87
+ const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : 'not verified';
88
+ if (authDeferred || result.verifyOk === false) {
89
+ const reason = authDeferred
90
+ ? 'one step left: sign in when your agent first runs'
91
+ : `verify warning (${verifyLabel}): ${result.verifyReason ?? 'unknown'}`;
92
+ return `⚠ ${name} — installed, ${reason}. Fix: run \`/tinyfish:doctor\` in ${name} once it's open.`;
93
+ }
94
+ return `✓ ${name} — installed, ${verifyLabel}`;
95
+ }
96
+ function summaryLine(result, uninstall) {
97
+ return (absentSummaryLine(result, uninstall) ??
98
+ skippedSummaryLine(result) ??
99
+ uninstallSummaryLine(result) ??
100
+ installedSummaryLine(result));
101
+ }
102
+ /** One line for every harness: seven near-identical paragraphs is what this replaces. */
103
+ export function reloadHint(results) {
104
+ const byAction = new Map();
105
+ for (const result of results) {
106
+ if (!result.installed)
107
+ continue;
108
+ const action = RELOAD_ACTION[result.harness];
109
+ byAction.set(action, [...(byAction.get(action) ?? []), DISPLAY_NAMES[result.harness]]);
110
+ }
111
+ if (byAction.size === 0)
112
+ return undefined;
113
+ const parts = [...byAction].map(([action, names]) => `${names.join(', ')} — ${action}`);
114
+ return `TinyFish tools load when a harness starts. If one is already open: ${parts.join('; ')}.`;
115
+ }
116
+ /** Absent and unpicked harnesses are noise once something real happened. */
117
+ export function renderSummary(results, uninstall) {
118
+ if (!results.some(actionable))
119
+ return results.map((r) => summaryLine(r, uninstall));
120
+ // Ids, not display names: they double as the `connect` argument.
121
+ const skipped = results.filter((r) => r.outcome === 'deselected').map((r) => r.harness);
122
+ const lines = results
123
+ .filter((r) => !HIDDEN_ONCE_SOMETHING_WORKED.has(r.outcome))
124
+ .map((r) => summaryLine(r, uninstall));
125
+ if (skipped.length === 0)
126
+ return lines;
127
+ lines.push('', `Skipped (not selected): ${skipped.join(', ')}`);
128
+ return lines;
129
+ }
130
+ export function computeExitCode(results) {
131
+ // Flag-independent: uninstall interrupts have no other 130 site.
132
+ if (results.some((r) => r.outcome === 'interrupted'))
133
+ return 130;
134
+ // A removal that could not run is a failure, not a silent success.
135
+ if (results.some((r) => r.removalFailed))
136
+ return 1;
137
+ const detected = results.filter(actionable);
138
+ if (detected.length === 0)
139
+ return 1;
140
+ // Only real install attempts count toward the success/failure denominator.
141
+ const attempted = detected.filter((r) => r.outcome === 'installed' ||
142
+ r.outcome === 'failed' ||
143
+ r.outcome === 'harness_too_old' ||
144
+ r.outcome === 'interrupted');
145
+ if (attempted.length === 0)
146
+ return 0;
147
+ return attempted.some((r) => r.outcome === 'installed') ? 0 : 1;
148
+ }
@@ -0,0 +1,5 @@
1
+ import { type Harness } from './harness-detect.js';
2
+ import type { HarnessRunResult } from './connect-all.js';
3
+ export declare function planText(harness: Harness, mcpUrl: string, apiKey?: string): string;
4
+ export declare function uninstallPlanText(harness: Harness): string;
5
+ export declare function uninstallHarness(harness: Harness, configPath: string, verbose: boolean, binaryOnPath: boolean): HarnessRunResult;
@@ -0,0 +1,188 @@
1
+ import { clearConnectContext, connectContextState } from './auth.js';
2
+ import { NATIVE_BY_HARNESS, openclawSkillUninstall } from './connect-clients.js';
3
+ import { ConnectInterruptedError, spawnRemoval } from './connect-runtime.js';
4
+ import { removeCursorMcpServer, planCursorWrite } from './cursor-config.js';
5
+ import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
6
+ import { HARNESS_DISPLAY_NAMES as DISPLAY_NAMES } from './harness-detect.js';
7
+ import { errLine } from './output.js';
8
+ // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
9
+ const OPENCODE_UNINSTALL_POINTER = 'remove the tinyfish entry from ~/.config/opencode/opencode.json';
10
+ export function planText(harness, mcpUrl, apiKey) {
11
+ if (harness === 'cursor')
12
+ return planCursorWrite(mcpUrl, apiKey);
13
+ // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
14
+ if (harness === 'hermes' && apiKey) {
15
+ return `would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ${HERMES_KEY_VAR} to Hermes' .env)`;
16
+ }
17
+ return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
18
+ }
19
+ export function uninstallPlanText(harness) {
20
+ if (harness === 'cursor') {
21
+ return 'would remove the tinyfish entry from ~/.cursor/mcp.json';
22
+ }
23
+ if (!uninstallRuns(harness)) {
24
+ return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
25
+ }
26
+ // Hermes alone leaves a seeded key behind; `mcp remove` never clears it.
27
+ const envNote = harness === 'hermes' ? ` and clear ${HERMES_KEY_VAR} from Hermes' .env` : '';
28
+ return `would run \`${uninstallPointer(harness)}\`${envNote}`;
29
+ }
30
+ /** Undefined: no harness-CLI removal exists (opencode). */
31
+ function uninstallRuns(harness) {
32
+ if (harness === 'openclaw') {
33
+ const removal = openclawSkillUninstall();
34
+ return { command: removal.command, runs: [removal.args], timeoutMs: removal.timeoutMs };
35
+ }
36
+ const native = NATIVE_BY_HARNESS.get(harness);
37
+ if (!native || native.removals.length === 0)
38
+ return undefined;
39
+ return { command: native.command, runs: native.removals.map((r) => r.args) };
40
+ }
41
+ /** Derived from the same args the spawn runs, so the advice cannot drift. */
42
+ function uninstallPointer(harness) {
43
+ const removal = uninstallRuns(harness);
44
+ if (!removal)
45
+ return OPENCODE_UNINSTALL_POINTER;
46
+ // `;` runs every removal but breaks cmd.exe; `&&` is the reverse — see
47
+ // internal-docs/install-command-shell-quirks.md (PS 5.1 stays a known gap).
48
+ const joiner = process.platform === 'win32' ? ' && ' : ' ; ';
49
+ return removal.runs.map((args) => `${removal.command} ${args.join(' ')}`).join(joiner);
50
+ }
51
+ /** `mcp remove` leaves this behind; a stale value gets silently reused. */
52
+ function clearHermesEnvKey() {
53
+ const home = resolveHermesHome();
54
+ const retry = 're-run `tinyfish connect --all --uninstall`';
55
+ if (!home) {
56
+ return (`could not locate Hermes' home, so ${HERMES_KEY_VAR} may still be set in its .env. ` +
57
+ `A stale key is reused by the next install: delete that line, or ${retry}.`);
58
+ }
59
+ try {
60
+ removeHermesKey(home);
61
+ return undefined;
62
+ }
63
+ catch (e) {
64
+ const detail = e instanceof Error ? e.message : String(e);
65
+ return (`could not clear ${HERMES_KEY_VAR} from ${hermesEnvPath(home)} (${detail}). ` +
66
+ `A stale key is reused by the next install: delete that line, or ${retry}.`);
67
+ }
68
+ }
69
+ /** Kept on purpose: the entry survives and still interpolates this variable. */
70
+ function hermesKeyKeptAdvisory() {
71
+ return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
72
+ 'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
73
+ }
74
+ function runRemovalCommands(detection, removal, verbose) {
75
+ const { harness } = detection;
76
+ let removed = false;
77
+ try {
78
+ for (const args of removal.runs) {
79
+ const result = spawnRemoval(removal.command, args, removal.timeoutMs);
80
+ if (result.status !== 0 && verbose) {
81
+ const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
82
+ errLine(`${removal.command} ${args.join(' ')}: ${details}`);
83
+ }
84
+ // Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
85
+ if (result.error) {
86
+ return {
87
+ ...detection,
88
+ outcome: 'uninstalled',
89
+ removalFailed: true,
90
+ errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
91
+ fixCommand: uninstallPointer(harness),
92
+ };
93
+ }
94
+ // Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
95
+ if (result.status === 0)
96
+ removed = true;
97
+ }
98
+ }
99
+ catch (e) {
100
+ if (e instanceof ConnectInterruptedError) {
101
+ return {
102
+ ...detection,
103
+ outcome: 'interrupted',
104
+ fixCommand: 'tinyfish connect --all --uninstall',
105
+ };
106
+ }
107
+ throw e;
108
+ }
109
+ return { kind: 'continue', removed };
110
+ }
111
+ function uninstallViaCommands(detection, verbose, binaryOnPath) {
112
+ const { harness } = detection;
113
+ const removal = uninstallRuns(harness);
114
+ if (!removal) {
115
+ return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
116
+ }
117
+ // Stale config: the harness binary is gone, so its removal command cannot run.
118
+ // openclaw is exempt — its removal runs through npx, not the harness binary.
119
+ if (harness !== 'openclaw' && !binaryOnPath) {
120
+ return {
121
+ ...detection,
122
+ outcome: 'uninstall_pointer',
123
+ errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
124
+ fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
125
+ };
126
+ }
127
+ // Absent marker = never connected here, so a non-zero removal exit is a no-op.
128
+ // Unreadable config cannot prove that, so it keeps the pointer.
129
+ const connectedBefore = connectContextState(harness) !== 'absent';
130
+ const ran = runRemovalCommands(detection, removal, verbose);
131
+ if (!('kind' in ran))
132
+ return ran;
133
+ if (ran.removed) {
134
+ // Kept marker makes doctor emit a registration-lost repair that undoes this.
135
+ clearConnectContext(harness);
136
+ return { ...detection, outcome: 'uninstalled' };
137
+ }
138
+ if (!connectedBefore)
139
+ return { ...detection, outcome: 'uninstall_noop' };
140
+ return {
141
+ ...detection,
142
+ outcome: 'uninstall_pointer',
143
+ errorMessage: 'could not confirm removal (command exited non-zero; there may have been nothing to remove).',
144
+ fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
145
+ };
146
+ }
147
+ function uninstallCursor(detection) {
148
+ let result;
149
+ try {
150
+ result = removeCursorMcpServer();
151
+ }
152
+ catch (e) {
153
+ result = { status: 'corrupt_skip', error: e instanceof Error ? e.message : String(e) };
154
+ }
155
+ if (result.status === 'corrupt_skip') {
156
+ return {
157
+ ...detection,
158
+ outcome: 'uninstalled',
159
+ removalFailed: true,
160
+ errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
161
+ fixCommand: 'Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall',
162
+ };
163
+ }
164
+ if (result.status === 'unchanged') {
165
+ return { ...detection, outcome: 'uninstall_noop' };
166
+ }
167
+ clearConnectContext('cursor');
168
+ return { ...detection, outcome: 'uninstalled' };
169
+ }
170
+ export function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
171
+ const detection = { harness, detected: true, configPath, installed: false };
172
+ const result = harness === 'cursor'
173
+ ? uninstallCursor(detection)
174
+ : uninstallViaCommands(detection, verbose, binaryOnPath);
175
+ if (harness !== 'hermes')
176
+ return result;
177
+ // Clearing a key the entry still references would break a working install.
178
+ const removed = !result.removalFailed &&
179
+ (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
180
+ const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
181
+ if (!advisory)
182
+ return result;
183
+ // Appended, never replacing: an advisory must not hide the removal's message.
184
+ return {
185
+ ...result,
186
+ errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
187
+ };
188
+ }
@@ -1,3 +1,25 @@
1
+ import { type Harness } from './harness-detect.js';
2
+ import { type VerifyDepth } from './verify.js';
3
+ export type Outcome = 'not_detected' | 'not_installed' | 'stale_config' | 'installed' | 'failed' | 'harness_too_old' | 'interrupted' | 'auth_pending' | 'no_tty_auth_skip' | 'uninstalled' | 'uninstall_noop' | 'uninstall_pointer' | 'dry_run' | 'not_reached' | 'deselected';
4
+ export type HarnessResultBase = {
5
+ harness: Harness;
6
+ detected: boolean;
7
+ configPath: string;
8
+ installed: boolean;
9
+ };
10
+ export interface HarnessRunResult extends HarnessResultBase {
11
+ outcome: Outcome;
12
+ fixCommand?: string;
13
+ errorMessage?: string;
14
+ verifyDepth?: VerifyDepth;
15
+ verifyOk?: boolean;
16
+ verifyReason?: string;
17
+ keyAuthed?: boolean;
18
+ /** MCP works, but a finishing step (CLI/skill/auth) failed. */
19
+ postInstallFailed?: boolean;
20
+ /** Removal itself failed; an advisory note alone must not exit 1. */
21
+ removalFailed?: boolean;
22
+ }
1
23
  export interface ConnectAllOptions {
2
24
  apiKey?: string;
3
25
  mcpUrl: string;
@@ -9,6 +31,8 @@ export interface ConnectAllOptions {
9
31
  pick?: boolean;
10
32
  /** Inherit subprocess stdio instead of replaying it only on failure. */
11
33
  verbose?: boolean;
34
+ /** Setup-page id riding in on --url; joins "copied the command" to this run. */
35
+ attemptId?: string;
12
36
  }
13
37
  export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
14
38
  export declare const FIRST_TASK_PROMPT: string;
@@ -1,51 +1,24 @@
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 { clearConnectContext, connectContextState, loadConfig, validatedApiKey } from './auth.js';
4
+ import { loadConfig, validatedApiKey } from './auth.js';
5
5
  import { installTinyFishCli, } from './cli-install.js';
6
- import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS, openclawSkillUninstall, } from './connect-clients.js';
6
+ import { authGate, mapConnectError, OAUTH_SIGN_IN_TIMEOUT_MS } from './connect-all-auth.js';
7
+ import { actionable, computeExitCode, reloadHint, renderSummary } from './connect-all-summary.js';
8
+ import { planText, uninstallHarness, uninstallPlanText } from './connect-all-uninstall.js';
9
+ import { HARNESS_CAPABILITIES, NATIVE_BY_HARNESS } from './connect-clients.js';
7
10
  import { createStdinPrompt, runCliFallback } from './connect-fallback.js';
8
- import { ConnectInterruptedError, ConnectStepError, SignInTimeoutError, finishSetupCommand, spawnRemoval, } from './connect-runtime.js';
11
+ import { ConnectInterruptedError } from './connect-runtime.js';
9
12
  import { pickHarnesses } from './connect-picker.js';
10
- import { HERMES_KEY_VAR, hermesEnvPath, removeHermesKey, resolveHermesHome } from './hermes-env.js';
11
- import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, RELOAD_ACTION, } from './harness-detect.js';
13
+ import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from './harness-detect.js';
12
14
  import { detectHumanInitiated } from './harness.js';
13
15
  import { emitNotice } from './notice.js';
14
- import { removeCursorMcpServer, planCursorWrite } from './cursor-config.js';
15
16
  import { errLine, outLine, setErrIndent } from './output.js';
16
- import { sendSetupCompleted } from './setup-telemetry.js';
17
+ import { sendSetupCompleted, sendSetupStarted, } from './setup-telemetry.js';
17
18
  import { verifyMcpAuth, verifyMcpHealth } from './verify.js';
18
- // A probe that exits non-zero is an install so old the subcommand is missing entirely.
19
- const TOO_OLD_REASONS = new Set([
20
- 'harness_too_old',
21
- 'harness_command_unsupported',
22
- ]);
23
- // These failures land before any browser could have opened.
24
- const NO_BROWSER_REASONS = new Set([
25
- ...TOO_OLD_REASONS,
26
- 'harness_not_installed',
27
- 'command_not_found',
28
- ]);
29
- // Naming a harness the user does not have is the noise PF-3708 cut.
30
- const HIDDEN_ONCE_SOMETHING_WORKED = new Set([
31
- 'deselected',
32
- 'not_detected',
33
- 'not_installed',
34
- ]);
35
19
  const STEP_INDENT = ' ';
36
- // Must undercut agent tool-call budgets (Claude Code Bash defaults to 120s).
37
- const OAUTH_SIGN_IN_TIMEOUT_MS = 90_000;
38
- // Derived: these block `connect` on a browser sign-in when keyless.
39
- const BROWSER_SIGN_IN = new Set(ALL_HARNESSES.filter((harness) => {
40
- const native = NATIVE_BY_HARNESS.get(harness);
41
- return !!native?.loginArgs || !!native?.oauthInAdd;
42
- }));
43
- // Derived: these refuse a keyless install, so no sign-in helps.
44
- const KEY_REQUIRED = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyRequired));
45
20
  // Derived: a native add carries the key, or the CLI holds it itself.
46
21
  const KEY_AUTH_CAPABLE = new Set(ALL_HARNESSES.filter((harness) => !!NATIVE_BY_HARNESS.get(harness)?.keyAuth || HARNESS_CAPABILITIES[harness].keyHeldByCli));
47
- // OpenCode ships no `mcp remove`, so the only removal is editing the config it wrote.
48
- const OPENCODE_UNINSTALL_POINTER = 'remove the tinyfish entry from ~/.config/opencode/opencode.json';
49
22
  // A new harness fails to compile until wired here.
50
23
  const HARNESS_CONNECTORS = {
51
24
  'claude-code': connectClaudeCode,
@@ -104,274 +77,6 @@ async function verifyHarness(harness, mcpUrl, keyed, apiKey) {
104
77
  return { depth: 'health+auth', ok: false, reason: 'no stored API key' };
105
78
  return verifyMcpAuth(apiKey);
106
79
  }
107
- function planText(harness, mcpUrl, apiKey) {
108
- if (harness === 'cursor')
109
- return planCursorWrite(mcpUrl, apiKey);
110
- // Only the keyed path seeds the .env; a keyless Hermes install writes nothing.
111
- if (harness === 'hermes' && apiKey) {
112
- return `would run \`tinyfish connect hermes\` (harness-owned MCP write; the CLI writes ${HERMES_KEY_VAR} to Hermes' .env)`;
113
- }
114
- return `would run \`tinyfish connect ${harness}\` (harness-owned MCP write; no local file touched by the CLI)`;
115
- }
116
- function uninstallPlanText(harness) {
117
- if (harness === 'cursor') {
118
- return 'would remove the tinyfish entry from ~/.cursor/mcp.json';
119
- }
120
- if (!uninstallRuns(harness)) {
121
- return `would print \`${uninstallPointer(harness)}\` (no removal command exists)`;
122
- }
123
- // Hermes alone leaves a seeded key behind; `mcp remove` never clears it.
124
- const envNote = harness === 'hermes' ? ` and clear ${HERMES_KEY_VAR} from Hermes' .env` : '';
125
- return `would run \`${uninstallPointer(harness)}\`${envNote}`;
126
- }
127
- /** Undefined: no harness-CLI removal exists (opencode). */
128
- function uninstallRuns(harness) {
129
- if (harness === 'openclaw') {
130
- const removal = openclawSkillUninstall();
131
- return { command: removal.command, runs: [removal.args], timeoutMs: removal.timeoutMs };
132
- }
133
- const native = NATIVE_BY_HARNESS.get(harness);
134
- if (!native || native.removals.length === 0)
135
- return undefined;
136
- return { command: native.command, runs: native.removals.map((r) => r.args) };
137
- }
138
- /** Derived from the same args the spawn runs, so the advice cannot drift. */
139
- function uninstallPointer(harness) {
140
- const removal = uninstallRuns(harness);
141
- if (!removal)
142
- return OPENCODE_UNINSTALL_POINTER;
143
- // `;` runs every removal but breaks cmd.exe; `&&` is the reverse — see
144
- // internal-docs/install-command-shell-quirks.md (PS 5.1 stays a known gap).
145
- const joiner = process.platform === 'win32' ? ' && ' : ' ; ';
146
- return removal.runs.map((args) => `${removal.command} ${args.join(' ')}`).join(joiner);
147
- }
148
- /** `mcp remove` leaves this behind; a stale value gets silently reused. */
149
- function clearHermesEnvKey() {
150
- const home = resolveHermesHome();
151
- const retry = 're-run `tinyfish connect --all --uninstall`';
152
- if (!home) {
153
- return (`could not locate Hermes' home, so ${HERMES_KEY_VAR} may still be set in its .env. ` +
154
- `A stale key is reused by the next install: delete that line, or ${retry}.`);
155
- }
156
- try {
157
- removeHermesKey(home);
158
- return undefined;
159
- }
160
- catch (e) {
161
- const detail = e instanceof Error ? e.message : String(e);
162
- return (`could not clear ${HERMES_KEY_VAR} from ${hermesEnvPath(home)} (${detail}). ` +
163
- `A stale key is reused by the next install: delete that line, or ${retry}.`);
164
- }
165
- }
166
- /** Kept on purpose: the entry survives and still interpolates this variable. */
167
- function hermesKeyKeptAdvisory() {
168
- return (`left ${HERMES_KEY_VAR} in Hermes' .env because the entry was not removed. ` +
169
- 'Run the command above, then re-run `tinyfish connect --all --uninstall` to clear it.');
170
- }
171
- function runRemovalCommands(detection, removal, verbose) {
172
- const { harness } = detection;
173
- let removed = false;
174
- try {
175
- for (const args of removal.runs) {
176
- const result = spawnRemoval(removal.command, args, removal.timeoutMs);
177
- if (result.status !== 0 && verbose) {
178
- const details = result.stderr?.trim() || result.error?.message || `exit ${result.status}`;
179
- errLine(`${removal.command} ${args.join(' ')}: ${details}`);
180
- }
181
- // Same binary: a spawn-level failure (ENOENT, EPERM) repeats identically.
182
- if (result.error) {
183
- return {
184
- ...detection,
185
- outcome: 'uninstalled',
186
- removalFailed: true,
187
- errorMessage: `could not run \`${removal.command} ${args.join(' ')}\` (${result.error.message}) — nothing removed.`,
188
- fixCommand: uninstallPointer(harness),
189
- };
190
- }
191
- // Non-zero is ambiguous (nothing registered vs real failure) — the pointer covers both.
192
- if (result.status === 0)
193
- removed = true;
194
- }
195
- }
196
- catch (e) {
197
- if (e instanceof ConnectInterruptedError) {
198
- return {
199
- ...detection,
200
- outcome: 'interrupted',
201
- fixCommand: 'tinyfish connect --all --uninstall',
202
- };
203
- }
204
- throw e;
205
- }
206
- return { kind: 'continue', removed };
207
- }
208
- function uninstallViaCommands(detection, verbose, binaryOnPath) {
209
- const { harness } = detection;
210
- const removal = uninstallRuns(harness);
211
- if (!removal) {
212
- return { ...detection, outcome: 'uninstall_pointer', fixCommand: uninstallPointer(harness) };
213
- }
214
- // Stale config: the harness binary is gone, so its removal command cannot run.
215
- // openclaw is exempt — its removal runs through npx, not the harness binary.
216
- if (harness !== 'openclaw' && !binaryOnPath) {
217
- return {
218
- ...detection,
219
- outcome: 'uninstall_pointer',
220
- errorMessage: `no \`${removal.command}\` binary on PATH — nothing to run.`,
221
- fixCommand: `Reinstall ${DISPLAY_NAMES[harness]} first, then: ${uninstallPointer(harness)}`,
222
- };
223
- }
224
- // Absent marker = never connected here, so a non-zero removal exit is a no-op.
225
- // Unreadable config cannot prove that, so it keeps the pointer.
226
- const connectedBefore = connectContextState(harness) !== 'absent';
227
- const ran = runRemovalCommands(detection, removal, verbose);
228
- if (!('kind' in ran))
229
- return ran;
230
- if (ran.removed) {
231
- // Kept marker makes doctor emit a registration-lost repair that undoes this.
232
- clearConnectContext(harness);
233
- return { ...detection, outcome: 'uninstalled' };
234
- }
235
- if (!connectedBefore)
236
- return { ...detection, outcome: 'uninstall_noop' };
237
- return {
238
- ...detection,
239
- outcome: 'uninstall_pointer',
240
- errorMessage: 'could not confirm removal (command exited non-zero; there may have been nothing to remove).',
241
- fixCommand: `Retry by hand: ${uninstallPointer(harness)}`,
242
- };
243
- }
244
- function uninstallCursor(detection) {
245
- let result;
246
- try {
247
- result = removeCursorMcpServer();
248
- }
249
- catch (e) {
250
- result = { status: 'corrupt_skip', error: e instanceof Error ? e.message : String(e) };
251
- }
252
- if (result.status === 'corrupt_skip') {
253
- return {
254
- ...detection,
255
- outcome: 'uninstalled',
256
- removalFailed: true,
257
- errorMessage: `could not update Cursor's mcp.json (${result.error}) — nothing removed.`,
258
- fixCommand: 'Fix ~/.cursor/mcp.json by hand, then re-run: tinyfish connect --all --uninstall',
259
- };
260
- }
261
- if (result.status === 'unchanged') {
262
- return { ...detection, outcome: 'uninstall_noop' };
263
- }
264
- clearConnectContext('cursor');
265
- return { ...detection, outcome: 'uninstalled' };
266
- }
267
- function removeHarnessRegistration(harness, configPath, verbose, binaryOnPath) {
268
- const detection = { harness, detected: true, configPath, installed: false };
269
- if (harness === 'cursor')
270
- return uninstallCursor(detection);
271
- return uninstallViaCommands(detection, verbose, binaryOnPath);
272
- }
273
- function uninstallHarness(harness, configPath, verbose, binaryOnPath) {
274
- const result = removeHarnessRegistration(harness, configPath, verbose, binaryOnPath);
275
- if (harness !== 'hermes')
276
- return result;
277
- // Clearing a key the entry still references would break a working install.
278
- const removed = !result.removalFailed &&
279
- (result.outcome === 'uninstalled' || result.outcome === 'uninstall_noop');
280
- const advisory = removed ? clearHermesEnvKey() : hermesKeyKeptAdvisory();
281
- if (!advisory)
282
- return result;
283
- // Appended, never replacing: an advisory must not hide the removal's message.
284
- return {
285
- ...result,
286
- errorMessage: result.errorMessage ? `${result.errorMessage} ${advisory}` : advisory,
287
- };
288
- }
289
- function headlessGate(base, oauthBudget, fixCommand) {
290
- const { harness } = base;
291
- // A CLI-login sign-in is interactive-only — unboundable headless.
292
- if (HARNESS_CAPABILITIES[harness].signInViaCliLogin) {
293
- return {
294
- result: { ...base, outcome: 'auth_pending', fixCommand },
295
- headlessAuth: false,
296
- spentBrowserSlot: false,
297
- };
298
- }
299
- let spentBrowserSlot = false;
300
- if (BROWSER_SIGN_IN.has(harness)) {
301
- // One browser sign-in per run; the rest wait for a re-run.
302
- if (oauthBudget.spent) {
303
- return {
304
- result: { ...base, outcome: 'auth_pending', fixCommand },
305
- headlessAuth: false,
306
- spentBrowserSlot: false,
307
- };
308
- }
309
- oauthBudget.spent = true;
310
- spentBrowserSlot = true;
311
- errLine(`${DISPLAY_NAMES[harness]} needs a browser sign-in — finish it when your browser ` +
312
- `opens; waiting up to ${OAUTH_SIGN_IN_TIMEOUT_MS / 1000} seconds.`);
313
- }
314
- return { headlessAuth: true, spentBrowserSlot };
315
- }
316
- /** Null: proceed. A result: settle with it. */
317
- async function awaitSignInPrompt(base, prompt, fixCommand) {
318
- errLine(`step: waiting for browser sign-in to TinyFish for ${DISPLAY_NAMES[base.harness]}`);
319
- try {
320
- await prompt(`Press enter to open the browser and sign in to TinyFish for ${DISPLAY_NAMES[base.harness]}... `);
321
- return null;
322
- }
323
- catch (error) {
324
- // Ctrl+C stops the run; Ctrl+D only skips this harness.
325
- if (error instanceof ConnectInterruptedError) {
326
- errLine('Setup interrupted — run the command again to finish.');
327
- process.exitCode = 130;
328
- return { ...base, outcome: 'interrupted', fixCommand };
329
- }
330
- return { ...base, outcome: 'no_tty_auth_skip', fixCommand };
331
- }
332
- }
333
- function mapConnectError(base, e, oauthBudget, spentBrowserSlot, fixCommand) {
334
- if (e instanceof SignInTimeoutError) {
335
- return {
336
- ...base,
337
- outcome: 'auth_pending',
338
- errorMessage: 'sign-in did not finish in time',
339
- fixCommand,
340
- };
341
- }
342
- // A pre-browser failure must not burn the run's only browser slot.
343
- if (spentBrowserSlot &&
344
- e instanceof ConnectStepError &&
345
- NO_BROWSER_REASONS.has(e.failureReason)) {
346
- oauthBudget.spent = false;
347
- }
348
- // Detection saw a name on PATH; the probe proved nothing is behind it.
349
- if (e instanceof ConnectStepError && e.failureReason === 'harness_not_installed') {
350
- return { ...base, detected: false, outcome: 'not_installed' };
351
- }
352
- // Both reasons carry the same "flag not listed" message; the upgrade nudge is in it.
353
- if (e instanceof ConnectStepError && TOO_OLD_REASONS.has(e.failureReason)) {
354
- return { ...base, outcome: 'harness_too_old', errorMessage: e.message, fixCommand };
355
- }
356
- return {
357
- ...base,
358
- outcome: 'failed',
359
- errorMessage: e instanceof Error ? e.message : String(e),
360
- fixCommand,
361
- };
362
- }
363
- async function authGate(base, oauthBudget, fixCommand, ctx) {
364
- if (!ctx.isTTY && !ctx.keyed) {
365
- return headlessGate(base, oauthBudget, fixCommand);
366
- }
367
- // Key-required harnesses refuse below; offering a browser sign-in would mislead.
368
- if (ctx.isTTY && !ctx.keyed && ctx.prompt && !KEY_REQUIRED.has(base.harness)) {
369
- const settled = await awaitSignInPrompt(base, ctx.prompt, fixCommand);
370
- if (settled)
371
- return { result: settled, headlessAuth: false, spentBrowserSlot: false };
372
- }
373
- return { headlessAuth: false, spentBrowserSlot: false };
374
- }
375
80
  /** The non-connect settlements: undetected, stale, dry-run, uninstall. */
376
81
  function settleWithoutConnect(detection, opts, base) {
377
82
  const { harness, configPath } = detection;
@@ -443,122 +148,6 @@ async function processHarness(detection, opts, prompt, oauthBudget, hoistedCliIn
443
148
  postInstallFailed,
444
149
  };
445
150
  }
446
- function absentSummaryLine(result, uninstall) {
447
- const name = DISPLAY_NAMES[result.harness];
448
- const next = uninstall
449
- ? 'nothing to remove'
450
- : `Install ${name}, then run: tinyfish connect ${result.harness}`;
451
- if (result.outcome === 'not_installed') {
452
- return `- ${name} — not installed (\`${HARNESS_COMMANDS[result.harness]}\` is on PATH but does not run). ${next}`;
453
- }
454
- if (!result.detected) {
455
- return `- ${name} — not detected (no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, no ${result.configPath}). ${next}`;
456
- }
457
- return undefined;
458
- }
459
- function skippedSummaryLine(result) {
460
- const name = DISPLAY_NAMES[result.harness];
461
- if (result.outcome === 'stale_config') {
462
- return `… ${name} — config at ${result.configPath} but no \`${HARNESS_COMMANDS[result.harness]}\` on PATH, skipped. Install ${name}, then run: ${result.fixCommand}`;
463
- }
464
- if (result.outcome === 'not_reached') {
465
- return `… ${name} — not reached, the run stopped early. Fix: ${result.fixCommand}`;
466
- }
467
- if (result.outcome === 'dry_run') {
468
- return `… ${name} — ${result.fixCommand}`;
469
- }
470
- if (result.outcome === 'no_tty_auth_skip') {
471
- return `… ${name} — skipped, no stored credentials and no terminal to sign in. Fix: ${result.fixCommand}`;
472
- }
473
- if (result.outcome === 'auth_pending') {
474
- return result.errorMessage
475
- ? `… ${name} — ${result.errorMessage}. Finish it in a terminal: ${result.fixCommand}`
476
- : `… ${name} — needs a browser sign-in. Run in a terminal: ${result.fixCommand}`;
477
- }
478
- if (result.outcome === 'interrupted') {
479
- return `✗ ${name} — interrupted before finishing. Fix: ${result.fixCommand}`;
480
- }
481
- if (result.outcome === 'harness_too_old') {
482
- return `… ${name} — needs a newer ${name}: ${result.errorMessage} Fix, once upgraded: ${result.fixCommand}`;
483
- }
484
- if (result.outcome === 'failed') {
485
- return `✗ ${name} — install failed: ${result.errorMessage}. Fix: ${result.fixCommand}`;
486
- }
487
- return undefined;
488
- }
489
- function uninstallSummaryLine(result) {
490
- const name = DISPLAY_NAMES[result.harness];
491
- if (result.outcome === 'uninstall_pointer') {
492
- // errorMessage set = a removal was attempted or impossible; absent = no removal command exists.
493
- return result.errorMessage
494
- ? `… ${name} — ${result.errorMessage} ${result.fixCommand}`
495
- : `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
496
- }
497
- if (result.outcome === 'uninstall_noop') {
498
- return `… ${name} — no tinyfish entry found, nothing removed`;
499
- }
500
- if (result.outcome === 'uninstalled') {
501
- if (!result.errorMessage)
502
- return `✓ ${name} — removed`;
503
- // The removal itself worked; the note is advice, not a failed removal.
504
- if (!result.removalFailed)
505
- return `⚠ ${name} — removed, but ${result.errorMessage}`;
506
- return `✗ ${name} — ${result.errorMessage} Fix: ${result.fixCommand}`;
507
- }
508
- return undefined;
509
- }
510
- function installedSummaryLine(result) {
511
- const name = DISPLAY_NAMES[result.harness];
512
- // The summary is what users read; stderr hints scroll away.
513
- if (result.postInstallFailed) {
514
- return (`⚠ ${name} — installed, but a finishing step failed. ` +
515
- `Fix: ${finishSetupCommand(result.harness)}`);
516
- }
517
- // A keyed install has no sign-in left, so the deferred-auth warning would contradict it.
518
- const authDeferred = HARNESS_CAPABILITIES[result.harness].authDeferredAtInstall && !result.keyAuthed;
519
- const verifyLabel = result.verifyDepth ? `verified: ${result.verifyDepth}` : 'not verified';
520
- if (authDeferred || result.verifyOk === false) {
521
- const reason = authDeferred
522
- ? 'one step left: sign in when your agent first runs'
523
- : `verify warning (${verifyLabel}): ${result.verifyReason ?? 'unknown'}`;
524
- return `⚠ ${name} — installed, ${reason}. Fix: run \`/tinyfish:doctor\` in ${name} once it's open.`;
525
- }
526
- return `✓ ${name} — installed, ${verifyLabel}`;
527
- }
528
- function summaryLine(result, uninstall) {
529
- return (absentSummaryLine(result, uninstall) ??
530
- skippedSummaryLine(result) ??
531
- uninstallSummaryLine(result) ??
532
- installedSummaryLine(result));
533
- }
534
- /** One line for every harness: seven near-identical paragraphs is what this replaces. */
535
- function reloadHint(results) {
536
- const byAction = new Map();
537
- for (const result of results) {
538
- if (!result.installed)
539
- continue;
540
- const action = RELOAD_ACTION[result.harness];
541
- byAction.set(action, [...(byAction.get(action) ?? []), DISPLAY_NAMES[result.harness]]);
542
- }
543
- if (byAction.size === 0)
544
- return undefined;
545
- const parts = [...byAction].map(([action, names]) => `${names.join(', ')} — ${action}`);
546
- return `TinyFish tools load when a harness starts. If one is already open: ${parts.join('; ')}.`;
547
- }
548
- /** Absent and unpicked harnesses are noise once something real happened. */
549
- function renderSummary(results, uninstall) {
550
- if (!results.some(actionable))
551
- return results.map((r) => summaryLine(r, uninstall));
552
- // Ids, not display names: they double as the `connect` argument.
553
- const skipped = results.filter((r) => r.outcome === 'deselected').map((r) => r.harness);
554
- const lines = results
555
- .filter((r) => !HIDDEN_ONCE_SOMETHING_WORKED.has(r.outcome))
556
- .map((r) => summaryLine(r, uninstall));
557
- if (skipped.length === 0)
558
- return lines;
559
- lines.push('', `Skipped (not selected): ${skipped.join(', ')}`);
560
- return lines;
561
- }
562
151
  /** The states processHarness settles without work; stepTotal must agree with it. */
563
152
  function noWorkOutcome(d, uninstall) {
564
153
  if (!d.detected)
@@ -573,29 +162,6 @@ function isStaleConfig(detection) {
573
162
  !detection.binaryOnPath &&
574
163
  !HARNESS_CAPABILITIES[detection.harness].cliWritesConfig);
575
164
  }
576
- /** A stale config dir is a detection signal with nothing behind it. */
577
- function actionable(result) {
578
- return result.detected && result.outcome !== 'stale_config';
579
- }
580
- function computeExitCode(results) {
581
- // Flag-independent: uninstall interrupts have no other 130 site.
582
- if (results.some((r) => r.outcome === 'interrupted'))
583
- return 130;
584
- // A removal that could not run is a failure, not a silent success.
585
- if (results.some((r) => r.removalFailed))
586
- return 1;
587
- const detected = results.filter(actionable);
588
- if (detected.length === 0)
589
- return 1;
590
- // Only real install attempts count toward the success/failure denominator.
591
- const attempted = detected.filter((r) => r.outcome === 'installed' ||
592
- r.outcome === 'failed' ||
593
- r.outcome === 'harness_too_old' ||
594
- r.outcome === 'interrupted');
595
- if (attempted.length === 0)
596
- return 0;
597
- return attempted.some((r) => r.outcome === 'installed') ? 0 : 1;
598
- }
599
165
  async function resolvePick(detections, opts, prompt) {
600
166
  let deselected = new Set();
601
167
  let mode = 'all';
@@ -749,6 +315,12 @@ async function finishRun(results, opts, isTTY) {
749
315
  }
750
316
  export async function runConnectAll(opts) {
751
317
  const isTTY = detectHumanInitiated();
318
+ // Ahead of detection and the hoisted install; either can end the process silently.
319
+ // Not on the picker path: an explicit cancel is not a setup.
320
+ if (!opts.uninstall && !opts.dryRun && !opts.pick) {
321
+ // validatedApiKey falls back to env/config; opts.apiKey alone splits the run's two ends.
322
+ await sendSetupStarted(opts.mcpUrl, opts.attemptId, validatedApiKey(opts.apiKey));
323
+ }
752
324
  // Open interface holds stdin raw; hermes' input() then wedges.
753
325
  const prompt = isTTY ? createStdinPrompt() : undefined;
754
326
  const detections = detectInstalledHarnesses();
@@ -806,7 +378,7 @@ export async function runConnectAll(opts) {
806
378
  verify_depth: r.verifyDepth ?? null,
807
379
  verify_ok: r.verifyOk ?? null,
808
380
  }));
809
- await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode);
381
+ await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode, opts.attemptId);
810
382
  // After the summary and before the launch, which is where the user is reading.
811
383
  emitNotice();
812
384
  }
@@ -40,6 +40,8 @@ declare const harnessResultSchema: z.ZodObject<{
40
40
  }>>;
41
41
  verify_ok: z.ZodNullable<z.ZodBoolean>;
42
42
  }, z.core.$strip>;
43
+ /** First act of a `--all` run; everything upstream of it stays invisible. */
44
+ export declare function sendSetupStarted(mcpUrl: string, attemptId?: string, apiKey?: string): Promise<void>;
43
45
  export declare const setupCompletedPayloadSchema: z.ZodObject<{
44
46
  harnesses: z.ZodArray<z.ZodObject<{
45
47
  harness: z.ZodEnum<{
@@ -65,6 +67,7 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
65
67
  pick: "pick";
66
68
  pick_numeric: "pick_numeric";
67
69
  }>>;
70
+ connect_attempt_id: z.ZodOptional<z.ZodUUID>;
68
71
  }, z.core.$strip>;
69
72
  export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
70
73
  export type HarnessResult = z.infer<typeof harnessResultSchema>;
@@ -74,7 +77,7 @@ export type UpgradeOutcome = 'updated' | 'failed' | 'interrupted';
74
77
  export type UpgradeScope = 'both' | 'cli' | 'skill';
75
78
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
76
79
  export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
77
- export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode): Promise<void>;
80
+ export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode, attemptId?: string): Promise<void>;
78
81
  export declare const DOCTOR_HARNESS_SCOPES: readonly ["all", "claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
79
82
  export declare const doctorCompletedPayloadSchema: z.ZodObject<{
80
83
  schema_version: z.ZodInt;
@@ -101,6 +101,25 @@ const harnessResultSchema = z.object({
101
101
  verify_depth: z.enum(['health', 'health+auth']).nullable(),
102
102
  verify_ok: z.boolean().nullable(),
103
103
  });
104
+ /** First act of a `--all` run; everything upstream of it stays invisible. */
105
+ export async function sendSetupStarted(mcpUrl, attemptId, apiKey) {
106
+ if (telemetryDisabled())
107
+ return;
108
+ // Route 400s a bad id and postConnectEvent never retries 4xx, losing the whole event.
109
+ const stamped = attemptId && z.uuid().safeParse(attemptId).success ? attemptId : undefined;
110
+ await postConnectEvent({
111
+ endpoint: new URL('/api/cli/connect-event', mcpUrl).toString(),
112
+ body: JSON.stringify({
113
+ event: 'setup_started',
114
+ cli_version: CLI_VERSION,
115
+ runtime_platform: process.platform,
116
+ ...(stamped ? { connect_attempt_id: stamped } : {}),
117
+ }),
118
+ attempts: 1,
119
+ label: 'setup',
120
+ apiKey,
121
+ });
122
+ }
104
123
  export const setupCompletedPayloadSchema = z.object({
105
124
  harnesses: z
106
125
  .array(harnessResultSchema)
@@ -111,6 +130,7 @@ export const setupCompletedPayloadSchema = z.object({
111
130
  cli_version: z.string(),
112
131
  // Segments picker runs from --all runs; the server field shipped first.
113
132
  mode: z.enum(['all', 'pick', 'pick_numeric']).optional(),
133
+ connect_attempt_id: z.uuid().optional(),
114
134
  });
115
135
  /** `toVersion` is null when the run was interrupted or the installed version could not be read. */
116
136
  export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scope, apiKey) {
@@ -128,7 +148,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scop
128
148
  apiKey,
129
149
  });
130
150
  }
131
- export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
151
+ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode, attemptId) {
132
152
  // Ahead of the parse so an opt-out run prints no telemetry debug.
133
153
  if (telemetryDisabled())
134
154
  return;
@@ -140,6 +160,10 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
140
160
  harnesses,
141
161
  cli_version: CLI_VERSION,
142
162
  ...(mode ? { mode } : {}),
163
+ // Guarded here, not only at the route: a parse throw drops the whole event, not one field.
164
+ ...(attemptId && z.uuid().safeParse(attemptId).success
165
+ ? { connect_attempt_id: attemptId }
166
+ : {}),
143
167
  });
144
168
  endpoint = new URL('/api/cli/connect-event', mcpUrl).toString();
145
169
  body = JSON.stringify({ event: 'setup_completed', ...payload });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/cli",
3
- "version": "0.31.1-next.269",
3
+ "version": "0.31.1-next.272",
4
4
  "description": "TinyFish CLI — run web automations from your terminal",
5
5
  "type": "module",
6
6
  "bin": {