@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.
- package/dist/commands/connect.js +6 -4
- package/dist/lib/connect-all-auth.d.ts +19 -0
- package/dist/lib/connect-all-auth.js +110 -0
- package/dist/lib/connect-all-summary.d.ts +8 -0
- package/dist/lib/connect-all-summary.js +148 -0
- package/dist/lib/connect-all-uninstall.d.ts +5 -0
- package/dist/lib/connect-all-uninstall.js +188 -0
- package/dist/lib/connect-all.d.ts +24 -0
- package/dist/lib/connect-all.js +15 -443
- package/dist/lib/setup-telemetry.d.ts +4 -1
- package/dist/lib/setup-telemetry.js +25 -1
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -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;
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -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 {
|
|
4
|
+
import { loadConfig, validatedApiKey } from './auth.js';
|
|
5
5
|
import { installTinyFishCli, } from './cli-install.js';
|
|
6
|
-
import {
|
|
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
|
|
11
|
+
import { ConnectInterruptedError } from './connect-runtime.js';
|
|
9
12
|
import { pickHarnesses } from './connect-picker.js';
|
|
10
|
-
import {
|
|
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 });
|