premanmcp 0.8.0 → 0.10.0

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
@@ -10,18 +10,24 @@
10
10
  * never fails the connect.
11
11
  */
12
12
 
13
- import { execFileSync, spawnSync } from "node:child_process";
13
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
14
14
  import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
17
 
18
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 { readRunnerState, registerRunner, runnerIsAlive, startBackground } from "./runner.js";
19
23
  import {
24
+ apiKeyIsExplicit,
20
25
  assertOk,
21
26
  authenticateTerminal,
22
27
  backendUrl,
23
28
  buildServerConfig,
24
29
  callBackendJson,
30
+ clearStoredCredentials,
25
31
  cliInvocation,
26
32
  frontendUrl,
27
33
  hasKeyAvailable,
@@ -53,7 +59,11 @@ const AGENTS = [
53
59
  id: "cursor",
54
60
  label: "Cursor",
55
61
  aliases: ["cursor"],
56
- dispatch: { credential: "Cursor API key", needsRoutine: false },
62
+ dispatch: {
63
+ credential: "Cursor API key",
64
+ needsRoutine: false,
65
+ source: "cursor.com/dashboard → Integrations → API Keys",
66
+ },
57
67
  snippetHint: "merge into ~/.cursor/mcp.json",
58
68
  restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
59
69
  },
@@ -61,7 +71,13 @@ const AGENTS = [
61
71
  id: "claude_code",
62
72
  label: "Claude Code",
63
73
  aliases: ["claude", "claude-code", "claude_code", "claudecode"],
64
- dispatch: { credential: "Claude Code routine token", needsRoutine: true },
74
+ dispatch: {
75
+ credential: "Claude Code routine token",
76
+ needsRoutine: true,
77
+ // Named explicitly because "Claude Code routine token" reads like an
78
+ // Anthropic API key, which is a different credential from a different page.
79
+ source: "claude.ai/code/routines → your routine → Add API trigger",
80
+ },
65
81
  snippetHint: "run:",
66
82
  restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
67
83
  },
@@ -97,17 +113,36 @@ function detectAgents() {
97
113
  };
98
114
  }
99
115
 
100
- async function promptAgentChoice(detected) {
116
+ /**
117
+ * The agent whose session this command is running inside, if any.
118
+ *
119
+ * A better default than "first one installed": someone who types this into an
120
+ * agent's terminal almost always means that agent, and on a machine with all
121
+ * three installed the detected-order default is usually wrong.
122
+ */
123
+ export function runningInside(env = process.env) {
124
+ if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
125
+ if (env.CURSOR_TRACE_ID) return "cursor";
126
+ return "";
127
+ }
128
+
129
+ async function promptAgentChoice(detected, insideId = runningInside()) {
101
130
  process.stdout.write("Which coding agent?\n");
102
131
  AGENTS.forEach((agent, index) => {
103
- const mark = detected[agent.id] ? " (detected)" : "";
132
+ let mark = "";
133
+ if (agent.id === insideId) mark = " (this session)";
134
+ else if (detected[agent.id]) mark = " (detected)";
104
135
  process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
105
136
  });
106
137
 
107
- const defaultIndex = Math.max(
108
- 0,
109
- AGENTS.findIndex((a) => detected[a.id])
110
- );
138
+ const inside = AGENTS.findIndex((a) => a.id === insideId);
139
+ const defaultIndex =
140
+ inside >= 0
141
+ ? inside
142
+ : Math.max(
143
+ 0,
144
+ AGENTS.findIndex((a) => detected[a.id])
145
+ );
111
146
  const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
112
147
  if (!answer) return AGENTS[defaultIndex];
113
148
 
@@ -360,19 +395,81 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
360
395
 
361
396
  // ── Pairing ─────────────────────────────────────────────────────────────
362
397
 
398
+ /**
399
+ * Mint the pair code, distinguishing "could not reach it" from "this key is dead".
400
+ *
401
+ * 401 is the backend saying the key is invalid or revoked, which is the one
402
+ * failure the caller must not shrug off: every other outcome still leaves a
403
+ * usable config, but writing a revoked key into an agent's config produces a
404
+ * setup that looks connected and fails on every call.
405
+ */
363
406
  async function startPairing(args, agent, apiKey) {
364
407
  const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
365
408
  token: apiKey,
366
409
  json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
367
410
  });
411
+ if (result.status_code === 401) return { pairCode: "", stale: true };
368
412
  if (!result.ok) {
369
413
  process.stdout.write(
370
414
  `Note: could not start pairing (${result.status_code}); the config still works, ` +
371
415
  "your agent will link on its first PreMan call.\n"
372
416
  );
373
- return "";
417
+ return { pairCode: "", stale: false };
374
418
  }
375
- return String(result.pair_code || "");
419
+ return { pairCode: String(result.pair_code || ""), stale: false };
420
+ }
421
+
422
+ /**
423
+ * The --no-pair stand-in for the auth check pairing performs implicitly.
424
+ *
425
+ * Declining a pair code should not also decline noticing the key is dead,
426
+ * otherwise this flag writes exactly the broken config pairing would catch.
427
+ */
428
+ async function verifyKey(args, apiKey) {
429
+ const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
430
+ return { pairCode: "", stale: result.status_code === 401 };
431
+ }
432
+
433
+ /** Whichever of the two ways to learn the backend still accepts this key. */
434
+ async function checkKeyAndPair(args, agent, apiKey) {
435
+ return args.has("--no-pair")
436
+ ? verifyKey(args, apiKey)
437
+ : startPairing(args, agent, apiKey);
438
+ }
439
+
440
+ /**
441
+ * Replace credentials the backend has rejected, and return the new key.
442
+ *
443
+ * Only stored credentials can be replaced here: an explicit --api-key or
444
+ * PREMAN_API_KEY would just be re-read, so those say so and stop rather than
445
+ * looping. Refusing to continue is deliberate — the alternative is writing a
446
+ * key we know is dead into the agent's config.
447
+ */
448
+ async function refreshStaleCredentials(args) {
449
+ if (apiKeyIsExplicit(args)) {
450
+ const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
451
+ throw new ConnectError(
452
+ `The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
453
+ `dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
454
+ EXIT_USAGE
455
+ );
456
+ }
457
+ if (args.has("--skip-login") || !process.stdin.isTTY) {
458
+ throw new ConnectError(
459
+ "Saved PreMan credentials are invalid or revoked. Run " +
460
+ `'${cliInvocation()} logout', then connect again to sign in.`,
461
+ EXIT_USAGE
462
+ );
463
+ }
464
+
465
+ process.stdout.write(
466
+ "\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
467
+ "account behind it was removed. Signing you in again.\n"
468
+ );
469
+ clearStoredCredentials();
470
+ await authenticateTerminal(args);
471
+ process.stdout.write("\n");
472
+ return resolveApiKey(args);
376
473
  }
377
474
 
378
475
  export async function waitForConnection(
@@ -381,6 +478,7 @@ export async function waitForConnection(
381
478
  {
382
479
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
383
480
  timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
481
+ stopWhen = null,
384
482
  } = {}
385
483
  ) {
386
484
  const deadline = Date.now() + timeoutMs;
@@ -396,6 +494,7 @@ export async function waitForConnection(
396
494
  token: apiKey,
397
495
  });
398
496
  if (status.ok && status.connected) return true;
497
+ if (stopWhen && stopWhen()) return false;
399
498
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
400
499
  }
401
500
  } finally {
@@ -404,9 +503,247 @@ export async function waitForConnection(
404
503
  return false;
405
504
  }
406
505
 
506
+ // ── Auto check-in ───────────────────────────────────────────────────────
507
+
508
+ /**
509
+ * How to ask each agent to make one PreMan call without opening its UI.
510
+ *
511
+ * The link is established by the agent's first call, so anything that reaches
512
+ * `preman_status` finishes the connect. Claude Code needs the server on its
513
+ * allow-list because print mode refuses un-allowed MCP tools rather than
514
+ * prompting.
515
+ */
516
+ export function headlessCheckIn(agent, serverName) {
517
+ const prompt = `Call the ${serverName} MCP tool preman_status and report the result.`;
518
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
519
+ if (agent.id === "claude_code") {
520
+ return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
521
+ }
522
+ if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
523
+ return null;
524
+ }
525
+
526
+ /**
527
+ * Finish the link ourselves instead of asking the user to go restart their agent.
528
+ *
529
+ * The config on disk is already correct at this point; all that is missing is
530
+ * one call from the agent, and telling someone to make it from another terminal
531
+ * is a dead end in the one terminal they are sitting in. So run the agent
532
+ * headlessly and poll for the check-in it produces.
533
+ *
534
+ * Returns `ran: false` when the agent's binary is absent or will not start, and
535
+ * the caller falls back to the printed instructions.
536
+ */
537
+ export async function autoCheckIn(
538
+ args,
539
+ agent,
540
+ apiKey,
541
+ {
542
+ // Never outlast the connect's own wait budget: this phase is part of it, not
543
+ // an extra one bolted on the front.
544
+ timeoutMs = Math.min(
545
+ Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
546
+ Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
547
+ ),
548
+ serverName = "preman",
549
+ intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
550
+ } = {}
551
+ ) {
552
+ const spec = headlessCheckIn(agent, serverName);
553
+ if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
554
+ if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
555
+
556
+ let child;
557
+ try {
558
+ // Piped rather than ignored: an agent that runs and does not check in used to
559
+ // report exactly that and nothing else, which is the least useful sentence
560
+ // available. Its own last words usually name the cause.
561
+ child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
562
+ } catch (error) {
563
+ return { ran: false, connected: false, reason: error.message };
564
+ }
565
+
566
+ let spawnError = null;
567
+ let exitedAt = 0;
568
+ let output = "";
569
+ const absorb = (chunk) => {
570
+ output = `${output}${chunk}`.slice(-4000);
571
+ };
572
+ child.stdout?.setEncoding("utf8");
573
+ child.stderr?.setEncoding("utf8");
574
+ child.stdout?.on("data", absorb);
575
+ child.stderr?.on("data", absorb);
576
+ child.on("error", (error) => {
577
+ spawnError = error;
578
+ exitedAt = exitedAt || Date.now();
579
+ });
580
+ child.on("exit", () => {
581
+ exitedAt = exitedAt || Date.now();
582
+ });
583
+
584
+ // The check-in can land moments after the agent's own process ends, so keep
585
+ // polling briefly past its exit rather than declaring failure at the edge.
586
+ const grace = intervalMs * 2;
587
+ try {
588
+ const connected = await waitForConnection(args, apiKey, {
589
+ intervalMs,
590
+ timeoutMs,
591
+ stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
592
+ });
593
+ if (spawnError && !connected) {
594
+ return { ran: false, connected: false, reason: spawnError.message, output };
595
+ }
596
+ return { ran: true, connected, command: spec.bin, output };
597
+ } finally {
598
+ if (child.exitCode === null && child.signalCode === null) child.kill();
599
+ }
600
+ }
601
+
602
+ /** The last non-empty line of an agent's output, for a one-line diagnosis. */
603
+ export function lastLine(output, cap = 200) {
604
+ const lines = String(output || "")
605
+ .split("\n")
606
+ .map((line) => line.trim())
607
+ .filter(Boolean);
608
+ return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
609
+ }
610
+
611
+ // ── MCP self-test ───────────────────────────────────────────────────────
612
+
613
+ /**
614
+ * How long to give the self-test, and whether to run it at all.
615
+ *
616
+ * `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
617
+ * run wants: the launcher is `npm exec premanmcp@latest`, so every case that
618
+ * reaches this would otherwise go to the registry.
619
+ */
620
+ export function selfTestBudgetMs() {
621
+ const raw = process.env.PREMAN_SELFTEST_MS;
622
+ if (raw === undefined || raw === "") return 60000;
623
+ const parsed = Number(raw);
624
+ return Number.isFinite(parsed) ? parsed : 60000;
625
+ }
626
+
627
+ /**
628
+ * Run the config we just wrote, exactly as the agent will, and call one tool.
629
+ *
630
+ * This is both the fastest way to finish the link and the only check that proves
631
+ * the whole chain — launcher, package, key, backend — rather than proving the
632
+ * file is on disk. It matters because the failure it catches is invisible
633
+ * otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
634
+ * redirect the server to a backend the key was never issued for, and every
635
+ * symptom of that points at the key.
636
+ *
637
+ * Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
638
+ */
639
+ export async function mcpSelfTest(
640
+ serverConfig,
641
+ { timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
642
+ ) {
643
+ if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
644
+ let child;
645
+ try {
646
+ child = spawn(serverConfig.command, serverConfig.args, {
647
+ cwd,
648
+ env: { ...process.env, ...serverConfig.env },
649
+ stdio: ["pipe", "pipe", "pipe"],
650
+ });
651
+ } catch (error) {
652
+ return { ok: false, reason: error.message };
653
+ }
654
+
655
+ return new Promise((resolve) => {
656
+ let stdout = "";
657
+ let stderr = "";
658
+ let settled = false;
659
+
660
+ const finish = (result) => {
661
+ if (settled) return;
662
+ settled = true;
663
+ clearTimeout(timer);
664
+ if (child.exitCode === null && child.signalCode === null) child.kill();
665
+ resolve(result);
666
+ };
667
+
668
+ const timer = setTimeout(
669
+ () => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
670
+ timeoutMs
671
+ );
672
+
673
+ const send = (message) => {
674
+ try {
675
+ child.stdin.write(`${JSON.stringify(message)}\n`);
676
+ } catch (error) {
677
+ finish({ ok: false, reason: error.message });
678
+ }
679
+ };
680
+
681
+ child.on("error", (error) => finish({ ok: false, reason: error.message }));
682
+ child.on("exit", (code) =>
683
+ finish({
684
+ ok: false,
685
+ reason: `the MCP server exited with code ${code}`,
686
+ stderr: lastLine(stderr),
687
+ })
688
+ );
689
+
690
+ child.stdout.setEncoding("utf8");
691
+ child.stderr.setEncoding("utf8");
692
+ child.stderr.on("data", (chunk) => {
693
+ stderr = `${stderr}${chunk}`.slice(-4000);
694
+ });
695
+ child.stdout.on("data", (chunk) => {
696
+ stdout += chunk;
697
+ let newline = stdout.indexOf("\n");
698
+ while (newline !== -1) {
699
+ const line = stdout.slice(0, newline).trim();
700
+ stdout = stdout.slice(newline + 1);
701
+ newline = stdout.indexOf("\n");
702
+ if (!line.startsWith("{")) continue;
703
+ let message;
704
+ try {
705
+ message = JSON.parse(line);
706
+ } catch {
707
+ continue;
708
+ }
709
+ if (message.id === 1) {
710
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
711
+ send({
712
+ jsonrpc: "2.0",
713
+ id: 2,
714
+ method: "tools/call",
715
+ params: { name: "preman_status", arguments: {} },
716
+ });
717
+ continue;
718
+ }
719
+ if (message.id !== 2) continue;
720
+ const text = message.result?.content?.[0]?.text;
721
+ let status = {};
722
+ try {
723
+ status = text ? JSON.parse(text) : {};
724
+ } catch {
725
+ status = { raw: text };
726
+ }
727
+ finish({ ok: !message.error, status, reason: message.error?.message || "" });
728
+ }
729
+ });
730
+
731
+ send({
732
+ jsonrpc: "2.0",
733
+ id: 1,
734
+ method: "initialize",
735
+ params: {
736
+ protocolVersion: "2024-11-05",
737
+ capabilities: {},
738
+ clientInfo: { name: "preman-connect-selftest", version: "1" },
739
+ },
740
+ });
741
+ });
742
+ }
743
+
407
744
  // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
408
745
 
409
- async function captureDispatchCredential(args, agent, apiKey) {
746
+ async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
410
747
  if (!agent.dispatch) return;
411
748
  if (args.has("--skip-dispatch-credential")) return;
412
749
 
@@ -414,19 +751,20 @@ async function captureDispatchCredential(args, agent, apiKey) {
414
751
  let routineId = args.value("--routine-id", "");
415
752
 
416
753
  if (!secret) {
417
- if (!process.stdin.isTTY) return;
754
+ if (!prompt || !process.stdin.isTTY) return;
418
755
  // Framed as the expected step rather than an optional aside. Without it
419
756
  // PreMan can only suggest fixes; with it, it can run them. Presenting it as
420
757
  // "optional, press Enter to skip" meant almost everyone skipped the thing
421
758
  // that makes the product act rather than advise.
422
759
  process.stdout.write(
423
- `\nLet PreMan start ${agent.label} runs for you — it can then apply fixes and\n` +
424
- `run checks on a schedule instead of only telling you what to do.\n`
760
+ `\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
761
+ `fixes and run checks on a schedule instead of only telling you what to do.\n` +
762
+ `Get the token from ${agent.dispatch.source}.\n`
425
763
  );
426
- secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to set up later): `);
764
+ secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
427
765
  if (!secret) {
428
766
  process.stdout.write(
429
- `Skipped. Run '${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
767
+ `Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
430
768
  );
431
769
  return;
432
770
  }
@@ -458,6 +796,64 @@ async function captureDispatchCredential(args, agent, apiKey) {
458
796
  }
459
797
  }
460
798
 
799
+ export const DISPATCH_HELP = `
800
+ Dispatch options:
801
+ --agent <name> cursor | claude-code (defaults to the connected agent)
802
+ --dispatch-credential <t> Cloud-dispatch token (non-interactive)
803
+ --routine-id <id> Claude Code routine id, with --dispatch-credential
804
+ --api-key <key> PreMan API key. If omitted, stored credentials are used
805
+ --backend <url> PreMan backend URL
806
+ `;
807
+
808
+ /**
809
+ * `preman dispatch` — store the cloud-dispatch credential on its own.
810
+ *
811
+ * Exists so connect never has to ask for a token mid-onboarding. Someone who
812
+ * skipped it (or did not have it yet) comes back here instead of re-running the
813
+ * whole connect.
814
+ */
815
+ export async function dispatchCommand(commandArgs) {
816
+ const args = makeArgs(commandArgs);
817
+ const apiKey = resolveApiKey(args);
818
+ if (!apiKey) {
819
+ throw new ConnectError(
820
+ `No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
821
+ EXIT_USAGE
822
+ );
823
+ }
824
+
825
+ let agent = findAgent(args.value("--agent", ""));
826
+ if (!agent && args.value("--agent", "")) {
827
+ throw new ConnectError(
828
+ `Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
829
+ EXIT_USAGE
830
+ );
831
+ }
832
+
833
+ if (!agent) {
834
+ const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
835
+ agent = findAgent(current.ok ? current.agent : "");
836
+ if (!agent) {
837
+ throw new ConnectError(
838
+ "Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
839
+ EXIT_USAGE
840
+ );
841
+ }
842
+ }
843
+
844
+ if (!agent.dispatch) {
845
+ throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
846
+ }
847
+ if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
848
+ throw new ConnectError(
849
+ "preman dispatch needs a terminal, or --dispatch-credential <token>.",
850
+ EXIT_USAGE
851
+ );
852
+ }
853
+
854
+ await captureDispatchCredential(args, agent, apiKey);
855
+ }
856
+
461
857
  /** Accept either a bare trig_… id or the routine URL it appears in. */
462
858
  export function extractRoutineId(value) {
463
859
  const raw = String(value || "").trim();
@@ -476,81 +872,381 @@ function nextStepsBlock(agent) {
476
872
  return (
477
873
  "\nNext steps:\n" +
478
874
  ` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
479
- " 2. preman endpoints discover # brief for your agent → endpoints.json\n" +
480
- " 3. preman endpoints setup --file endpoints.json # register runnable requests\n" +
481
- " 4. preman test <request-id> # generate + run your first scenarios\n"
875
+ ` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
876
+ ` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
877
+ ` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
482
878
  );
483
879
  }
484
880
 
485
- async function confirm(question) {
486
- const answer = (await promptText(`${question} [Y/n]: `)).toLowerCase();
487
- return answer === "" || answer.startsWith("y");
881
+ async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
882
+ if (assumeYes) return true;
883
+ if (!process.stdin.isTTY) return false;
884
+ const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
885
+ if (answer === "") return defaultYes;
886
+ return answer.startsWith("y");
887
+ }
888
+
889
+ function step(title) {
890
+ process.stdout.write(`\n${title}\n`);
891
+ }
892
+
893
+ /** What the account can see right now: registry rows, and the runnable subset. */
894
+ async function endpointCounts(args) {
895
+ const inventory = await callPremanTool(args, "get_endpoints", {
896
+ include_workbench: true,
897
+ limit: 200,
898
+ });
899
+ const runnable = inventory.workbench_requests || [];
900
+ return {
901
+ registered: (inventory.endpoints || []).length,
902
+ runnable: runnable.length,
903
+ first: runnable[0] || null,
904
+ };
488
905
  }
489
906
 
490
907
  /**
491
- * Carry a freshly linked agent to its first passing test.
908
+ * The one-shot command that has an agent execute the discovery brief.
492
909
  *
493
- * Discovery itself belongs to the coding agent — the backend hands back a brief
494
- * for it to execute — so this either runs a test against what the account
495
- * already has, or prints that brief and the two commands that follow it.
910
+ * Same shape as `headlessCheckIn`, with the read tools the walk needs added to
911
+ * Claude Code's allow-list: print mode refuses anything un-allowed instead of
912
+ * asking, so an agent given only the MCP server can call PreMan and read nothing.
913
+ */
914
+ export function headlessDiscovery(agent, serverName, instructions = []) {
915
+ const prompt = [
916
+ `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
917
+ "Work autonomously and do not ask for confirmation.",
918
+ "",
919
+ ...instructions,
920
+ ].join("\n");
921
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
922
+ if (agent.id === "claude_code") {
923
+ return {
924
+ bin: "claude",
925
+ args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
926
+ };
927
+ }
928
+ if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
929
+ return null;
930
+ }
931
+
932
+ /**
933
+ * Get this project's endpoints into PreMan without handing anyone homework.
496
934
  *
497
- * Never throws: onboarding help must not turn a successful connect into a failure.
935
+ * Discovery is the agent's job — only it can read the repo — but "here is a brief,
936
+ * go paste it somewhere" is the step everybody dropped out on, and the commands
937
+ * printed after it referenced a file the user never had. So run the agent on the
938
+ * brief and report the two numbers that matter.
939
+ *
940
+ * Never throws: a failed discovery must not fail the connect.
498
941
  */
499
- async function guidedFirstRun(args, agent) {
942
+ async function discoverEndpoints(args, agent, serverName) {
500
943
  try {
501
- const inventory = await callPremanTool(args, "get_endpoints", {
502
- include_workbench: true,
503
- limit: 50,
504
- });
505
- const runnable = (inventory.workbench_requests || [])[0];
506
- const registered = (inventory.endpoints || []).length;
507
-
508
- if (runnable) {
509
- const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
510
- if (!(await confirm(`\nRun a first test against ${label}?`))) {
511
- process.stdout.write(`Whenever you are ready: preman test ${runnable.id}\n`);
512
- return;
513
- }
514
- process.stdout.write("Generating scenarios…\n");
515
- const result = await callPremanTool(args, "generate_endpoint_tests", {
516
- target: runnable.id,
517
- run: true,
518
- allow_writes: false,
519
- max_cases: 10,
520
- });
521
- printTestSummary(result);
522
- process.stdout.write(`\nAdd your own: preman test ${runnable.id} --scenario "..."\n`);
523
- return;
944
+ const before = await endpointCounts(args);
945
+ if (before.registered) {
946
+ process.stdout.write(
947
+ `${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
948
+ );
949
+ return before;
524
950
  }
525
951
 
526
- if (registered) {
952
+ const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
953
+ const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
954
+ if (!spec || !onPath(spec.bin)) {
955
+ for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
956
+ process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
957
+ return before;
958
+ }
959
+
960
+ process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
961
+ const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
962
+ const outcome = await runAgentOnce(spec, timeoutMs);
963
+ const after = await endpointCounts(args);
964
+
965
+ if (!after.registered) {
966
+ const why = outcome.reason || lastLine(outcome.output) || "it registered nothing";
527
967
  process.stdout.write(
528
- `\nYou have ${registered} registered endpoint(s), but none are runnable yet:\n` +
529
- " preman endpoints setup --ids <id1,id2> # make them runnable\n" +
530
- " preman test <request-id> # generate + run your first scenarios\n"
968
+ `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
969
+ ` Run it yourself: ${cliInvocation()} endpoints discover\n`
531
970
  );
532
- return;
971
+ return after;
533
972
  }
973
+ process.stdout.write(
974
+ `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
975
+ );
976
+ return after;
977
+ } catch (error) {
978
+ process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
979
+ return { registered: 0, runnable: 0, first: null };
980
+ }
981
+ }
534
982
 
535
- if (!(await confirm("\nNo endpoints in PreMan yet. Print the discovery brief for your agent?"))) {
536
- process.stdout.write(`\nWhen you are ready:\n${MANUAL_STEPS}`);
983
+ /** Run one bounded, non-interactive agent invocation and keep its tail. */
984
+ async function runAgentOnce(spec, timeoutMs) {
985
+ return new Promise((resolve) => {
986
+ let child;
987
+ try {
988
+ child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
989
+ } catch (error) {
990
+ resolve({ ok: false, reason: error.message, output: "" });
537
991
  return;
538
992
  }
993
+ let output = "";
994
+ const absorb = (chunk) => {
995
+ output = `${output}${chunk}`.slice(-8000);
996
+ };
997
+ child.stdout?.setEncoding("utf8");
998
+ child.stderr?.setEncoding("utf8");
999
+ child.stdout?.on("data", absorb);
1000
+ child.stderr?.on("data", absorb);
1001
+
1002
+ const timer = setTimeout(() => {
1003
+ child.kill("SIGTERM");
1004
+ resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
1005
+ }, timeoutMs);
1006
+ child.on("error", (error) => {
1007
+ clearTimeout(timer);
1008
+ resolve({ ok: false, reason: error.message, output });
1009
+ });
1010
+ child.on("close", (code) => {
1011
+ clearTimeout(timer);
1012
+ resolve({
1013
+ ok: code === 0,
1014
+ reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
1015
+ output,
1016
+ });
1017
+ });
1018
+ });
1019
+ }
539
1020
 
540
- const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
541
- for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
1021
+ /** Generate and run scenarios against the first runnable request, if they want it. */
1022
+ async function runFirstTest(args, runnable, { assumeYes }) {
1023
+ const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
1024
+ if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
1025
+ process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
1026
+ return;
1027
+ }
1028
+ try {
1029
+ process.stdout.write("Generating scenarios…\n");
1030
+ const result = await callPremanTool(args, "generate_endpoint_tests", {
1031
+ target: runnable.id,
1032
+ run: true,
1033
+ allow_writes: false,
1034
+ max_cases: 10,
1035
+ });
1036
+ printTestSummary(result);
1037
+ } catch (error) {
1038
+ process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Pair this machine as a runner and leave it running.
1044
+ *
1045
+ * Without this, everything PreMan finds later is advice: it can describe a fix
1046
+ * and cannot apply one. With it, the queue has somewhere to land.
1047
+ */
1048
+ async function setUpRunner(args, agent, { assumeYes }) {
1049
+ if (args.has("--no-runner")) return { state: "skipped" };
1050
+ const existing = readRunnerState();
1051
+ if (existing && runnerIsAlive()) {
1052
+ process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
1053
+ return { state: "running" };
1054
+ }
1055
+ if (
1056
+ !(await confirm(
1057
+ `Let PreMan run ${agent.label} here when it finds something to fix?`,
1058
+ { assumeYes }
1059
+ ))
1060
+ ) {
542
1061
  process.stdout.write(
543
- `\nHand this brief to ${agent.label}, then run:\n` +
544
- " preman endpoints setup --file endpoints.json\n" +
545
- " preman test <request-id>\n"
1062
+ `${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
546
1063
  );
1064
+ return { state: "skipped" };
1065
+ }
1066
+
1067
+ try {
1068
+ if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
1069
+ await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1070
+ }
1071
+ const started = startBackground([]);
1072
+ process.stdout.write(
1073
+ `${MARK.ok()} Runner running (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1074
+ ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1075
+ );
1076
+ return { state: "running", pid: started.pid };
547
1077
  } catch (error) {
548
1078
  process.stdout.write(
549
- `Note: ${error.message}. Run \`preman endpoints list\` when you are ready.\n`
1079
+ `${MARK.fail()} Could not start the runner: ${error.message}\n` +
1080
+ ` Retry later: ${cliInvocation()} runner start --background\n`
550
1081
  );
1082
+ return { state: "failed", detail: error.message };
551
1083
  }
552
1084
  }
553
1085
 
1086
+ /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
1087
+ async function offerDesktop(args, { assumeYes }) {
1088
+ if (args.has("--no-desktop")) return { state: "skipped" };
1089
+ if (process.platform !== "darwin") return { state: "unsupported" };
1090
+ if (existsSync("/Applications/PreMan.app")) {
1091
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
1092
+ return { state: "installed" };
1093
+ }
1094
+ // Default no, unlike every other step here: this one downloads a hundred-odd
1095
+ // megabytes and writes to /Applications, which nobody should get by pressing
1096
+ // Enter to move past a prompt.
1097
+ if (
1098
+ !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
1099
+ assumeYes,
1100
+ defaultYes: false,
1101
+ }))
1102
+ ) {
1103
+ process.stdout.write(
1104
+ `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
1105
+ );
1106
+ return { state: "skipped" };
1107
+ }
1108
+ try {
1109
+ return await installDesktopCommand([]);
1110
+ } catch (error) {
1111
+ process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
1112
+ return { state: "failed", detail: error.message };
1113
+ }
1114
+ }
1115
+
1116
+ /**
1117
+ * Which of GitHub, AWS and Slack this account already has.
1118
+ *
1119
+ * One probe per provider because each owns its own list route; a provider whose
1120
+ * route errors is reported as unknown rather than missing, so a backend hiccup
1121
+ * does not send someone through an install they already did.
1122
+ */
1123
+ export async function integrationStatus(args, apiKey) {
1124
+ const probe = async (method, route, pick) => {
1125
+ try {
1126
+ const result = await callBackendJson(args, method, route, { token: apiKey });
1127
+ if (!result.ok) return null;
1128
+ return (pick(result) || []).length;
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ };
1133
+ const [github, aws, slack] = await Promise.all([
1134
+ probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
1135
+ probe("GET", "/aws-links", (r) => r.links || r.list),
1136
+ probe("GET", "/slack/connections", (r) => r.connections || r.list),
1137
+ ]);
1138
+ return { github, aws, slack };
1139
+ }
1140
+
1141
+ /**
1142
+ * Show what is connected, and offer to finish what is not.
1143
+ *
1144
+ * Each install lives in the customer's browser session, so every one of these
1145
+ * opens a URL and polls our own API until the other side reports the connection
1146
+ * exists — the terminal picks the result up on its own, whether they finished in
1147
+ * the browser or in the desktop app.
1148
+ */
1149
+ async function connectIntegrations(args, apiKey, { assumeYes }) {
1150
+ if (args.has("--no-integrations")) return;
1151
+ const status = await integrationStatus(args, apiKey);
1152
+ const providers = [
1153
+ { key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
1154
+ { key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
1155
+ { key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
1156
+ ];
1157
+
1158
+ for (const provider of providers) {
1159
+ const count = status[provider.key];
1160
+ if (count === null) {
1161
+ process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
1162
+ } else if (count > 0) {
1163
+ process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
1164
+ } else {
1165
+ process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
1166
+ }
1167
+ }
1168
+
1169
+ for (const provider of providers) {
1170
+ if (status[provider.key] !== 0) continue;
1171
+ if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
1172
+ try {
1173
+ await provider.run();
1174
+ } catch (error) {
1175
+ // One provider's failure must not cost the customer the ones that worked.
1176
+ process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ /** Install the pre-push hook, so a push is what triggers the tests. */
1182
+ async function setUpPushTesting(args, { assumeYes }) {
1183
+ if (args.has("--no-hook")) return { state: "skipped" };
1184
+ const current = (() => {
1185
+ try {
1186
+ return hookStatus();
1187
+ } catch {
1188
+ return null; // not a git repository
1189
+ }
1190
+ })();
1191
+ if (!current) {
1192
+ process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1193
+ return { state: "unavailable" };
1194
+ }
1195
+ if (current.state === "installed") {
1196
+ process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1197
+ return { state: "installed" };
1198
+ }
1199
+ if (!(await confirm("Test the endpoints you touched on every git push?", { assumeYes }))) {
1200
+ process.stdout.write(`${MARK.skip()} Skipped. Turn it on: ${cliInvocation()} hook install\n`);
1201
+ return { state: "skipped" };
1202
+ }
1203
+
1204
+ const result = installHook(args);
1205
+ if (result.action === "conflict") {
1206
+ process.stdout.write(
1207
+ `${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
1208
+ );
1209
+ return result;
1210
+ }
1211
+ process.stdout.write(
1212
+ `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1213
+ " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
1214
+ );
1215
+ return result;
1216
+ }
1217
+
1218
+ /**
1219
+ * Everything after the link, in one pass, with nothing left for the user to run.
1220
+ *
1221
+ * Order follows what a new account needs to see: what PreMan found, then where to
1222
+ * watch it, then who to tell, then when to run it.
1223
+ */
1224
+ async function guidedFirstRun(args, agent, apiKey, serverName) {
1225
+ const assumeYes = args.has("--yes");
1226
+
1227
+ step("Endpoints");
1228
+ const counts = await discoverEndpoints(args, agent, serverName);
1229
+
1230
+ if (counts.first) {
1231
+ step("First test");
1232
+ await runFirstTest(args, counts.first, { assumeYes });
1233
+ }
1234
+
1235
+ step("Runner");
1236
+ await setUpRunner(args, agent, { assumeYes });
1237
+
1238
+ step("Desktop app");
1239
+ await offerDesktop(args, { assumeYes });
1240
+
1241
+ step("Integrations");
1242
+ await connectIntegrations(args, apiKey, { assumeYes });
1243
+
1244
+ step("Testing on push");
1245
+ await setUpPushTesting(args, { assumeYes });
1246
+
1247
+ process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
1248
+ }
1249
+
554
1250
  // ── Preflight ───────────────────────────────────────────────────────────
555
1251
 
556
1252
  const MIN_NODE_MAJOR = 18;
@@ -602,6 +1298,7 @@ Connect options:
602
1298
  --project Write project-local config instead of the user config
603
1299
  --api-key <key> PreMan API key. If omitted, stored credentials are used
604
1300
  --email <email> Pre-fill the email prompt when logging in
1301
+ --password [value] Also set a dashboard password (signup asks for none)
605
1302
  --backend <url> PreMan backend URL
606
1303
  --frontend <url> PreMan frontend URL
607
1304
  --name <name> MCP server name. Defaults to preman
@@ -610,8 +1307,15 @@ Connect options:
610
1307
  --skip-dispatch-credential Do not ask for a cloud-dispatch credential
611
1308
  --skip-login Write config without interactive terminal auth
612
1309
  --no-pair Do not mint a pair code
1310
+ --no-self-test Do not start the MCP server to finish the link
1311
+ --no-auto-checkin Do not run the agent to finish the link
613
1312
  --no-wait Do not wait for the agent to check in
614
1313
  --no-guide Skip the guided first run after connecting
1314
+ --no-runner Do not pair this machine as a job runner
1315
+ --no-desktop Do not offer the desktop app
1316
+ --no-integrations Do not check or offer GitHub / AWS / Slack
1317
+ --no-hook Do not install the git pre-push hook
1318
+ --yes Accept every optional step without prompting
615
1319
  --print Print the config instead of writing it
616
1320
  `;
617
1321
 
@@ -630,31 +1334,23 @@ export async function connectCommand(commandArgs) {
630
1334
  );
631
1335
  }
632
1336
 
633
- if (!agent) {
634
- if (!interactive) {
635
- // Nothing to prompt on, so leave behind everything a CI log needs to
636
- // finish the setup by hand rather than just the reason it stopped.
637
- process.stdout.write(
638
- `preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
639
- 'Then restart your agent and ask it: "run preman_status".\n' +
640
- "Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
641
- );
642
- throw new ConnectError(
643
- "preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
644
- "and --api-key pm_live_… (or --print), or use one of the snippets above.",
645
- EXIT_USAGE
646
- );
647
- }
648
- agent = await promptAgentChoice(detectAgents());
649
- }
650
-
651
- if (!printOnly) {
652
- // Verify the machine can run what we are about to write — before any
653
- // config edits or logins, so failures leave nothing half-done.
654
- await preflight(args);
1337
+ // Nothing to prompt on, so leave behind everything a CI log needs to finish
1338
+ // the setup by hand rather than just the reason it stopped.
1339
+ if (!agent && !interactive) {
1340
+ process.stdout.write(
1341
+ `preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
1342
+ 'Then restart your agent and ask it: "run preman_status".\n' +
1343
+ "Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
1344
+ );
1345
+ throw new ConnectError(
1346
+ "preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
1347
+ "and --api-key pm_live_… (or --print), or use one of the snippets above.",
1348
+ EXIT_USAGE
1349
+ );
655
1350
  }
656
1351
 
657
1352
  if (printOnly) {
1353
+ if (!agent) agent = await promptAgentChoice(detectAgents());
658
1354
  const serverConfig = buildServerConfig(args);
659
1355
  if (agent.id === "codex") {
660
1356
  process.stdout.write(renderCodexToml(serverName, serverConfig));
@@ -664,6 +1360,12 @@ export async function connectCommand(commandArgs) {
664
1360
  return;
665
1361
  }
666
1362
 
1363
+ // Verify the machine can run what we are about to write — before any config
1364
+ // edits or logins, so failures leave nothing half-done.
1365
+ await preflight(args);
1366
+
1367
+ // Account first, so "First, let's connect your PreMan account" is not the
1368
+ // second thing that happens.
667
1369
  if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
668
1370
  if (!interactive) {
669
1371
  throw new ConnectError(
@@ -676,11 +1378,21 @@ export async function connectCommand(commandArgs) {
676
1378
  process.stdout.write("\n");
677
1379
  }
678
1380
 
679
- const apiKey = resolveApiKey(args);
1381
+ if (!agent) agent = await promptAgentChoice(detectAgents());
1382
+
1383
+ let apiKey = resolveApiKey(args);
680
1384
 
681
1385
  let pairCode = "";
682
- if (apiKey && !args.has("--no-pair")) {
683
- pairCode = await startPairing(args, agent, apiKey);
1386
+ if (apiKey) {
1387
+ let pairing = await checkKeyAndPair(args, agent, apiKey);
1388
+ if (pairing.stale) {
1389
+ apiKey = await refreshStaleCredentials(args);
1390
+ pairing = await checkKeyAndPair(args, agent, apiKey);
1391
+ if (pairing.stale) {
1392
+ throw new ConnectError("PreMan rejected a key it just issued. Try again shortly.");
1393
+ }
1394
+ }
1395
+ pairCode = pairing.pairCode;
684
1396
  }
685
1397
 
686
1398
  const serverConfig = buildServerConfig(args, { pairCode });
@@ -708,32 +1420,105 @@ export async function connectCommand(commandArgs) {
708
1420
  );
709
1421
  }
710
1422
 
711
- // Not gated on TTY: --dispatch-credential is the non-interactive path, and the
712
- // prompt inside only runs when there is a terminal to prompt on.
713
- await captureDispatchCredential(args, agent, apiKey);
714
-
1423
+ // A non-interactive run can still be handed the credential up front, so this
1424
+ // stays reachable; the prompt inside only fires when there is a TTY, and by
1425
+ // then onboarding is done.
715
1426
  if (!pairCode || args.has("--no-wait") || !interactive) {
1427
+ await captureDispatchCredential(args, agent, apiKey);
716
1428
  process.stdout.write(nextStepsBlock(agent));
717
1429
  return;
718
1430
  }
719
1431
 
1432
+ if (!(await establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }))) {
1433
+ // Still honour an explicitly-passed credential, but do not open a new prompt
1434
+ // on top of a connect that just told the user something went wrong.
1435
+ await captureDispatchCredential(args, agent, apiKey, { prompt: false });
1436
+ return;
1437
+ }
1438
+
1439
+ process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
1440
+ if (!args.has("--no-guide")) {
1441
+ await guidedFirstRun(args, agent, apiKey, serverName);
1442
+ }
1443
+ await captureDispatchCredential(args, agent, apiKey);
1444
+ }
1445
+
1446
+ /**
1447
+ * Finish the link here, by whatever means work, in cheapest-first order.
1448
+ *
1449
+ * 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
1450
+ * seconds, and it proves the launcher/key/backend chain the agent will use.
1451
+ * 2. Failing that, run the agent headlessly — which also proves the agent can
1452
+ * load the config we wrote.
1453
+ * 3. Failing that, ask them to restart it and wait, which is all this ever did.
1454
+ *
1455
+ * Returns whether the check-in landed, and prints the troubleshooting block
1456
+ * itself when it did not.
1457
+ */
1458
+ async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
1459
+ const notes = [];
1460
+
1461
+ if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
1462
+ process.stdout.write("\nChecking the connection…\n");
1463
+ const test = await mcpSelfTest(serverConfig);
1464
+ const status = test.status || {};
1465
+ const repo = status.config?.repo_config;
1466
+ if (repo?.override && repo.applied?.length) {
1467
+ // The one failure that reads as a bad key: a working server talking to a
1468
+ // backend nobody chose. Name the file before it costs anyone an hour.
1469
+ notes.push(
1470
+ `${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
1471
+ `so your agent will use ${status.backend_url}.`
1472
+ );
1473
+ }
1474
+ if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
1475
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1476
+ return true;
1477
+ }
1478
+ notes.push(
1479
+ test.ok
1480
+ ? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
1481
+ : `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
1482
+ );
1483
+ }
1484
+
1485
+ if (!args.has("--no-auto-checkin")) {
1486
+ const spec = headlessCheckIn(agent, serverName);
1487
+ if (spec && onPath(spec.bin)) {
1488
+ process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
1489
+ }
1490
+ const auto = await autoCheckIn(args, agent, apiKey, { serverName });
1491
+ if (auto.connected) {
1492
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1493
+ return true;
1494
+ }
1495
+ if (auto.ran) {
1496
+ const why = lastLine(auto.output);
1497
+ process.stdout.write(
1498
+ `${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
1499
+ );
1500
+ } else if (auto.reason) {
1501
+ process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
1502
+ }
1503
+ }
1504
+
720
1505
  process.stdout.write(
721
1506
  `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
722
1507
  "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
723
1508
  );
724
1509
 
725
- if (!(await waitForConnection(args, apiKey))) {
726
- process.stdout.write(
727
- "No check-in yet. Troubleshooting:\n" +
728
- ` - ${agent.restartHint}\n` +
729
- ` - Config written to: ${written.path}\n` +
730
- ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n`
731
- );
732
- return;
1510
+ if (await waitForConnection(args, apiKey)) {
1511
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1512
+ return true;
733
1513
  }
734
1514
 
735
- process.stdout.write(`Connected as ${agent.label}.\n`);
736
- if (!args.has("--no-guide")) {
737
- await guidedFirstRun(args, agent);
738
- }
1515
+ process.stdout.write(
1516
+ "No check-in yet. Troubleshooting:\n" +
1517
+ notes.map((note) => ` - ${note}\n`).join("") +
1518
+ ` - ${agent.restartHint}\n` +
1519
+ ` - Config written to: ${written.path}\n` +
1520
+ ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
1521
+ ` - Then: ${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}\n`
1522
+ );
1523
+ return false;
739
1524
  }