@tiny-fish/cli 0.25.1 → 0.25.2-next.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/commands/connect.js +31 -12
- package/dist/lib/connect-all.d.ts +2 -0
- package/dist/lib/connect-all.js +40 -16
- package/dist/lib/connect-picker.d.ts +16 -0
- package/dist/lib/connect-picker.js +136 -0
- package/dist/lib/connect-runtime.d.ts +2 -2
- package/dist/lib/connect-runtime.js +21 -9
- package/dist/lib/harness-detect.d.ts +1 -0
- package/dist/lib/harness-detect.js +9 -0
- package/dist/lib/setup-telemetry.d.ts +7 -1
- package/dist/lib/setup-telemetry.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,25 @@ npm install -g @tiny-fish/cli
|
|
|
10
10
|
|
|
11
11
|
Requires Node.js 24+.
|
|
12
12
|
|
|
13
|
+
### Connect your agents
|
|
14
|
+
|
|
15
|
+
The recommended way in: run `connect` with no arguments.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y @tiny-fish/cli@latest connect
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It detects the agent configs on your machine (Claude Code, Codex, Cursor, Grok, Hermes,
|
|
22
|
+
OpenClaw, OpenCode) and opens a checklist with everything pre-selected. Press enter to connect
|
|
23
|
+
them all, or use the arrow keys and space to narrow the set first; `q` cancels without touching
|
|
24
|
+
anything. Terminals without raw-key support get a numbered prompt instead.
|
|
25
|
+
|
|
26
|
+
Without a terminal (agents, CI), bare `connect` behaves exactly like `tinyfish connect --all`
|
|
27
|
+
and says so on its first line: every detected harness that can authenticate headlessly (via
|
|
28
|
+
`TINYFISH_API_KEY` or `--api-key`) is connected, the rest are skipped with a fix command.
|
|
29
|
+
`tinyfish connect --all` itself is unchanged and skips the picker; `--dry-run`, `--uninstall`
|
|
30
|
+
and `--skip-launch` still require it.
|
|
31
|
+
|
|
13
32
|
### Connect Claude Code
|
|
14
33
|
|
|
15
34
|
Add TinyFish MCP and the global `use-tinyfish` web skill, authenticate the CLI, and start the
|
package/dist/commands/connect.js
CHANGED
|
@@ -88,9 +88,7 @@ async function connectNativeMcpClient(client, options) {
|
|
|
88
88
|
const addArgs = keyedAdd
|
|
89
89
|
? keyedAdd.spec.addArgs(mcpUrl.toString(), keyedAdd.key)
|
|
90
90
|
: client.addArgs(mcpUrl.toString(), options.mcpUrl);
|
|
91
|
-
const addResult = spawn.sync(client.command, addArgs, {
|
|
92
|
-
stdio: "inherit",
|
|
93
|
-
});
|
|
91
|
+
const addResult = spawn.sync(client.command, addArgs, { stdio: "inherit" });
|
|
94
92
|
if (addResult.error || addResult.status !== 0) {
|
|
95
93
|
throw spawnStepError(`Could not add TinyFish to ${client.displayName}`, addResult);
|
|
96
94
|
}
|
|
@@ -111,18 +109,19 @@ async function connectNativeMcpClient(client, options) {
|
|
|
111
109
|
// A probed client signs in again only when its inline OAuth is known to have been abandoned.
|
|
112
110
|
const retryLogin = client.probeAuthenticated ? addSignedIn === false : true;
|
|
113
111
|
const pendingLogin = !useKeyAuth && retryLogin ? loginArgs : undefined;
|
|
112
|
+
let signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
114
113
|
if (pendingLogin) {
|
|
115
114
|
state.stage = "client_oauth";
|
|
116
115
|
errLine("Signing in to TinyFish...");
|
|
117
|
-
const loginResult = spawn.sync(client.command, pendingLogin, {
|
|
118
|
-
stdio: "inherit",
|
|
119
|
-
});
|
|
116
|
+
const loginResult = spawn.sync(client.command, pendingLogin, { stdio: "inherit" });
|
|
120
117
|
if (loginResult.error || loginResult.status !== 0) {
|
|
121
118
|
throw spawnStepError(`Could not authenticate TinyFish in ${client.displayName}`, loginResult);
|
|
122
119
|
}
|
|
123
|
-
|
|
120
|
+
if (client.probeAuthenticated?.() === false)
|
|
121
|
+
signInDeferred = true;
|
|
122
|
+
else
|
|
123
|
+
telemetry.track("checkpoint", { phase: "oauth_done" });
|
|
124
124
|
}
|
|
125
|
-
const signInDeferred = !useKeyAuth && !addSignedIn && !pendingLogin;
|
|
126
125
|
const authMode = useKeyAuth
|
|
127
126
|
? AuthMode.ApiKey
|
|
128
127
|
: signInDeferred
|
|
@@ -212,7 +211,10 @@ function runCliInstallStep(options, telemetry) {
|
|
|
212
211
|
throw firstTake ? outcome.error : new HoistedFailureReportedError(outcome.error.message);
|
|
213
212
|
}
|
|
214
213
|
if (firstTake) {
|
|
215
|
-
telemetry.track("checkpoint", {
|
|
214
|
+
telemetry.track("checkpoint", {
|
|
215
|
+
phase: "cli_installed",
|
|
216
|
+
stageDurationMs: outcome.durationMs,
|
|
217
|
+
});
|
|
216
218
|
}
|
|
217
219
|
return;
|
|
218
220
|
}
|
|
@@ -231,7 +233,7 @@ function trackPostInstallFailure(displayName, state, telemetry, error) {
|
|
|
231
233
|
if (!(error instanceof HoistedFailureReportedError)) {
|
|
232
234
|
telemetry.track("post_install_failed", {
|
|
233
235
|
failedStage: state.stage,
|
|
234
|
-
|
|
236
|
+
failureReason: error instanceof ConnectStepError ? error.failureReason : "unexpected_error",
|
|
235
237
|
});
|
|
236
238
|
}
|
|
237
239
|
errLine(`The ${displayName} MCP connection succeeded, but a finishing step ` +
|
|
@@ -415,7 +417,7 @@ export function registerConnect(program) {
|
|
|
415
417
|
program
|
|
416
418
|
.command("connect")
|
|
417
419
|
.description("Connect TinyFish to an AI agent")
|
|
418
|
-
.argument("[client]", "Agent client to connect (claude-code, codex, cursor, grok, hermes, openclaw, or opencode)")
|
|
420
|
+
.argument("[client]", "Agent client to connect (claude-code, codex, cursor, grok, hermes, openclaw, or opencode); omit to pick from the agents detected on this machine")
|
|
419
421
|
.option("--all", "Detect and connect every supported harness found on this machine")
|
|
420
422
|
.option("--dry-run", "Print planned writes per harness without touching anything (--all only)")
|
|
421
423
|
.option("--uninstall", "Remove TinyFish entries written by connect --all")
|
|
@@ -474,7 +476,24 @@ export function registerConnect(program) {
|
|
|
474
476
|
throw new Error("--dry-run, --uninstall and --skip-launch are only supported with --all.");
|
|
475
477
|
}
|
|
476
478
|
if (!client) {
|
|
477
|
-
|
|
479
|
+
if (options.launch) {
|
|
480
|
+
throw new Error("--launch is not supported with --all. Connect a single client to launch it.");
|
|
481
|
+
}
|
|
482
|
+
const humanInitiated = detectHumanInitiated();
|
|
483
|
+
if (!humanInitiated) {
|
|
484
|
+
errLine("No terminal detected: connecting every detected harness (same as tinyfish connect --all).");
|
|
485
|
+
}
|
|
486
|
+
const exitCode = await runConnectAll({
|
|
487
|
+
apiKey: options.apiKey,
|
|
488
|
+
mcpUrl,
|
|
489
|
+
dryRun: false,
|
|
490
|
+
uninstall: false,
|
|
491
|
+
pick: humanInitiated,
|
|
492
|
+
});
|
|
493
|
+
// Preserve the 130 runGuarded set on Ctrl+C.
|
|
494
|
+
if (!process.exitCode)
|
|
495
|
+
process.exitCode = exitCode;
|
|
496
|
+
return;
|
|
478
497
|
}
|
|
479
498
|
const connectOptions = {
|
|
480
499
|
apiKey: options.apiKey,
|
|
@@ -5,5 +5,7 @@ export interface ConnectAllOptions {
|
|
|
5
5
|
uninstall: boolean;
|
|
6
6
|
/** Skip the end-of-install agent launch (finale still prints the ask). */
|
|
7
7
|
noLaunch?: boolean;
|
|
8
|
+
/** Bare connect: offer the checkbox picker over the detected harnesses. */
|
|
9
|
+
pick?: boolean;
|
|
8
10
|
}
|
|
9
11
|
export declare function runConnectAll(opts: ConnectAllOptions): Promise<number>;
|
package/dist/lib/connect-all.js
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
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 { validatedApiKey } from "./auth.js";
|
|
4
|
+
import { loadConfig, validatedApiKey } from "./auth.js";
|
|
5
5
|
import { installTinyFishCli, } from "./cli-install.js";
|
|
6
6
|
import { NATIVE_MCP_CLIENTS } from "./connect-clients.js";
|
|
7
7
|
import { ensureCliAuthenticated, TINYFISH_CLI_NOT_FOUND_MESSAGE } from "./connect-auth.js";
|
|
8
8
|
import { commandNotFound, ConnectInterruptedError, ConnectStepError, finishSetupCommand, throwIfInterrupted, } from "./connect-runtime.js";
|
|
9
|
-
import {
|
|
9
|
+
import { pickHarnesses } from "./connect-picker.js";
|
|
10
|
+
import { commandOnPath, detectInstalledHarnesses, HARNESS_COMMANDS, HARNESS_DISPLAY_NAMES as DISPLAY_NAMES, ALL_HARNESSES, } from "./harness-detect.js";
|
|
10
11
|
import { detectHumanInitiated } from "./harness.js";
|
|
11
12
|
import { emitNotice } from "./notice.js";
|
|
12
13
|
import { removeCursorMcpServer, planCursorWrite } from "./cursor-config.js";
|
|
13
14
|
import { errLine, outLine } from "./output.js";
|
|
14
15
|
import { sendSetupCompleted } from "./setup-telemetry.js";
|
|
15
16
|
import { verifyMcpAuth, verifyMcpHealth } from "./verify.js";
|
|
16
|
-
const DISPLAY_NAMES = {
|
|
17
|
-
"claude-code": "Claude Code",
|
|
18
|
-
codex: "Codex",
|
|
19
|
-
cursor: "Cursor",
|
|
20
|
-
grok: "Grok",
|
|
21
|
-
hermes: "Hermes",
|
|
22
|
-
openclaw: "OpenClaw",
|
|
23
|
-
opencode: "OpenCode",
|
|
24
|
-
};
|
|
25
17
|
// `codex mcp add` succeeds unauthenticated; OAuth lands at first tool use.
|
|
26
18
|
// TODO(PF-3580): connect now probes Codex sign-in, so this over-warns a signed-in install.
|
|
27
19
|
const AUTH_DEFERRED_AT_INSTALL = new Set(["codex"]);
|
|
@@ -241,6 +233,9 @@ function summaryLine(result, uninstall) {
|
|
|
241
233
|
: "no stored credentials and no terminal to sign in";
|
|
242
234
|
return `… ${name} — skipped, ${why}. Fix: ${result.fixCommand}`;
|
|
243
235
|
}
|
|
236
|
+
if (result.outcome === "deselected") {
|
|
237
|
+
return `… ${name} — skipped, not selected. Fix: ${result.fixCommand}`;
|
|
238
|
+
}
|
|
244
239
|
if (result.outcome === "uninstall_pointer") {
|
|
245
240
|
return `… ${name} — not removed automatically (config is owned by ${name}). Run: ${result.fixCommand}`;
|
|
246
241
|
}
|
|
@@ -384,9 +379,26 @@ export async function runConnectAll(opts) {
|
|
|
384
379
|
}
|
|
385
380
|
: undefined;
|
|
386
381
|
const detections = detectInstalledHarnesses();
|
|
382
|
+
let deselected = new Set();
|
|
383
|
+
let mode = "all";
|
|
384
|
+
// Before willAttempt/cliNeeded: a cursor-only pick must not hoist the CLI.
|
|
385
|
+
// A stale config dir has no binary to connect, so never offer it as a choice.
|
|
386
|
+
const offerable = detections.filter((d) => d.detected && !isStaleConfig(d));
|
|
387
|
+
if (opts.pick && offerable.length > 0) {
|
|
388
|
+
const connected = new Set(Object.keys(loadConfig().connect ?? {}));
|
|
389
|
+
const pick = await pickHarnesses(offerable, connected, { prompt });
|
|
390
|
+
mode = pick.mode;
|
|
391
|
+
if (pick.selection === "cancel") {
|
|
392
|
+
// Before summary and telemetry: an explicit nothing is not a setup.
|
|
393
|
+
outLine("Nothing installed. Run: tinyfish connect --all or tinyfish connect <client>");
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
const chosen = new Set(pick.selection);
|
|
397
|
+
deselected = new Set(offerable.filter((d) => !chosen.has(d.harness)).map((d) => d.harness));
|
|
398
|
+
}
|
|
387
399
|
const gateKey = validatedApiKey(opts.apiKey);
|
|
388
400
|
// Mirrors processHarness's skip gate: a skipped harness installs nothing today.
|
|
389
|
-
const willAttempt = (harness) => isTTY || (!!gateKey && KEY_AUTH_CAPABLE.has(harness));
|
|
401
|
+
const willAttempt = (harness) => !deselected.has(harness) && (isTTY || (!!gateKey && KEY_AUTH_CAPABLE.has(harness)));
|
|
390
402
|
// Machine-global, so once per invocation; cursor's connect never installs it.
|
|
391
403
|
const cliNeeded = !opts.dryRun &&
|
|
392
404
|
!opts.uninstall &&
|
|
@@ -408,9 +420,21 @@ export async function runConnectAll(opts) {
|
|
|
408
420
|
};
|
|
409
421
|
}
|
|
410
422
|
const results = [];
|
|
411
|
-
|
|
423
|
+
const stepTotal = detections.length - deselected.size;
|
|
424
|
+
let step = 0;
|
|
425
|
+
for (const detection of detections) {
|
|
412
426
|
const { harness } = detection;
|
|
413
|
-
|
|
427
|
+
if (deselected.has(harness)) {
|
|
428
|
+
results.push({
|
|
429
|
+
...detection,
|
|
430
|
+
installed: false,
|
|
431
|
+
outcome: "deselected",
|
|
432
|
+
fixCommand: `tinyfish connect ${harness}`,
|
|
433
|
+
});
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
step += 1;
|
|
437
|
+
errLine(`step ${step} of ${stepTotal} — ${DISPLAY_NAMES[harness]}`);
|
|
414
438
|
let result;
|
|
415
439
|
try {
|
|
416
440
|
result = await processHarness(detection, opts, prompt, takeHoistedCli);
|
|
@@ -432,7 +456,7 @@ export async function runConnectAll(opts) {
|
|
|
432
456
|
if (result.outcome === "interrupted")
|
|
433
457
|
break;
|
|
434
458
|
}
|
|
435
|
-
// The interrupt break truncates `results`; pad so the
|
|
459
|
+
// The interrupt break truncates `results`; pad so the one-row-per-harness schema still parses.
|
|
436
460
|
for (const detection of detections) {
|
|
437
461
|
if (results.some((r) => r.harness === detection.harness))
|
|
438
462
|
continue;
|
|
@@ -483,7 +507,7 @@ export async function runConnectAll(opts) {
|
|
|
483
507
|
verify_depth: r.verifyDepth ?? null,
|
|
484
508
|
verify_ok: r.verifyOk ?? null,
|
|
485
509
|
}));
|
|
486
|
-
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey));
|
|
510
|
+
await sendSetupCompleted(opts.mcpUrl, harnessTelemetry, validatedApiKey(opts.apiKey), mode);
|
|
487
511
|
// After the summary and before the launch, which is where the user is reading.
|
|
488
512
|
emitNotice();
|
|
489
513
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { type Harness, type HarnessDetection } from "./harness-detect.js";
|
|
3
|
+
export type PickSelection = Harness[] | "cancel";
|
|
4
|
+
export interface PickResult {
|
|
5
|
+
selection: PickSelection;
|
|
6
|
+
mode: "pick" | "pick_numeric";
|
|
7
|
+
}
|
|
8
|
+
export type PickerStdin = Readable & {
|
|
9
|
+
setRawMode?: (raw: boolean) => unknown;
|
|
10
|
+
};
|
|
11
|
+
export interface PickerIo {
|
|
12
|
+
stdin?: PickerStdin;
|
|
13
|
+
write?: (text: string) => void;
|
|
14
|
+
prompt?: (question: string) => Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
export declare function pickHarnesses(detections: HarnessDetection[], connectedBefore: ReadonlySet<string>, io?: PickerIo): Promise<PickResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { HARNESS_DISPLAY_NAMES } from "./harness-detect.js";
|
|
3
|
+
const KEY_HELP = " ↑↓ move space toggle one a none/all q cancel";
|
|
4
|
+
function header(count) {
|
|
5
|
+
return `Found ${count} agent config${count === 1 ? "" : "s"} on this machine.\n\n`;
|
|
6
|
+
}
|
|
7
|
+
function connectedNote(item) {
|
|
8
|
+
return item.connectedBefore ? " (previously connected)" : "";
|
|
9
|
+
}
|
|
10
|
+
function enterHint(checkedCount, total) {
|
|
11
|
+
if (checkedCount === 0)
|
|
12
|
+
return "press enter to cancel";
|
|
13
|
+
if (checkedCount === total)
|
|
14
|
+
return `press enter to connect all ${total}`;
|
|
15
|
+
return `press enter to connect ${checkedCount} of ${total}`;
|
|
16
|
+
}
|
|
17
|
+
function frame(items, checked, cursor) {
|
|
18
|
+
const rows = items.map((item, i) => ` ${i === cursor ? ">" : " "} [${checked[i] ? "x" : " "}] ${item.name}${connectedNote(item)}`);
|
|
19
|
+
const count = checked.filter(Boolean).length;
|
|
20
|
+
return [...rows, "", ` ${enterHint(count, items.length)}`, KEY_HELP, ""].join("\n");
|
|
21
|
+
}
|
|
22
|
+
async function pickWithKeys(items, stdin, write) {
|
|
23
|
+
const checked = items.map(() => true);
|
|
24
|
+
let cursor = 0;
|
|
25
|
+
let painted = 0;
|
|
26
|
+
const paint = () => {
|
|
27
|
+
const text = frame(items, checked, cursor);
|
|
28
|
+
write((painted > 0 ? `\u001b[${painted}A\u001b[0J` : "") + text);
|
|
29
|
+
painted = items.length + 3;
|
|
30
|
+
};
|
|
31
|
+
readline.emitKeypressEvents(stdin);
|
|
32
|
+
stdin.setRawMode(true);
|
|
33
|
+
stdin.resume();
|
|
34
|
+
let onKeypress;
|
|
35
|
+
try {
|
|
36
|
+
write(header(items.length));
|
|
37
|
+
paint();
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
onKeypress = (_chunk, key = {}) => {
|
|
40
|
+
try {
|
|
41
|
+
const name = key.name ?? "";
|
|
42
|
+
// Raw mode eats terminal SIGINT, so Ctrl+C arrives only here.
|
|
43
|
+
if ((key.ctrl && (name === "c" || name === "d")) || name === "q" || name === "escape") {
|
|
44
|
+
resolve("cancel");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (name === "return" || name === "enter") {
|
|
48
|
+
const picked = items.filter((_, i) => checked[i]).map((item) => item.harness);
|
|
49
|
+
resolve(picked.length > 0 ? picked : "cancel");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (name === "up" || name === "k")
|
|
53
|
+
cursor = (cursor + items.length - 1) % items.length;
|
|
54
|
+
else if (name === "down" || name === "j")
|
|
55
|
+
cursor = (cursor + 1) % items.length;
|
|
56
|
+
else if (name === "space")
|
|
57
|
+
checked[cursor] = !checked[cursor];
|
|
58
|
+
else if (name === "a")
|
|
59
|
+
checked.fill(!checked.every(Boolean));
|
|
60
|
+
paint();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
stdin.on("keypress", onKeypress);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
// Restore before any child spawn can inherit raw stdin (PF-3635).
|
|
71
|
+
if (onKeypress)
|
|
72
|
+
stdin.removeListener("keypress", onKeypress);
|
|
73
|
+
stdin.setRawMode(false);
|
|
74
|
+
stdin.pause();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function numberedRows(items) {
|
|
78
|
+
return (items.map((item, i) => ` [${i + 1}] ${item.name}${connectedNote(item)}`).join("\n") + "\n");
|
|
79
|
+
}
|
|
80
|
+
function parseSubset(answer, items) {
|
|
81
|
+
const tokens = answer.split(/[\s,]+/).filter(Boolean);
|
|
82
|
+
if (tokens.length === 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const picked = new Set();
|
|
85
|
+
for (const token of tokens) {
|
|
86
|
+
if (!/^\d+$/.test(token))
|
|
87
|
+
return undefined;
|
|
88
|
+
const index = Number.parseInt(token, 10);
|
|
89
|
+
if (index < 1 || index > items.length)
|
|
90
|
+
return undefined;
|
|
91
|
+
picked.add(index - 1);
|
|
92
|
+
}
|
|
93
|
+
return items.filter((_, i) => picked.has(i)).map((item) => item.harness);
|
|
94
|
+
}
|
|
95
|
+
async function pickWithNumbers(items, prompt, write) {
|
|
96
|
+
write(header(items.length) + numberedRows(items));
|
|
97
|
+
const question = `Press enter to connect all ${items.length}, numbers for a subset (e.g. 1,3), or n to cancel: `;
|
|
98
|
+
// One re-prompt on garbage, then cancel; never a loop.
|
|
99
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
100
|
+
let answer;
|
|
101
|
+
try {
|
|
102
|
+
answer = (await prompt(question)).trim();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Closed stdin: every later question would throw too.
|
|
106
|
+
return "cancel";
|
|
107
|
+
}
|
|
108
|
+
if (answer === "")
|
|
109
|
+
return items.map((item) => item.harness);
|
|
110
|
+
if (/^[nq]$/i.test(answer))
|
|
111
|
+
return "cancel";
|
|
112
|
+
const subset = parseSubset(answer, items);
|
|
113
|
+
if (subset)
|
|
114
|
+
return subset;
|
|
115
|
+
}
|
|
116
|
+
return "cancel";
|
|
117
|
+
}
|
|
118
|
+
export async function pickHarnesses(detections, connectedBefore, io = {}) {
|
|
119
|
+
const items = detections
|
|
120
|
+
.filter((detection) => detection.detected)
|
|
121
|
+
.map((detection) => ({
|
|
122
|
+
harness: detection.harness,
|
|
123
|
+
name: HARNESS_DISPLAY_NAMES[detection.harness],
|
|
124
|
+
connectedBefore: connectedBefore.has(detection.harness),
|
|
125
|
+
}));
|
|
126
|
+
const stdin = io.stdin ?? process.stdin;
|
|
127
|
+
const write = io.write ?? ((text) => void process.stderr.write(text));
|
|
128
|
+
if (typeof stdin.setRawMode !== "function") {
|
|
129
|
+
// No prompt means nothing to ask; keep --all semantics.
|
|
130
|
+
const selection = io.prompt
|
|
131
|
+
? await pickWithNumbers(items, io.prompt, write)
|
|
132
|
+
: items.map((item) => item.harness);
|
|
133
|
+
return { selection, mode: "pick_numeric" };
|
|
134
|
+
}
|
|
135
|
+
return { selection: await pickWithKeys(items, stdin, write), mode: "pick" };
|
|
136
|
+
}
|
|
@@ -3,11 +3,11 @@ export declare const NON_INTERACTIVE_TIMEOUT_MS = 10000;
|
|
|
3
3
|
export type AgentClient = "claude-code" | "codex" | "cursor" | "grok" | "hermes" | "openclaw" | "opencode";
|
|
4
4
|
type ConnectStage = "started" | "checkpoint" | "completed" | "failed" | "aborted" | "post_install_failed";
|
|
5
5
|
type ConnectFailureStage = "prerequisite_check" | "registration_cleanup" | "registration" | "registration_or_authentication" | "client_oauth" | "cli_install" | "skill_install" | "authentication" | "walkthrough_launch";
|
|
6
|
-
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config"];
|
|
6
|
+
export declare const CONNECT_FAILURE_REASONS: readonly ["harness_not_installed", "harness_command_unsupported", "harness_too_old", "command_not_found", "timeout", "spawn_error", "nonzero_exit", "invalid_config", "user_abandoned", "process_terminated", "unexpected_error"];
|
|
7
7
|
type ConnectFailureReason = (typeof CONNECT_FAILURE_REASONS)[number];
|
|
8
8
|
export type ConnectAuthMode = Exclude<AuthMode, AuthMode.Unknown> | "deferred";
|
|
9
9
|
type ConnectCheckpoint = "prerequisite_ok" | "registered" | "oauth_done" | "cli_installed" | "skill_installed" | "authenticated";
|
|
10
|
-
/** Ctrl+C
|
|
10
|
+
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
11
11
|
export declare class ConnectInterruptedError extends Error {
|
|
12
12
|
}
|
|
13
13
|
/** A classified step failure; runGuarded forwards the reason. */
|
|
@@ -17,8 +17,11 @@ export const CONNECT_FAILURE_REASONS = [
|
|
|
17
17
|
"spawn_error",
|
|
18
18
|
"nonzero_exit",
|
|
19
19
|
"invalid_config",
|
|
20
|
+
"user_abandoned",
|
|
21
|
+
"process_terminated",
|
|
22
|
+
"unexpected_error",
|
|
20
23
|
];
|
|
21
|
-
/** Ctrl+C
|
|
24
|
+
/** Ctrl+C killed a setup child — abandonment, not error. */
|
|
22
25
|
export class ConnectInterruptedError extends Error {
|
|
23
26
|
}
|
|
24
27
|
/** A classified step failure; runGuarded forwards the reason. */
|
|
@@ -41,7 +44,7 @@ export function throwIfInterrupted(result) {
|
|
|
41
44
|
const code = result.error?.code;
|
|
42
45
|
if (code && NOT_INTERRUPTIONS.has(code))
|
|
43
46
|
return;
|
|
44
|
-
if (result.signal === "SIGINT"
|
|
47
|
+
if (result.signal === "SIGINT") {
|
|
45
48
|
throw new ConnectInterruptedError("Setup interrupted");
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -75,9 +78,12 @@ export function settle(state, telemetry, stage, detail) {
|
|
|
75
78
|
// Raw-mode children still surface via throwIfInterrupted; cooked-mode stages
|
|
76
79
|
// (npm/npx/OAuth waits) land here.
|
|
77
80
|
function installConnectSignalGuard(state, telemetry) {
|
|
78
|
-
return installSignalGuard(async () => {
|
|
81
|
+
return installSignalGuard(async (signal) => {
|
|
79
82
|
if (!state.settled) {
|
|
80
|
-
settle(state, telemetry, "aborted", {
|
|
83
|
+
settle(state, telemetry, "aborted", {
|
|
84
|
+
failedStage: state.stage,
|
|
85
|
+
failureReason: signal === "SIGINT" ? "user_abandoned" : "process_terminated",
|
|
86
|
+
});
|
|
81
87
|
errLine("Setup interrupted — run the command again to finish.");
|
|
82
88
|
}
|
|
83
89
|
// Settled spans the whole post-install phase; the `completed` POST may still be in flight.
|
|
@@ -92,16 +98,18 @@ export async function runGuarded(state, telemetry, body) {
|
|
|
92
98
|
}
|
|
93
99
|
catch (error) {
|
|
94
100
|
if (error instanceof ConnectInterruptedError) {
|
|
95
|
-
settle(state, telemetry, "aborted", {
|
|
101
|
+
settle(state, telemetry, "aborted", {
|
|
102
|
+
failedStage: state.stage,
|
|
103
|
+
failureReason: "user_abandoned",
|
|
104
|
+
});
|
|
96
105
|
errLine("Setup interrupted — run the command again to finish.");
|
|
97
106
|
process.exitCode = 130;
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
109
|
settle(state, telemetry, "failed", {
|
|
101
110
|
failedStage: state.stage,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
: {}),
|
|
111
|
+
failureReason: error instanceof ConnectStepError ? error.failureReason : "unexpected_error",
|
|
112
|
+
...(error instanceof ConnectStepError ? { harnessVersion: error.harnessVersion } : {}),
|
|
105
113
|
});
|
|
106
114
|
throw error;
|
|
107
115
|
}
|
|
@@ -146,7 +154,11 @@ export function requireCommandSupport(client) {
|
|
|
146
154
|
harnessVersion: probeHarnessVersion(client.command),
|
|
147
155
|
});
|
|
148
156
|
}
|
|
149
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
optionalSupported,
|
|
159
|
+
keyAuthSupported,
|
|
160
|
+
harnessVersion: probeHarnessVersion(client.command),
|
|
161
|
+
};
|
|
150
162
|
}
|
|
151
163
|
/** Best-effort; the server rejects non-printable characters and >32 chars. */
|
|
152
164
|
function probeHarnessVersion(command) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const ALL_HARNESSES: readonly ["claude-code", "codex", "cursor", "grok", "hermes", "openclaw", "opencode"];
|
|
2
2
|
export type Harness = (typeof ALL_HARNESSES)[number];
|
|
3
|
+
export declare const HARNESS_DISPLAY_NAMES: Record<Harness, string>;
|
|
3
4
|
export declare enum Registered {
|
|
4
5
|
Yes = "yes",
|
|
5
6
|
No = "no",
|
|
@@ -11,6 +11,15 @@ export const ALL_HARNESSES = [
|
|
|
11
11
|
"openclaw",
|
|
12
12
|
"opencode",
|
|
13
13
|
];
|
|
14
|
+
export const HARNESS_DISPLAY_NAMES = {
|
|
15
|
+
"claude-code": "Claude Code",
|
|
16
|
+
codex: "Codex",
|
|
17
|
+
cursor: "Cursor",
|
|
18
|
+
grok: "Grok",
|
|
19
|
+
hermes: "Hermes",
|
|
20
|
+
openclaw: "OpenClaw",
|
|
21
|
+
opencode: "OpenCode",
|
|
22
|
+
};
|
|
14
23
|
// Values are the wire contract; the report schema derives from these.
|
|
15
24
|
export var Registered;
|
|
16
25
|
(function (Registered) {
|
|
@@ -60,13 +60,19 @@ export declare const setupCompletedPayloadSchema: z.ZodObject<{
|
|
|
60
60
|
verify_ok: z.ZodNullable<z.ZodBoolean>;
|
|
61
61
|
}, z.core.$strip>>;
|
|
62
62
|
cli_version: z.ZodString;
|
|
63
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
64
|
+
all: "all";
|
|
65
|
+
pick: "pick";
|
|
66
|
+
pick_numeric: "pick_numeric";
|
|
67
|
+
}>>;
|
|
63
68
|
}, z.core.$strip>;
|
|
64
69
|
export type SetupCompletedPayload = z.infer<typeof setupCompletedPayloadSchema>;
|
|
65
70
|
export type HarnessResult = z.infer<typeof harnessResultSchema>;
|
|
71
|
+
export type SetupMode = NonNullable<SetupCompletedPayload["mode"]>;
|
|
66
72
|
export type UpgradeOutcome = "updated" | "failed" | "interrupted";
|
|
67
73
|
/** Step selection; "updated" means every selected step ran. */
|
|
68
74
|
export type UpgradeScope = "both" | "cli" | "skill";
|
|
69
75
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
70
76
|
export declare function sendUpgradeCompleted(fromVersion: string, toVersion: string | null, outcome: UpgradeOutcome, scope: UpgradeScope, apiKey?: string): Promise<void>;
|
|
71
|
-
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string): Promise<void>;
|
|
77
|
+
export declare function sendSetupCompleted(mcpUrl: string, harnesses: HarnessResult[], apiKey?: string, mode?: SetupMode): Promise<void>;
|
|
72
78
|
export {};
|
|
@@ -109,6 +109,8 @@ export const setupCompletedPayloadSchema = z.object({
|
|
|
109
109
|
message: "expected one result per harness",
|
|
110
110
|
}),
|
|
111
111
|
cli_version: z.string(),
|
|
112
|
+
// Segments picker runs from --all runs; the server field shipped first.
|
|
113
|
+
mode: z.enum(["all", "pick", "pick_numeric"]).optional(),
|
|
112
114
|
});
|
|
113
115
|
/** `toVersion` is null when the run was interrupted or the installed version could not be read. */
|
|
114
116
|
export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scope, apiKey) {
|
|
@@ -126,7 +128,7 @@ export async function sendUpgradeCompleted(fromVersion, toVersion, outcome, scop
|
|
|
126
128
|
apiKey,
|
|
127
129
|
});
|
|
128
130
|
}
|
|
129
|
-
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
|
|
131
|
+
export async function sendSetupCompleted(mcpUrl, harnesses, apiKey, mode) {
|
|
130
132
|
// Ahead of the parse so an opt-out run prints no telemetry debug.
|
|
131
133
|
if (telemetryDisabled())
|
|
132
134
|
return;
|
|
@@ -134,7 +136,11 @@ export async function sendSetupCompleted(mcpUrl, harnesses, apiKey) {
|
|
|
134
136
|
let body;
|
|
135
137
|
try {
|
|
136
138
|
// Parse inside try — schema violation must not crash finished setup.
|
|
137
|
-
const payload = setupCompletedPayloadSchema.parse({
|
|
139
|
+
const payload = setupCompletedPayloadSchema.parse({
|
|
140
|
+
harnesses,
|
|
141
|
+
cli_version: CLI_VERSION,
|
|
142
|
+
...(mode ? { mode } : {}),
|
|
143
|
+
});
|
|
138
144
|
endpoint = new URL("/api/cli/connect-event", mcpUrl).toString();
|
|
139
145
|
body = JSON.stringify({ event: "setup_completed", ...payload });
|
|
140
146
|
}
|