premanmcp 0.10.5 → 0.10.7

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/bin/connect.js CHANGED
@@ -1,1524 +1,70 @@
1
1
  /**
2
2
  * `preman connect` — pick a coding agent and get connected, in one command.
3
3
  *
4
- * Replaces the hand-edited MCP config: the user picks Cursor / Claude Code /
5
- * Codex from a list, PreMan logs them in if needed, writes that agent's config
6
- * itself, and binds the connection to their account with a pair code.
4
+ * This file is the command itself: preflight, the order the steps run in, and
5
+ * the ladder that gets the first PreMan call to happen. The parts it orchestrates
6
+ * live next door, because each one grew its own reasons to be careful and reading
7
+ * them interleaved stopped being possible at around a thousand lines:
7
8
  *
8
- * Optionally captures a cloud-dispatch credential in the same pass so PreMan can
9
- * start agent runs for them later (SCRUM-124). That step is always skippable and
10
- * never fails the connect.
9
+ * connect/agents.js which agent, and how it is asked
10
+ * connect/configs.js writing each agent's config, and proving it landed
11
+ * connect/pairing.js pair codes and the credential behind them
12
+ * connect/checkin.js self-test, agent run, terminal opening, waiting
13
+ * connect/guide.js endpoints, first test, runner, desktop, integrations, hook
14
+ * connect/dispatch.js the cloud-dispatch credential
15
+ *
16
+ * Everything those modules export is re-exported at the bottom, so `cli.js` and
17
+ * the smoke suites keep importing `bin/connect.js` and cannot tell this changed.
11
18
  */
12
19
 
13
- import { execFileSync, spawn, spawnSync } from "node:child_process";
14
- import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
20
  import os from "node:os";
16
21
  import path from "node:path";
22
+ import { existsSync } from "node:fs";
17
23
 
18
- import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
19
- import { installDesktopCommand } from "./desktop.js";
20
- import { installHook, hookStatus } from "./hook.js";
21
- import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
22
- import {
23
- confirmRunnerOnline,
24
- pairingIsLive,
25
- readRunnerState,
26
- registerRunner,
27
- runnerIsAlive,
28
- startBackground,
29
- } from "./runner.js";
30
24
  import {
31
25
  apiKeyIsExplicit,
32
- assertOk,
33
26
  authenticateTerminal,
34
27
  backendUrl,
35
28
  buildServerConfig,
36
- callBackendJson,
37
- clearStoredCredentials,
38
29
  cliInvocation,
39
- frontendUrl,
40
30
  hasKeyAvailable,
41
- LAUNCHER_ARGS,
42
- LAUNCHER_COMMAND,
43
31
  makeArgs,
32
+ onPath,
44
33
  pathPremanOwner,
45
- promptSecret,
46
- promptText,
47
- readJsonFile,
48
34
  resolveApiKey,
49
- writeJsonFile,
50
35
  } from "./shared.js";
51
-
52
- const EXIT_USAGE = 2;
53
-
54
- class ConnectError extends Error {
55
- constructor(message, exitCode = 1) {
56
- super(message);
57
- this.exitCode = exitCode;
58
- }
59
- }
60
-
61
- /**
62
- * Supported agents. `id` matches the backend's normalize_agent() vocabulary so
63
- * the link record and this CLI cannot disagree about who is connected.
64
- */
65
- const AGENTS = [
66
- {
67
- id: "cursor",
68
- label: "Cursor",
69
- aliases: ["cursor"],
70
- dispatch: {
71
- credential: "Cursor API key",
72
- needsRoutine: false,
73
- source: "cursor.com/dashboard → Integrations → API Keys",
74
- },
75
- snippetHint: "merge into ~/.cursor/mcp.json",
76
- restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
77
- },
78
- {
79
- id: "claude_code",
80
- label: "Claude Code",
81
- aliases: ["claude", "claude-code", "claude_code", "claudecode"],
82
- dispatch: {
83
- credential: "Claude Code routine token",
84
- needsRoutine: true,
85
- // Named explicitly because "Claude Code routine token" reads like an
86
- // Anthropic API key, which is a different credential from a different page.
87
- source: "claude.ai/code/routines → your routine → Add API trigger",
88
- },
89
- snippetHint: "run:",
90
- restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
91
- },
92
- {
93
- id: "codex",
94
- label: "Codex",
95
- aliases: ["codex", "openai-codex", "openai_codex"],
96
- dispatch: null, // No public fire API; stays on the copy-paste path.
97
- snippetHint: "append to ~/.codex/config.toml",
98
- restartHint: "Restart Codex so it re-reads its config.toml.",
99
- },
100
- ];
101
-
102
- function findAgent(value) {
103
- const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
104
- return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
105
- }
106
-
107
- function onPath(binary) {
108
- const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
109
- stdio: "ignore",
110
- });
111
- return probe.status === 0;
112
- }
113
-
114
- /**
115
- * Why this agent's CLI cannot run unattended right now, or "".
116
- *
117
- * Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
118
- * on every `-p` run until someone signs in, which surfaced as "Cursor did not
119
- * register any endpoints" and sent people looking at PreMan for an hour. Only
120
- * Cursor is probed because only its CLI has a cheap non-interactive status
121
- * subcommand; the others are diagnosed from their own output when they fail.
122
- */
123
- export function agentBlocker(agentId) {
124
- if (agentId !== "cursor") return "";
125
- const probe = spawnSync("cursor-agent", ["status"], {
126
- encoding: "utf8",
127
- timeout: 15000,
128
- stdio: ["ignore", "pipe", "pipe"],
129
- });
130
- const text = `${probe.stdout || ""}${probe.stderr || ""}`;
131
- if (/not logged in|not authenticated|no active session/i.test(text)) {
132
- return "cursor-agent is installed but not signed in — run `cursor-agent login`";
133
- }
134
- return "";
135
- }
136
-
137
- /** Best guess at which agent this machine actually uses, for the default pick. */
138
- function detectAgents() {
139
- const home = os.homedir();
140
- return {
141
- cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
142
- claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
143
- codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
144
- };
145
- }
146
-
147
- /**
148
- * The agent whose session this command is running inside, if any.
149
- *
150
- * A better default than "first one installed": someone who types this into an
151
- * agent's terminal almost always means that agent, and on a machine with all
152
- * three installed the detected-order default is usually wrong.
153
- */
154
- export function runningInside(env = process.env) {
155
- if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
156
- if (env.CURSOR_TRACE_ID) return "cursor";
157
- return "";
158
- }
159
-
160
- async function promptAgentChoice(detected, insideId = runningInside()) {
161
- process.stdout.write("Which coding agent?\n");
162
- AGENTS.forEach((agent, index) => {
163
- let mark = "";
164
- if (agent.id === insideId) mark = " (this session)";
165
- else if (detected[agent.id]) mark = " (detected)";
166
- process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
167
- });
168
-
169
- const inside = AGENTS.findIndex((a) => a.id === insideId);
170
- const defaultIndex =
171
- inside >= 0
172
- ? inside
173
- : Math.max(
174
- 0,
175
- AGENTS.findIndex((a) => detected[a.id])
176
- );
177
- const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
178
- if (!answer) return AGENTS[defaultIndex];
179
-
180
- const byNumber = Number.parseInt(answer, 10);
181
- if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
182
- return AGENTS[byNumber - 1];
183
- }
184
- const byName = findAgent(answer);
185
- if (byName) return byName;
186
- throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
187
- }
188
-
189
- // ── Config writers ──────────────────────────────────────────────────────
190
-
191
- function cursorConfigPath(projectInstall) {
192
- return projectInstall
193
- ? path.join(process.cwd(), ".cursor", "mcp.json")
194
- : path.join(os.homedir(), ".cursor", "mcp.json");
195
- }
196
-
197
- export function writeCursorConfig({ serverName, serverConfig, projectInstall }) {
198
- const configPath = cursorConfigPath(projectInstall);
199
- const config = { ...readJsonFile(configPath) };
200
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
201
- writeJsonFile(configPath, config);
202
- return { path: configPath, how: "wrote" };
203
- }
204
-
205
- /** The `claude mcp add` invocation, shared by the writer and the printed fallback. */
206
- export function claudeMcpAddArgs(serverName, serverConfig, projectInstall) {
207
- const envArgs = Object.entries(serverConfig.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
208
- return [
209
- "mcp",
210
- "add",
211
- serverName,
212
- "--scope",
213
- projectInstall ? "project" : "user",
214
- ...envArgs,
215
- "--",
216
- serverConfig.command,
217
- ...serverConfig.args,
218
- ];
219
- }
220
-
221
- /**
222
- * Claude Code owns ~/.claude.json, so prefer its own CLI. Fall back to writing
223
- * the config directly when `claude` is not installed — a user can connect before
224
- * installing the agent.
225
- */
226
- export function writeClaudeConfig({ serverName, serverConfig, projectInstall }) {
227
- if (onPath("claude")) {
228
- const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
229
- try {
230
- execFileSync("claude", args, { stdio: "pipe" });
231
- return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
232
- } catch (error) {
233
- const detail = error?.stderr?.toString().trim() || error.message;
234
- process.stdout.write(`Note: claude mcp add failed (${detail}); writing the config directly.\n`);
235
- }
236
- }
237
-
238
- if (projectInstall) {
239
- const configPath = path.join(process.cwd(), ".mcp.json");
240
- const config = { ...readJsonFile(configPath) };
241
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
242
- writeJsonFile(configPath, config);
243
- return { path: configPath, how: "wrote" };
244
- }
245
-
246
- // Merge only mcpServers: the user config holds a lot of unrelated state.
247
- // CLAUDE_CONFIG_DIR relocates that file, and writing to ~ when it is set
248
- // produces a config Claude Code never reads.
249
- const configPath = path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
250
- const config = { ...readJsonFile(configPath) };
251
- config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
252
- writeJsonFile(configPath, config);
253
- return { path: configPath, how: "wrote" };
254
- }
255
-
256
- /**
257
- * Render the `[mcp_servers.<name>]` block for Codex's TOML config. Only strings
258
- * and string arrays appear here, so a hand-rolled writer is enough — no TOML
259
- * dependency for one fixed shape.
260
- */
261
- export function renderCodexToml(serverName, serverConfig) {
262
- const str = (value) => JSON.stringify(String(value));
263
- const lines = [
264
- `[mcp_servers.${serverName}]`,
265
- `command = ${str(serverConfig.command)}`,
266
- `args = [${serverConfig.args.map(str).join(", ")}]`,
267
- ];
268
- const env = Object.entries(serverConfig.env);
269
- if (env.length) {
270
- lines.push("", `[mcp_servers.${serverName}.env]`);
271
- for (const [key, value] of env) lines.push(`${key} = ${str(value)}`);
272
- }
273
- return `${lines.join("\n")}\n`;
274
- }
275
-
276
- /** Replace an existing preman block, or append a new one, leaving the rest alone. */
277
- export function upsertCodexToml(existing, serverName, block) {
278
- const header = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`, "m");
279
- if (!header.test(existing)) {
280
- const separator = existing.trim() ? "\n" : "";
281
- return `${existing.trimEnd()}${separator}\n${block}`;
282
- }
283
-
284
- const lines = existing.split("\n");
285
- const out = [];
286
- let skipping = false;
287
- for (const line of lines) {
288
- const isOurHeader = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`).test(line);
289
- if (isOurHeader) {
290
- skipping = true;
291
- continue;
292
- }
293
- if (skipping && /^\[/.test(line) && !isOurHeader) skipping = false;
294
- if (!skipping) out.push(line);
295
- }
296
- return `${out.join("\n").trimEnd()}\n\n${block}`;
297
- }
298
-
299
- export function writeCodexConfig({ serverName, serverConfig }) {
300
- const configPath = path.join(
301
- process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
302
- "config.toml"
303
- );
304
- const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
305
- const next = upsertCodexToml(existing, serverName, renderCodexToml(serverName, serverConfig));
306
- mkdirSync(path.dirname(configPath), { recursive: true });
307
- writeFileSync(configPath, next, { mode: 0o600 });
308
- chmodSync(configPath, 0o600); // mode above is ignored for an existing file
309
- return { path: configPath, how: "wrote" };
310
- }
311
-
312
- const WRITERS = {
313
- cursor: writeCursorConfig,
314
- claude_code: writeClaudeConfig,
315
- codex: writeCodexConfig,
316
- };
317
-
318
- // ── Copy-paste fallbacks ────────────────────────────────────────────────
319
-
320
- /** Quote only what a shell would otherwise mangle, so the line stays readable. */
321
- function shellQuote(value) {
322
- return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
323
- }
324
-
325
- /** What a user would paste by hand to get exactly what the writer would have written. */
326
- export function renderAgentSnippet(agentId, serverName, serverConfig, { projectInstall = false } = {}) {
327
- if (agentId === "codex") return renderCodexToml(serverName, serverConfig);
328
- if (agentId === "claude_code") {
329
- const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
330
- return `claude ${args.map(shellQuote).join(" ")}\n`;
331
- }
332
- return `${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`;
333
- }
334
-
335
- /** Every agent's snippet, for when we could not pick one for the user. */
336
- export function renderAllAgentSnippets(args, serverName, { projectInstall = false } = {}) {
337
- // Build config WITHOUT resolved API key to avoid leaking secrets in CI logs.
338
- // Users must supply --api-key or set PREMAN_API_KEY separately.
339
- const env = {
340
- PREMAN_BACKEND: backendUrl(args),
341
- PREMAN_FRONTEND: frontendUrl(args),
342
- };
343
- const serverConfig = {
344
- command: LAUNCHER_COMMAND,
345
- args: [...LAUNCHER_ARGS],
346
- env,
347
- };
348
-
349
-
350
- return AGENTS.map((agent) => {
351
- // Adjust hint based on projectInstall, matching verifyWrittenConfig logic
352
- let hint = agent.snippetHint;
353
- if (agent.id === "cursor" && projectInstall) {
354
- hint = "merge into .cursor/mcp.json";
355
- } else if (agent.id === "claude_code") {
356
- hint = projectInstall ? "run (with --scope project):" : "run:";
357
- }
358
- return `# ${agent.label} — ${hint}\n` + renderAgentSnippet(agent.id, serverName, serverConfig, { projectInstall });
359
- }).join("\n");
360
- }
361
-
362
- // ── Post-write validation ───────────────────────────────────────────────
363
-
364
- /** The lines belonging to our `[mcp_servers.<name>]` block, or null if absent. */
365
- function codexBlockLines(text, serverName) {
366
- const lines = text.split("\n");
367
- const start = lines.findIndex((line) => line.trim() === `[mcp_servers.${serverName}]`);
368
- if (start === -1) return null;
369
- const block = [];
370
- for (let i = start + 1; i < lines.length; i += 1) {
371
- const line = lines[i];
372
- if (/^\[/.test(line) && !line.startsWith(`[mcp_servers.${serverName}.`)) break;
373
- block.push(line);
374
- }
375
- return block;
376
- }
377
-
378
- /**
379
- * Read back what we just wrote, so a silently-failed write is not reported as a
380
- * success. Proves the entry is on disk (or that Claude Code knows about it) —
381
- * whether the agent has actually loaded it is only ever proven by a check-in.
382
- *
383
- * Never throws: a verification that cannot run must not fail the connect. An
384
- * unreadable state reports "unknown" and stays quiet, because a wrong warning
385
- * costs more trust than a missing one.
386
- */
387
- export function verifyWrittenConfig(agent, { serverName, written }) {
388
- try {
389
- if (agent.id === "codex") {
390
- const text = written.path && existsSync(written.path) ? readFileSync(written.path, "utf8") : "";
391
- const block = codexBlockLines(text, serverName);
392
- if (!block) {
393
- return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
394
- }
395
- if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
396
- return {
397
- status: "mismatch",
398
- detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
399
- };
400
- }
401
- return { status: "verified" };
402
- }
403
-
404
- // Claude Code registered the server itself — ask its CLI what it ended up with.
405
- if (agent.id === "claude_code" && written.how !== "wrote") {
406
- const probe = spawnSync("claude", ["mcp", "get", serverName], { stdio: "ignore" });
407
- if (probe.error) return { status: "unknown", detail: "could not run claude" };
408
- return probe.status === 0
409
- ? { status: "verified" }
410
- : { status: "mismatch", detail: `claude mcp get ${serverName} did not find the server` };
411
- }
412
-
413
- const entry = readJsonFile(written.path).mcpServers?.[serverName];
414
- if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
415
- if (entry.command !== LAUNCHER_COMMAND) {
416
- return {
417
- status: "mismatch",
418
- detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
419
- };
420
- }
421
- return { status: "verified" };
422
- } catch (error) {
423
- return { status: "unknown", detail: error.message };
424
- }
425
- }
426
-
427
- // ── Pairing ─────────────────────────────────────────────────────────────
428
-
429
- /**
430
- * Mint the pair code, distinguishing "could not reach it" from "this key is dead".
431
- *
432
- * 401 is the backend saying the key is invalid or revoked, which is the one
433
- * failure the caller must not shrug off: every other outcome still leaves a
434
- * usable config, but writing a revoked key into an agent's config produces a
435
- * setup that looks connected and fails on every call.
436
- */
437
- async function startPairing(args, agent, apiKey) {
438
- const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
439
- token: apiKey,
440
- json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
441
- });
442
- if (result.status_code === 401) return { pairCode: "", stale: true };
443
- if (!result.ok) {
444
- process.stdout.write(
445
- `Note: could not start pairing (${result.status_code}); the config still works, ` +
446
- "your agent will link on its first PreMan call.\n"
447
- );
448
- return { pairCode: "", stale: false };
449
- }
450
- return { pairCode: String(result.pair_code || ""), stale: false };
451
- }
452
-
453
- /**
454
- * The --no-pair stand-in for the auth check pairing performs implicitly.
455
- *
456
- * Declining a pair code should not also decline noticing the key is dead,
457
- * otherwise this flag writes exactly the broken config pairing would catch.
458
- */
459
- async function verifyKey(args, apiKey) {
460
- const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
461
- return { pairCode: "", stale: result.status_code === 401 };
462
- }
463
-
464
- /** Whichever of the two ways to learn the backend still accepts this key. */
465
- async function checkKeyAndPair(args, agent, apiKey) {
466
- return args.has("--no-pair")
467
- ? verifyKey(args, apiKey)
468
- : startPairing(args, agent, apiKey);
469
- }
470
-
471
- /**
472
- * Replace credentials the backend has rejected, and return the new key.
473
- *
474
- * Only stored credentials can be replaced here: an explicit --api-key or
475
- * PREMAN_API_KEY would just be re-read, so those say so and stop rather than
476
- * looping. Refusing to continue is deliberate — the alternative is writing a
477
- * key we know is dead into the agent's config.
478
- */
479
- async function refreshStaleCredentials(args) {
480
- if (apiKeyIsExplicit(args)) {
481
- const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
482
- throw new ConnectError(
483
- `The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
484
- `dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
485
- EXIT_USAGE
486
- );
487
- }
488
- if (args.has("--skip-login") || !process.stdin.isTTY) {
489
- throw new ConnectError(
490
- "Saved PreMan credentials are invalid or revoked. Run " +
491
- `'${cliInvocation()} logout', then connect again to sign in.`,
492
- EXIT_USAGE
493
- );
494
- }
495
-
496
- process.stdout.write(
497
- "\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
498
- "account behind it was removed. Signing you in again.\n"
499
- );
500
- clearStoredCredentials();
501
- await authenticateTerminal(args);
502
- process.stdout.write("\n");
503
- return resolveApiKey(args);
504
- }
505
-
506
- export async function waitForConnection(
507
- args,
508
- apiKey,
509
- {
510
- intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
511
- timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
512
- stopWhen = null,
513
- // Called once per unsuccessful poll, so a wait measured in minutes can show
514
- // that it is still a wait rather than a hang.
515
- onPoll = null,
516
- } = {}
517
- ) {
518
- const deadline = Date.now() + timeoutMs;
519
- let interrupted = false;
520
- const onInterrupt = () => {
521
- interrupted = true;
522
- };
523
- process.on("SIGINT", onInterrupt);
524
-
525
- try {
526
- while (Date.now() < deadline && !interrupted) {
527
- const status = await callBackendJson(args, "GET", "/workbench/coding-agent", {
528
- token: apiKey,
529
- });
530
- if (status.ok && status.connected) return true;
531
- if (stopWhen && stopWhen()) return false;
532
- if (onPoll) onPoll();
533
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
534
- }
535
- } finally {
536
- process.off("SIGINT", onInterrupt);
537
- }
538
- return false;
539
- }
540
-
541
- /** A dot per poll, and the newline that closes the run of them. */
542
- function pollTicker() {
543
- let dots = 0;
544
- return {
545
- tick() {
546
- dots += 1;
547
- process.stdout.write(".");
548
- },
549
- end() {
550
- if (!dots) return;
551
- dots = 0;
552
- process.stdout.write("\n");
553
- },
554
- };
555
- }
556
-
557
- // ── Auto check-in ───────────────────────────────────────────────────────
558
-
559
- /**
560
- * How to ask each agent to make one PreMan call without opening its UI.
561
- *
562
- * The link is established by the agent's first call, so anything that reaches
563
- * `preman_status` finishes the connect. Claude Code needs the server on its
564
- * allow-list because print mode refuses un-allowed MCP tools rather than
565
- * prompting.
566
- */
567
- export function headlessCheckIn(agent, serverName) {
568
- const prompt = checkInPrompt(serverName);
569
- if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
570
- if (agent.id === "claude_code") {
571
- return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
572
- }
573
- if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
574
- return null;
575
- }
576
-
577
- function checkInPrompt(serverName) {
578
- return `Call the ${serverName} MCP tool preman_status and report the result.`;
579
- }
580
-
581
- /**
582
- * How to start each agent as the session the user actually works in.
583
- *
584
- * Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
585
- * the agent anybody goes on to use, and its failures are invisible because
586
- * nobody is looking at it. The interactive form is the same prompt without that
587
- * flag — it needs a terminal of its own, which is what openInNewTerminal is for.
588
- */
589
- export function interactiveCheckIn(agent, serverName) {
590
- const prompt = checkInPrompt(serverName);
591
- if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
592
- if (agent.id === "claude_code") {
593
- return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
594
- }
595
- if (agent.id === "codex") return { bin: "codex", args: [prompt] };
596
- return null;
597
- }
598
-
599
- /** A command line for a spec, quoted for the shell the terminal will start. */
600
- export function commandLine(spec) {
601
- return [spec.bin, ...spec.args].map(shellQuote).join(" ");
602
- }
603
-
604
- /** Escape a shell line into an AppleScript string literal. */
605
- function appleScriptString(value) {
606
- return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
607
- }
608
-
609
- /**
610
- * The terminal emulator to open a window in, or null when we know of none.
611
- *
612
- * `sync` marks the launchers that hand the work to an app and return — those
613
- * report their own failure through an exit code, which is worth waiting for.
614
- * The rest *are* the window, so they are spawned detached and outlive us.
615
- */
616
- function terminalLauncher(line) {
617
- if (process.platform === "darwin") {
618
- const iterm = existsSync("/Applications/iTerm.app");
619
- const script = iterm
620
- ? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
621
- : `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
622
- return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
623
- }
624
-
625
- if (process.platform === "win32") {
626
- return {
627
- bin: "cmd.exe",
628
- args: ["/c", "start", "cmd.exe", "/k", line],
629
- label: "Command Prompt",
630
- sync: true,
631
- };
632
- }
633
-
634
- // Keep the shell alive afterwards: an agent that exits immediately would
635
- // otherwise take its own error message off the screen with it.
636
- const body = `${line}; exec ${process.env.SHELL || "sh"}`;
637
- const candidates = [
638
- { bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
639
- { bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
640
- { bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
641
- { bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
642
- ];
643
- return candidates.find((candidate) => onPath(candidate.bin)) || null;
644
- }
645
-
646
- /**
647
- * Open `command` in a new terminal window, best effort.
648
- *
649
- * Same contract as openUrl: never throws, and declines rather than guessing when
650
- * nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
651
- * stdout for CI, piped output and test runs. Callers fall back to a headless run.
652
- */
653
- export function openInNewTerminal(command, options = {}) {
654
- const cwd = options?.cwd || process.cwd();
655
- const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
656
- if (optOut && !["0", "false", "no"].includes(optOut)) {
657
- return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
658
- }
659
- if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
660
-
661
- const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
662
- if (!launcher) return { opened: false, reason: "no terminal emulator found" };
663
-
664
- try {
665
- if (launcher.sync) {
666
- const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
667
- if (done.error) return { opened: false, reason: done.error.message };
668
- if (done.status !== 0) {
669
- return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
670
- }
671
- } else {
672
- const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
673
- child.unref();
674
- }
675
- return { opened: true, terminal: launcher.label };
676
- } catch (error) {
677
- return { opened: false, reason: error.message };
678
- }
679
- }
680
-
681
- /**
682
- * Finish the link ourselves instead of asking the user to go restart their agent.
683
- *
684
- * The config on disk is already correct at this point; all that is missing is
685
- * one call from the agent, and telling someone to make it from another terminal
686
- * is a dead end in the one terminal they are sitting in.
687
- *
688
- * So start the agent. Interactively, in a window of its own, because that is the
689
- * session the user keeps: a print-mode run answers once, exits, and leaves them
690
- * exactly where they started. Only when no window can be opened — CI, SSH, a
691
- * machine with no terminal emulator — does this fall back to the headless run,
692
- * which still finishes the link even though nobody sees it happen.
693
- *
694
- * `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
695
- * the MCP server reads its repo config out of the working directory, so starting
696
- * the agent elsewhere is how a caller escapes a directory that would otherwise
697
- * redirect it to a backend this key was never issued for.
698
- *
699
- * Returns `ran: false` when the agent's binary is absent or will not start, and
700
- * the caller falls back to the printed instructions.
701
- */
702
- export async function autoCheckIn(
703
- args,
704
- agent,
705
- apiKey,
706
- {
707
- // Never outlast the connect's own wait budget: this phase is part of it, not
708
- // an extra one bolted on the front.
709
- timeoutMs = Math.min(
710
- Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
711
- Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
712
- ),
713
- serverName = "preman",
714
- intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
715
- cwd = process.cwd(),
716
- onLaunch = () => {},
717
- onPoll = null,
718
- } = {}
719
- ) {
720
- const session = interactiveCheckIn(agent, serverName);
721
- if (session && onPath(session.bin)) {
722
- const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
723
- if (opened) {
724
- onLaunch({ mode: "interactive", bin: session.bin, terminal });
725
- // Detached, so there is no exit to watch for and no output to quote: the
726
- // agent is in front of the user now, and the deadline is all that bounds
727
- // this. Whoever is watching the window can Ctrl+C out of the wait.
728
- const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
729
- return { ran: true, connected, command: session.bin, interactive: true, terminal };
730
- }
731
- }
732
-
733
- const spec = headlessCheckIn(agent, serverName);
734
- if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
735
- if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
736
-
737
- let child;
738
- try {
739
- // Piped rather than ignored: an agent that runs and does not check in used to
740
- // report exactly that and nothing else, which is the least useful sentence
741
- // available. Its own last words usually name the cause.
742
- child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
743
- } catch (error) {
744
- return { ran: false, connected: false, reason: error.message };
745
- }
746
- onLaunch({ mode: "headless", bin: spec.bin });
747
-
748
- let spawnError = null;
749
- let exitedAt = 0;
750
- let output = "";
751
- const absorb = (chunk) => {
752
- output = `${output}${chunk}`.slice(-4000);
753
- };
754
- child.stdout?.setEncoding("utf8");
755
- child.stderr?.setEncoding("utf8");
756
- child.stdout?.on("data", absorb);
757
- child.stderr?.on("data", absorb);
758
- child.on("error", (error) => {
759
- spawnError = error;
760
- exitedAt = exitedAt || Date.now();
761
- });
762
- child.on("exit", () => {
763
- exitedAt = exitedAt || Date.now();
764
- });
765
-
766
- // The check-in can land moments after the agent's own process ends, so keep
767
- // polling briefly past its exit rather than declaring failure at the edge.
768
- const grace = intervalMs * 2;
769
- try {
770
- const connected = await waitForConnection(args, apiKey, {
771
- intervalMs,
772
- timeoutMs,
773
- onPoll,
774
- stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
775
- });
776
- if (spawnError && !connected) {
777
- return { ran: false, connected: false, reason: spawnError.message, output };
778
- }
779
- return { ran: true, connected, command: spec.bin, output };
780
- } finally {
781
- if (child.exitCode === null && child.signalCode === null) child.kill();
782
- }
783
- }
784
-
785
- /** The last non-empty line of an agent's output, for a one-line diagnosis. */
786
- export function lastLine(output, cap = 200) {
787
- const lines = String(output || "")
788
- .split("\n")
789
- .map((line) => line.trim())
790
- .filter(Boolean);
791
- return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
792
- }
793
-
794
- // ── MCP self-test ───────────────────────────────────────────────────────
795
-
796
- /**
797
- * How long to give the self-test, and whether to run it at all.
798
- *
799
- * `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
800
- * run wants: the launcher is `npm exec premanmcp@latest`, so every case that
801
- * reaches this would otherwise go to the registry.
802
- */
803
- export function selfTestBudgetMs() {
804
- const raw = process.env.PREMAN_SELFTEST_MS;
805
- if (raw === undefined || raw === "") return 60000;
806
- const parsed = Number(raw);
807
- return Number.isFinite(parsed) ? parsed : 60000;
808
- }
809
-
810
- /**
811
- * Run the config we just wrote, exactly as the agent will, and call one tool.
812
- *
813
- * This is both the fastest way to finish the link and the only check that proves
814
- * the whole chain — launcher, package, key, backend — rather than proving the
815
- * file is on disk. It matters because the failure it catches is invisible
816
- * otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
817
- * redirect the server to a backend the key was never issued for, and every
818
- * symptom of that points at the key.
819
- *
820
- * Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
821
- */
822
- export async function mcpSelfTest(
823
- serverConfig,
824
- { timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
825
- ) {
826
- if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
827
- let child;
828
- try {
829
- child = spawn(serverConfig.command, serverConfig.args, {
830
- cwd,
831
- env: { ...process.env, ...serverConfig.env },
832
- stdio: ["pipe", "pipe", "pipe"],
833
- });
834
- } catch (error) {
835
- return { ok: false, reason: error.message };
836
- }
837
-
838
- return new Promise((resolve) => {
839
- let stdout = "";
840
- let stderr = "";
841
- let settled = false;
842
-
843
- const finish = (result) => {
844
- if (settled) return;
845
- settled = true;
846
- clearTimeout(timer);
847
- if (child.exitCode === null && child.signalCode === null) child.kill();
848
- resolve(result);
849
- };
850
-
851
- const timer = setTimeout(
852
- () => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
853
- timeoutMs
854
- );
855
-
856
- const send = (message) => {
857
- try {
858
- child.stdin.write(`${JSON.stringify(message)}\n`);
859
- } catch (error) {
860
- finish({ ok: false, reason: error.message });
861
- }
862
- };
863
-
864
- child.on("error", (error) => finish({ ok: false, reason: error.message }));
865
- child.on("exit", (code) =>
866
- finish({
867
- ok: false,
868
- reason: `the MCP server exited with code ${code}`,
869
- stderr: lastLine(stderr),
870
- })
871
- );
872
-
873
- child.stdout.setEncoding("utf8");
874
- child.stderr.setEncoding("utf8");
875
- child.stderr.on("data", (chunk) => {
876
- stderr = `${stderr}${chunk}`.slice(-4000);
877
- });
878
- child.stdout.on("data", (chunk) => {
879
- stdout += chunk;
880
- let newline = stdout.indexOf("\n");
881
- while (newline !== -1) {
882
- const line = stdout.slice(0, newline).trim();
883
- stdout = stdout.slice(newline + 1);
884
- newline = stdout.indexOf("\n");
885
- if (!line.startsWith("{")) continue;
886
- let message;
887
- try {
888
- message = JSON.parse(line);
889
- } catch {
890
- continue;
891
- }
892
- if (message.id === 1) {
893
- send({ jsonrpc: "2.0", method: "notifications/initialized" });
894
- send({
895
- jsonrpc: "2.0",
896
- id: 2,
897
- method: "tools/call",
898
- params: { name: "preman_status", arguments: {} },
899
- });
900
- continue;
901
- }
902
- if (message.id !== 2) continue;
903
- const text = message.result?.content?.[0]?.text;
904
- let status = {};
905
- try {
906
- status = text ? JSON.parse(text) : {};
907
- } catch {
908
- status = { raw: text };
909
- }
910
- finish({ ok: !message.error, status, reason: message.error?.message || "" });
911
- }
912
- });
913
-
914
- send({
915
- jsonrpc: "2.0",
916
- id: 1,
917
- method: "initialize",
918
- params: {
919
- protocolVersion: "2024-11-05",
920
- capabilities: {},
921
- clientInfo: { name: "preman-connect-selftest", version: "1" },
922
- },
923
- });
924
- });
925
- }
926
-
927
- // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
928
-
929
- async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
930
- if (!agent.dispatch) return;
931
- if (args.has("--skip-dispatch-credential")) return;
932
-
933
- let secret = args.value("--dispatch-credential", "");
934
- let routineId = args.value("--routine-id", "");
935
-
936
- if (!secret) {
937
- if (!prompt || !process.stdin.isTTY) return;
938
- // Framed as the expected step rather than an optional aside. Without it
939
- // PreMan can only suggest fixes; with it, it can run them. Presenting it as
940
- // "optional, press Enter to skip" meant almost everyone skipped the thing
941
- // that makes the product act rather than advise.
942
- process.stdout.write(
943
- `\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
944
- `fixes and run checks on a schedule instead of only telling you what to do.\n` +
945
- `Get the token from ${agent.dispatch.source}.\n`
946
- );
947
- secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
948
- if (!secret) {
949
- process.stdout.write(
950
- `Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
951
- );
952
- return;
953
- }
954
- if (agent.dispatch.needsRoutine && !routineId) {
955
- routineId = await promptText("Routine id or URL: ");
956
- }
957
- }
958
-
959
- if (agent.dispatch.needsRoutine && !routineId) {
960
- process.stdout.write("Skipped: a routine id is required alongside the token.\n");
961
- return;
962
- }
963
-
964
- const body = agent.dispatch.needsRoutine
965
- ? { provider: agent.id, secret, routine_id: extractRoutineId(routineId) }
966
- : { provider: agent.id, secret };
967
-
968
- const result = await callBackendJson(args, "PUT", "/workbench/coding-agent/dispatch", {
969
- token: apiKey,
970
- json: body,
971
- });
972
-
973
- if (result.ok) {
974
- process.stdout.write("Cloud dispatch enabled — PreMan can now start runs for you.\n");
975
- } else if (result.status_code === 404 || result.status_code === 405) {
976
- process.stdout.write("Note: this PreMan backend does not support cloud dispatch yet; skipped.\n");
977
- } else {
978
- process.stdout.write(`Note: could not save the credential (${result.status_code}); skipped.\n`);
979
- }
980
- }
981
-
982
- export const DISPATCH_HELP = `
983
- Dispatch options:
984
- --agent <name> cursor | claude-code (defaults to the connected agent)
985
- --dispatch-credential <t> Cloud-dispatch token (non-interactive)
986
- --routine-id <id> Claude Code routine id, with --dispatch-credential
987
- --api-key <key> PreMan API key. If omitted, stored credentials are used
988
- --backend <url> PreMan backend URL
989
- `;
990
-
991
- /**
992
- * `preman dispatch` — store the cloud-dispatch credential on its own.
993
- *
994
- * Exists so connect never has to ask for a token mid-onboarding. Someone who
995
- * skipped it (or did not have it yet) comes back here instead of re-running the
996
- * whole connect.
997
- */
998
- export async function dispatchCommand(commandArgs) {
999
- const args = makeArgs(commandArgs);
1000
- const apiKey = resolveApiKey(args);
1001
- if (!apiKey) {
1002
- throw new ConnectError(
1003
- `No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
1004
- EXIT_USAGE
1005
- );
1006
- }
1007
-
1008
- let agent = findAgent(args.value("--agent", ""));
1009
- if (!agent && args.value("--agent", "")) {
1010
- throw new ConnectError(
1011
- `Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
1012
- EXIT_USAGE
1013
- );
1014
- }
1015
-
1016
- if (!agent) {
1017
- const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
1018
- agent = findAgent(current.ok ? current.agent : "");
1019
- if (!agent) {
1020
- throw new ConnectError(
1021
- "Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
1022
- EXIT_USAGE
1023
- );
1024
- }
1025
- }
1026
-
1027
- if (!agent.dispatch) {
1028
- throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
1029
- }
1030
- if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
1031
- throw new ConnectError(
1032
- "preman dispatch needs a terminal, or --dispatch-credential <token>.",
1033
- EXIT_USAGE
1034
- );
1035
- }
1036
-
1037
- await captureDispatchCredential(args, agent, apiKey);
1038
- }
1039
-
1040
- /** Accept either a bare trig_… id or the routine URL it appears in. */
1041
- export function extractRoutineId(value) {
1042
- const raw = String(value || "").trim();
1043
- const match = raw.match(/trig_[A-Za-z0-9]+/);
1044
- return match ? match[0] : raw;
1045
- }
1046
-
1047
- // ── Guided first run ────────────────────────────────────────────────────
1048
-
1049
- const MANUAL_STEPS =
1050
- " preman endpoints discover # brief for your agent → endpoints.json\n" +
1051
- " preman endpoints setup --file endpoints.json # register runnable requests\n" +
1052
- " preman test <request-id> # generate + run your first scenarios\n";
1053
-
1054
- function nextStepsBlock(agent) {
1055
- return (
1056
- "\nNext steps:\n" +
1057
- ` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
1058
- ` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
1059
- ` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
1060
- ` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
1061
- );
1062
- }
1063
-
1064
- /**
1065
- * What is left once the link is done, as commands rather than as questions.
1066
- *
1067
- * Connect used to run all of these itself, which bought a prompt per step and a
1068
- * wait as long as the slowest broken one — and a link that had already succeeded
1069
- * ended on a failed runner and a five-minute GitHub poll. Each is one command, so
1070
- * this lists them and gets out of the way.
1071
- */
1072
- function whatIsLeftBlock(args) {
1073
- const cli = cliInvocation();
1074
- return (
1075
- "\nWhat is left:\n" +
1076
- ` ${cli} endpoints discover # map this repo's endpoints\n` +
1077
- ` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
1078
- ` ${cli} github # connect GitHub, or do it in the dashboard\n` +
1079
- `\n${cli} status reports your endpoints, last push and integrations.\n` +
1080
- `Watch runs at ${frontendUrl(args)}\n`
1081
- );
1082
- }
1083
-
1084
- /**
1085
- * Ask, or take the step's own default when nobody can answer.
1086
- *
1087
- * `--yes` returns the default rather than a blanket yes, which matters for
1088
- * exactly one step: the desktop app defaults to no because it downloads a
1089
- * hundred-odd megabytes and writes to /Applications. A blanket yes made
1090
- * `preman connect --yes` do that unattended, which is not what anyone means by
1091
- * "do not ask me questions" -- they get `preman install-desktop` for that.
1092
- */
1093
- async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
1094
- if (assumeYes) return defaultYes;
1095
- if (!process.stdin.isTTY) return false;
1096
- const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
1097
- if (answer === "") return defaultYes;
1098
- return answer.startsWith("y");
1099
- }
1100
-
1101
- function step(title) {
1102
- process.stdout.write(`\n${title}\n`);
1103
- }
1104
-
1105
- /** What the account can see right now: registry rows, and the runnable subset. */
1106
- async function endpointCounts(args) {
1107
- const inventory = await callPremanTool(args, "get_endpoints", {
1108
- include_workbench: true,
1109
- limit: 200,
1110
- });
1111
- const runnable = inventory.workbench_requests || [];
1112
- return {
1113
- registered: (inventory.endpoints || []).length,
1114
- runnable: runnable.length,
1115
- first: runnable[0] || null,
1116
- };
1117
- }
1118
-
1119
- /**
1120
- * The one-shot command that has an agent execute the discovery brief.
1121
- *
1122
- * Same shape as `headlessCheckIn`, with the read tools the walk needs added to
1123
- * Claude Code's allow-list: print mode refuses anything un-allowed instead of
1124
- * asking, so an agent given only the MCP server can call PreMan and read nothing.
1125
- */
1126
- export function headlessDiscovery(agent, serverName, instructions = []) {
1127
- const prompt = [
1128
- `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
1129
- "Work autonomously and do not ask for confirmation.",
1130
- "",
1131
- ...instructions,
1132
- ].join("\n");
1133
- if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
1134
- if (agent.id === "claude_code") {
1135
- return {
1136
- bin: "claude",
1137
- args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
1138
- };
1139
- }
1140
- if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
1141
- return null;
1142
- }
1143
-
1144
- /**
1145
- * Get this project's endpoints into PreMan without handing anyone homework.
1146
- *
1147
- * Discovery is the agent's job — only it can read the repo — but "here is a brief,
1148
- * go paste it somewhere" is the step everybody dropped out on, and the commands
1149
- * printed after it referenced a file the user never had. So run the agent on the
1150
- * brief and report the two numbers that matter.
1151
- *
1152
- * Never throws: a failed discovery must not fail the connect.
1153
- */
1154
- export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
1155
- try {
1156
- const before = await endpointCounts(args);
1157
- if (before.registered) {
1158
- process.stdout.write(
1159
- `${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
1160
- );
1161
- return before;
1162
- }
1163
-
1164
- const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
1165
- const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
1166
- // Discovery has to read *this* repo, so it cannot be moved out of a directory
1167
- // that redirects it the way the check-in was — it can only be skipped, before
1168
- // it spends ten minutes proving what the self-test already found out.
1169
- const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
1170
- if (!spec || !onPath(spec.bin) || blocker) {
1171
- // Say why before printing homework: "here is a brief" reads as PreMan not
1172
- // working, when the actual answer is one login away.
1173
- if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
1174
- for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
1175
- process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
1176
- return before;
1177
- }
1178
-
1179
- process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
1180
- const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
1181
- const outcome = await runAgentOnce(spec, timeoutMs);
1182
- const after = await endpointCounts(args);
1183
-
1184
- if (!after.registered) {
1185
- // Both halves: the exit code says the run failed, the agent's own last line
1186
- // says why, and either one alone has sent someone down the wrong path.
1187
- const why =
1188
- [outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
1189
- "it registered nothing";
1190
- process.stdout.write(
1191
- `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
1192
- ` Run it yourself: ${cliInvocation()} endpoints discover\n`
1193
- );
1194
- return after;
1195
- }
1196
- process.stdout.write(
1197
- `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
1198
- );
1199
- return after;
1200
- } catch (error) {
1201
- process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
1202
- return { registered: 0, runnable: 0, first: null };
1203
- }
1204
- }
1205
-
1206
- /** Run one bounded, non-interactive agent invocation and keep its tail. */
1207
- async function runAgentOnce(spec, timeoutMs) {
1208
- return new Promise((resolve) => {
1209
- let child;
1210
- try {
1211
- child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
1212
- } catch (error) {
1213
- resolve({ ok: false, reason: error.message, output: "" });
1214
- return;
1215
- }
1216
- let output = "";
1217
- const absorb = (chunk) => {
1218
- output = `${output}${chunk}`.slice(-8000);
1219
- };
1220
- child.stdout?.setEncoding("utf8");
1221
- child.stderr?.setEncoding("utf8");
1222
- child.stdout?.on("data", absorb);
1223
- child.stderr?.on("data", absorb);
1224
-
1225
- const timer = setTimeout(() => {
1226
- child.kill("SIGTERM");
1227
- resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
1228
- }, timeoutMs);
1229
- child.on("error", (error) => {
1230
- clearTimeout(timer);
1231
- resolve({ ok: false, reason: error.message, output });
1232
- });
1233
- child.on("close", (code) => {
1234
- clearTimeout(timer);
1235
- resolve({
1236
- ok: code === 0,
1237
- reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
1238
- output,
1239
- });
1240
- });
1241
- });
1242
- }
1243
-
1244
- /** Generate and run scenarios against the first runnable request, if they want it. */
1245
- async function runFirstTest(args, runnable, { assumeYes }) {
1246
- const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
1247
- if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
1248
- process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
1249
- return;
1250
- }
1251
- try {
1252
- process.stdout.write("Generating scenarios…\n");
1253
- const result = await callPremanTool(args, "generate_endpoint_tests", {
1254
- target: runnable.id,
1255
- run: true,
1256
- allow_writes: false,
1257
- max_cases: 10,
1258
- });
1259
- printTestSummary(result);
1260
- } catch (error) {
1261
- process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
1262
- }
1263
- }
1264
-
1265
- /**
1266
- * Whether a stored pairing can still be used, rather than merely matching.
1267
- *
1268
- * Matching agent and path only prove the state was written for this project. The
1269
- * token behind it can be revoked, replaced by another device, or left over from an
1270
- * API key that no longer exists — and reusing one of those starts a daemon that
1271
- * 401s on its first stream and exits, which reads as "the runner cannot start
1272
- * here" when the pairing is the only thing that needed replacing.
1273
- *
1274
- * A backend we cannot reach answers "usable": re-pairing is for a token the
1275
- * backend rejected, not for a network that was briefly down.
1276
- */
1277
- export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
1278
- if (!existing || existing.agent !== agentId) return false;
1279
- if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
1280
- return pairingIsLive(args, existing);
1281
- }
1282
-
1283
- /**
1284
- * Pair this machine as a runner and leave it running.
1285
- *
1286
- * Without this, everything PreMan finds later is advice: it can describe a fix
1287
- * and cannot apply one. With it, the queue has somewhere to land.
1288
- */
1289
- async function setUpRunner(args, agent, { assumeYes }) {
1290
- if (args.has("--no-runner")) return { state: "skipped" };
1291
- const existing = readRunnerState();
1292
- if (existing && runnerIsAlive()) {
1293
- process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
1294
- return { state: "running" };
1295
- }
1296
- if (
1297
- !(await confirm(
1298
- `Let PreMan run ${agent.label} here when it finds something to fix?`,
1299
- { assumeYes }
1300
- ))
1301
- ) {
1302
- process.stdout.write(
1303
- `${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
1304
- );
1305
- return { state: "skipped" };
1306
- }
1307
-
1308
- try {
1309
- if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
1310
- await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1311
- }
1312
- const started = startBackground([]);
1313
- // Claiming a runner that died two seconds later is worse than saying nothing:
1314
- // the whole point of this step is that PreMan can act, and someone told it
1315
- // can will wait for fixes that no device is listening for.
1316
- const up = await confirmRunnerOnline(started);
1317
- if (up.state === "exited") {
1318
- process.stdout.write(
1319
- `${MARK.fail()} The runner exited right after starting.\n` +
1320
- (up.detail ? ` ${up.detail}\n` : "") +
1321
- ` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
1322
- );
1323
- return { state: "failed", detail: up.detail };
1324
- }
1325
- process.stdout.write(
1326
- `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1327
- ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1328
- );
1329
- // Paired and online still cannot run anything if the agent it dispatches to
1330
- // will not start, and every job would fail with the same opaque exit code.
1331
- const blocker = agentBlocker(agent.id);
1332
- if (blocker) {
1333
- process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
1334
- }
1335
- return { state: "running", pid: started.pid };
1336
- } catch (error) {
1337
- process.stdout.write(
1338
- `${MARK.fail()} Could not start the runner: ${error.message}\n` +
1339
- ` Retry later: ${cliInvocation()} runner start --background\n`
1340
- );
1341
- return { state: "failed", detail: error.message };
1342
- }
1343
- }
1344
-
1345
- /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
1346
- async function offerDesktop(args, { assumeYes }) {
1347
- if (args.has("--no-desktop")) return { state: "skipped" };
1348
- if (process.platform !== "darwin") return { state: "unsupported" };
1349
- if (existsSync("/Applications/PreMan.app")) {
1350
- process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
1351
- return { state: "installed" };
1352
- }
1353
- // Default no, unlike every other step here: this one downloads a hundred-odd
1354
- // megabytes and writes to /Applications, which nobody should get by pressing
1355
- // Enter to move past a prompt.
1356
- if (
1357
- !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
1358
- assumeYes,
1359
- defaultYes: false,
1360
- }))
1361
- ) {
1362
- process.stdout.write(
1363
- `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
1364
- );
1365
- return { state: "skipped" };
1366
- }
1367
- try {
1368
- return await installDesktopCommand([]);
1369
- } catch (error) {
1370
- process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
1371
- return { state: "failed", detail: error.message };
1372
- }
1373
- }
1374
-
1375
- /**
1376
- * Which of GitHub, AWS and Slack this account already has.
1377
- *
1378
- * One probe per provider because each owns its own list route; a provider whose
1379
- * route errors is reported as unknown rather than missing, so a backend hiccup
1380
- * does not send someone through an install they already did.
1381
- */
1382
- export async function integrationStatus(args, apiKey) {
1383
- const probe = async (method, route, pick) => {
1384
- try {
1385
- const result = await callBackendJson(args, method, route, { token: apiKey });
1386
- if (!result.ok) return null;
1387
- return (pick(result) || []).length;
1388
- } catch {
1389
- return null;
1390
- }
1391
- };
1392
- const [github, aws, slack] = await Promise.all([
1393
- probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
1394
- probe("GET", "/aws-links", (r) => r.links || r.list),
1395
- probe("GET", "/slack/connections", (r) => r.connections || r.list),
1396
- ]);
1397
- return { github, aws, slack };
1398
- }
1399
-
1400
- /**
1401
- * Show what is connected, and offer to finish what is not.
1402
- *
1403
- * Each install lives in the customer's browser session, so every one of these
1404
- * opens a URL and polls our own API until the other side reports the connection
1405
- * exists — the terminal picks the result up on its own, whether they finished in
1406
- * the browser or in the desktop app.
1407
- */
1408
- async function connectIntegrations(args, apiKey, { assumeYes }) {
1409
- if (args.has("--no-integrations")) return;
1410
- const status = await integrationStatus(args, apiKey);
1411
- const providers = [
1412
- { key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
1413
- { key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
1414
- { key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
1415
- ];
1416
-
1417
- for (const provider of providers) {
1418
- const count = status[provider.key];
1419
- if (count === null) {
1420
- process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
1421
- } else if (count > 0) {
1422
- process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
1423
- } else {
1424
- process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
1425
- }
1426
- }
1427
-
1428
- for (const provider of providers) {
1429
- if (status[provider.key] !== 0) continue;
1430
- if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
1431
- try {
1432
- await provider.run();
1433
- } catch (error) {
1434
- // One provider's failure must not cost the customer the ones that worked.
1435
- process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
1436
- }
1437
- }
1438
- }
1439
-
1440
- /**
1441
- * Install the pre-push hook, so a push is what triggers the tests.
1442
- *
1443
- * Not a question: testing what you just changed before it ships is the product,
1444
- * the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
1445
- * still there. Asking only produced accounts that never tested anything.
1446
- */
1447
- async function setUpPushTesting(args) {
1448
- if (args.has("--no-hook")) return { state: "skipped" };
1449
- const current = (() => {
1450
- try {
1451
- return hookStatus({ probe: true });
1452
- } catch {
1453
- return null; // not a git repository
1454
- }
1455
- })();
1456
- if (!current) {
1457
- process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1458
- return { state: "unavailable" };
1459
- }
1460
- // A hook whose invocation went stale, or no longer answers, is reinstalled
1461
- // rather than reported as on: it is the case where PreMan looks connected and
1462
- // silently checks nothing.
1463
- if (current.state === "installed" && current.current && current.works) {
1464
- process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1465
- return { state: "installed" };
1466
- }
1467
-
1468
- const result = installHook(args);
1469
- if (result.action === "conflict") {
1470
- process.stdout.write(
1471
- `${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
1472
- );
1473
- return result;
1474
- }
1475
- if (result.action === "unproven") {
1476
- process.stdout.write(
1477
- `${MARK.skip()} No push testing: ${result.detail}\n` +
1478
- ` Then: ${cliInvocation()} hook install\n`
1479
- );
1480
- return result;
1481
- }
1482
- process.stdout.write(
1483
- `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1484
- " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
1485
- );
1486
- return result;
1487
- }
1488
-
1489
- /**
1490
- * Everything after the link, in one pass, for whoever asks for it with `--guide`.
1491
- *
1492
- * Order follows what a new account needs to see: what PreMan found, then where to
1493
- * watch it, then who to tell, then when to run it. It is opt-in because running
1494
- * all of it unasked is what turned a finished connect into a screen of prompts,
1495
- * a dead runner and a five-minute wait on an integration nobody had asked for.
1496
- */
1497
- async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
1498
- const assumeYes = args.has("--yes");
1499
-
1500
- step("Endpoints");
1501
- const counts = await discoverEndpoints(args, agent, serverName, blockedHere);
1502
-
1503
- if (counts.first) {
1504
- step("First test");
1505
- await runFirstTest(args, counts.first, { assumeYes });
1506
- }
1507
-
1508
- step("Runner");
1509
- await setUpRunner(args, agent, { assumeYes });
1510
-
1511
- step("Desktop app");
1512
- await offerDesktop(args, { assumeYes });
1513
-
1514
- step("Integrations");
1515
- await connectIntegrations(args, apiKey, { assumeYes });
1516
-
1517
- step("Testing on push");
1518
- await setUpPushTesting(args);
1519
-
1520
- process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
1521
- }
36
+ import { MARK } from "./integrations.js";
37
+ import { ConnectError, EXIT_USAGE } from "./connect/errors.js";
38
+ import {
39
+ agentBlocker,
40
+ detectAgents,
41
+ findAgent,
42
+ promptAgentChoice,
43
+ } from "./connect/agents.js";
44
+ import {
45
+ WRITERS,
46
+ renderAgentSnippet,
47
+ renderAllAgentSnippets,
48
+ renderCodexToml,
49
+ verifyWrittenConfig,
50
+ } from "./connect/configs.js";
51
+ import { checkKeyAndPair, refreshStaleCredentials } from "./connect/pairing.js";
52
+ import {
53
+ autoCheckIn,
54
+ lastLine,
55
+ mcpSelfTest,
56
+ pollTicker,
57
+ selfTestBudgetMs,
58
+ waitForConnection,
59
+ } from "./connect/checkin.js";
60
+ import { captureDispatchCredential } from "./connect/dispatch.js";
61
+ import {
62
+ discoverEndpoints,
63
+ guidedFirstRun,
64
+ nextStepsBlock,
65
+ setUpPushTesting,
66
+ whatIsLeftBlock,
67
+ } from "./connect/guide.js";
1522
68
 
1523
69
  // ── Preflight ───────────────────────────────────────────────────────────
1524
70
 
@@ -1637,7 +183,7 @@ export async function connectCommand(commandArgs) {
1637
183
 
1638
184
  if (printOnly) {
1639
185
  if (!agent) agent = await promptAgentChoice(detectAgents());
1640
- const serverConfig = buildServerConfig(args);
186
+ const serverConfig = buildServerConfig(args, { codingAgent: agent.id });
1641
187
  if (agent.id === "codex") {
1642
188
  process.stdout.write(renderCodexToml(serverName, serverConfig));
1643
189
  } else {
@@ -1669,6 +215,7 @@ export async function connectCommand(commandArgs) {
1669
215
  let apiKey = resolveApiKey(args);
1670
216
 
1671
217
  let pairCode = "";
218
+ let pairingId = "";
1672
219
  if (apiKey) {
1673
220
  let pairing = await checkKeyAndPair(args, agent, apiKey);
1674
221
  if (pairing.stale) {
@@ -1679,9 +226,13 @@ export async function connectCommand(commandArgs) {
1679
226
  }
1680
227
  }
1681
228
  pairCode = pairing.pairCode;
229
+ pairingId = pairing.pairingId;
1682
230
  }
1683
231
 
1684
- const serverConfig = buildServerConfig(args, { pairCode });
232
+ const serverConfig = buildServerConfig(args, {
233
+ pairCode,
234
+ codingAgent: agent.id,
235
+ });
1685
236
  const written = WRITERS[agent.id]({ serverName, serverConfig, projectInstall });
1686
237
 
1687
238
  process.stdout.write(
@@ -1695,7 +246,7 @@ export async function connectCommand(commandArgs) {
1695
246
  // Generate hint that matches the actual install location
1696
247
  let hint = agent.snippetHint;
1697
248
  if (agent.id === "cursor" && projectInstall) {
1698
- hint = "merge into .cursor/mcp.json";
249
+ hint = `merge into .cursor/mcp.json, then run: cursor-agent mcp enable ${serverName}`;
1699
250
  } else if (agent.id === "claude_code") {
1700
251
  hint = projectInstall ? "run (with --scope project):" : "run:";
1701
252
  }
@@ -1720,6 +271,7 @@ export async function connectCommand(commandArgs) {
1720
271
  written,
1721
272
  serverConfig,
1722
273
  projectInstall,
274
+ pairingId,
1723
275
  });
1724
276
  // The agent goes back to the caller because `onboard` runs steps after this one
1725
277
  // that need to know which agent to drive, and asking twice is a question we
@@ -1747,6 +299,16 @@ export async function connectCommand(commandArgs) {
1747
299
  }
1748
300
  if (!args.has("--no-guide")) {
1749
301
  await setUpPushTesting(args);
302
+ // Discovery is back inline, alone. What made the old full pass bad was its
303
+ // tail — a runner that fails on its own and a five-minute GitHub poll, after
304
+ // the link had already succeeded. Discovery is the one step PreMan is
305
+ // useless without: with no endpoints there is nothing to test, nothing to
306
+ // schedule and nothing for the push hook just installed above to check. It
307
+ // asks nothing, never throws, and skips itself when the repo is already
308
+ // mapped or no agent can be run.
309
+ await discoverEndpoints(args, agent, serverName, {
310
+ blockedHere: checkIn.blockedHere,
311
+ });
1750
312
  process.stdout.write(whatIsLeftBlock(args));
1751
313
  }
1752
314
  await captureDispatchCredential(args, agent, apiKey, { prompt: false });
@@ -1824,7 +386,7 @@ async function establishCheckIn(
1824
386
  args,
1825
387
  agent,
1826
388
  apiKey,
1827
- { serverName, written, serverConfig, projectInstall = false }
389
+ { serverName, written, serverConfig, projectInstall = false, pairingId = "" }
1828
390
  ) {
1829
391
  const notes = [];
1830
392
  // Both set only when this directory redirects the agent: where it can be
@@ -1855,7 +417,15 @@ async function establishCheckIn(
1855
417
  `so your agent will use ${status.backend_url}.`
1856
418
  );
1857
419
  }
1858
- if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
420
+ if (
421
+ test.ok &&
422
+ status.authenticated &&
423
+ (await waitForConnection(args, apiKey, {
424
+ timeoutMs: 15000,
425
+ expectedAgent: agent.id,
426
+ pairingId,
427
+ }))
428
+ ) {
1859
429
  return done(true);
1860
430
  }
1861
431
  note(
@@ -1913,7 +483,11 @@ async function establishCheckIn(
1913
483
  if (
1914
484
  retry.ok &&
1915
485
  retry.status?.authenticated &&
1916
- (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))
486
+ (await waitForConnection(args, apiKey, {
487
+ timeoutMs: 15000,
488
+ expectedAgent: agent.id,
489
+ pairingId,
490
+ }))
1917
491
  ) {
1918
492
  return done(true);
1919
493
  }
@@ -1938,6 +512,7 @@ async function establishCheckIn(
1938
512
  launch = await autoCheckIn(args, agent, apiKey, {
1939
513
  serverName,
1940
514
  cwd: elsewhere || process.cwd(),
515
+ pairingId,
1941
516
  onPoll: ticker.tick,
1942
517
  onLaunch: ({ mode, terminal }) =>
1943
518
  say(
@@ -1975,7 +550,13 @@ async function establishCheckIn(
1975
550
  "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1976
551
  );
1977
552
 
1978
- if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
553
+ if (
554
+ await waitForConnection(args, apiKey, {
555
+ expectedAgent: agent.id,
556
+ pairingId,
557
+ onPoll: ticker.tick,
558
+ })
559
+ ) {
1979
560
  ticker.end();
1980
561
  return done(true);
1981
562
  }
@@ -1990,3 +571,15 @@ async function establishCheckIn(
1990
571
  );
1991
572
  return done(false);
1992
573
  }
574
+
575
+ // ── Facade ──────────────────────────────────────────────────────────────
576
+
577
+ // Kept so every existing importer -- cli.js, the smoke suites, and the two
578
+ // that reach in by dynamic import -- sees the surface it always did.
579
+
580
+ export { agentBlocker, resolveAgentForPairing, resolveAgentToDrive, runningInside } from "./connect/agents.js";
581
+ export { autoCheckIn, commandLine, headlessCheckIn, interactiveCheckIn, lastLine, mcpSelfTest, openInNewTerminal, selfTestBudgetMs, waitForConnection } from "./connect/checkin.js";
582
+ export { claudeMcpAddArgs, cursorMcpEnableArgs, renderAgentSnippet, renderAllAgentSnippets, renderCodexToml, upsertCodexToml, verifyWrittenConfig, writeClaudeConfig, writeCodexConfig, writeCursorConfig } from "./connect/configs.js";
583
+ export { DISPATCH_HELP, dispatchCommand, extractRoutineId } from "./connect/dispatch.js";
584
+ export { discoverEndpoints, headlessDiscovery, integrationStatus, runnerPairingIsUsable } from "./connect/guide.js";
585
+ export { ConnectError, EXIT_USAGE } from "./connect/errors.js";