premanmcp 0.9.0 → 0.10.1

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
@@ -16,18 +16,31 @@ 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";
19
22
  import {
23
+ confirmRunnerOnline,
24
+ readRunnerState,
25
+ registerRunner,
26
+ runnerIsAlive,
27
+ startBackground,
28
+ } from "./runner.js";
29
+ import {
30
+ apiKeyIsExplicit,
20
31
  assertOk,
21
32
  authenticateTerminal,
22
33
  backendUrl,
23
34
  buildServerConfig,
24
35
  callBackendJson,
36
+ clearStoredCredentials,
25
37
  cliInvocation,
26
38
  frontendUrl,
27
39
  hasKeyAvailable,
28
40
  LAUNCHER_ARGS,
29
41
  LAUNCHER_COMMAND,
30
42
  makeArgs,
43
+ pathPremanOwner,
31
44
  promptSecret,
32
45
  promptText,
33
46
  readJsonFile,
@@ -97,6 +110,29 @@ function onPath(binary) {
97
110
  return probe.status === 0;
98
111
  }
99
112
 
113
+ /**
114
+ * Why this agent's CLI cannot run unattended right now, or "".
115
+ *
116
+ * Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
117
+ * on every `-p` run until someone signs in, which surfaced as "Cursor did not
118
+ * register any endpoints" and sent people looking at PreMan for an hour. Only
119
+ * Cursor is probed because only its CLI has a cheap non-interactive status
120
+ * subcommand; the others are diagnosed from their own output when they fail.
121
+ */
122
+ export function agentBlocker(agentId) {
123
+ if (agentId !== "cursor") return "";
124
+ const probe = spawnSync("cursor-agent", ["status"], {
125
+ encoding: "utf8",
126
+ timeout: 15000,
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ });
129
+ const text = `${probe.stdout || ""}${probe.stderr || ""}`;
130
+ if (/not logged in|not authenticated|no active session/i.test(text)) {
131
+ return "cursor-agent is installed but not signed in — run `cursor-agent login`";
132
+ }
133
+ return "";
134
+ }
135
+
100
136
  /** Best guess at which agent this machine actually uses, for the default pick. */
101
137
  function detectAgents() {
102
138
  const home = os.homedir();
@@ -389,19 +425,81 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
389
425
 
390
426
  // ── Pairing ─────────────────────────────────────────────────────────────
391
427
 
428
+ /**
429
+ * Mint the pair code, distinguishing "could not reach it" from "this key is dead".
430
+ *
431
+ * 401 is the backend saying the key is invalid or revoked, which is the one
432
+ * failure the caller must not shrug off: every other outcome still leaves a
433
+ * usable config, but writing a revoked key into an agent's config produces a
434
+ * setup that looks connected and fails on every call.
435
+ */
392
436
  async function startPairing(args, agent, apiKey) {
393
437
  const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
394
438
  token: apiKey,
395
439
  json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
396
440
  });
441
+ if (result.status_code === 401) return { pairCode: "", stale: true };
397
442
  if (!result.ok) {
398
443
  process.stdout.write(
399
444
  `Note: could not start pairing (${result.status_code}); the config still works, ` +
400
445
  "your agent will link on its first PreMan call.\n"
401
446
  );
402
- return "";
447
+ return { pairCode: "", stale: false };
448
+ }
449
+ return { pairCode: String(result.pair_code || ""), stale: false };
450
+ }
451
+
452
+ /**
453
+ * The --no-pair stand-in for the auth check pairing performs implicitly.
454
+ *
455
+ * Declining a pair code should not also decline noticing the key is dead,
456
+ * otherwise this flag writes exactly the broken config pairing would catch.
457
+ */
458
+ async function verifyKey(args, apiKey) {
459
+ const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
460
+ return { pairCode: "", stale: result.status_code === 401 };
461
+ }
462
+
463
+ /** Whichever of the two ways to learn the backend still accepts this key. */
464
+ async function checkKeyAndPair(args, agent, apiKey) {
465
+ return args.has("--no-pair")
466
+ ? verifyKey(args, apiKey)
467
+ : startPairing(args, agent, apiKey);
468
+ }
469
+
470
+ /**
471
+ * Replace credentials the backend has rejected, and return the new key.
472
+ *
473
+ * Only stored credentials can be replaced here: an explicit --api-key or
474
+ * PREMAN_API_KEY would just be re-read, so those say so and stop rather than
475
+ * looping. Refusing to continue is deliberate — the alternative is writing a
476
+ * key we know is dead into the agent's config.
477
+ */
478
+ async function refreshStaleCredentials(args) {
479
+ if (apiKeyIsExplicit(args)) {
480
+ const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
481
+ throw new ConnectError(
482
+ `The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
483
+ `dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
484
+ EXIT_USAGE
485
+ );
403
486
  }
404
- return String(result.pair_code || "");
487
+ if (args.has("--skip-login") || !process.stdin.isTTY) {
488
+ throw new ConnectError(
489
+ "Saved PreMan credentials are invalid or revoked. Run " +
490
+ `'${cliInvocation()} logout', then connect again to sign in.`,
491
+ EXIT_USAGE
492
+ );
493
+ }
494
+
495
+ process.stdout.write(
496
+ "\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
497
+ "account behind it was removed. Signing you in again.\n"
498
+ );
499
+ clearStoredCredentials();
500
+ await authenticateTerminal(args);
501
+ process.stdout.write("\n");
502
+ return resolveApiKey(args);
405
503
  }
406
504
 
407
505
  export async function waitForConnection(
@@ -487,13 +585,24 @@ export async function autoCheckIn(
487
585
 
488
586
  let child;
489
587
  try {
490
- child = spawn(spec.bin, spec.args, { stdio: "ignore" });
588
+ // Piped rather than ignored: an agent that runs and does not check in used to
589
+ // report exactly that and nothing else, which is the least useful sentence
590
+ // available. Its own last words usually name the cause.
591
+ child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
491
592
  } catch (error) {
492
593
  return { ran: false, connected: false, reason: error.message };
493
594
  }
494
595
 
495
596
  let spawnError = null;
496
597
  let exitedAt = 0;
598
+ let output = "";
599
+ const absorb = (chunk) => {
600
+ output = `${output}${chunk}`.slice(-4000);
601
+ };
602
+ child.stdout?.setEncoding("utf8");
603
+ child.stderr?.setEncoding("utf8");
604
+ child.stdout?.on("data", absorb);
605
+ child.stderr?.on("data", absorb);
497
606
  child.on("error", (error) => {
498
607
  spawnError = error;
499
608
  exitedAt = exitedAt || Date.now();
@@ -512,14 +621,156 @@ export async function autoCheckIn(
512
621
  stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
513
622
  });
514
623
  if (spawnError && !connected) {
515
- return { ran: false, connected: false, reason: spawnError.message };
624
+ return { ran: false, connected: false, reason: spawnError.message, output };
516
625
  }
517
- return { ran: true, connected, command: spec.bin };
626
+ return { ran: true, connected, command: spec.bin, output };
518
627
  } finally {
519
628
  if (child.exitCode === null && child.signalCode === null) child.kill();
520
629
  }
521
630
  }
522
631
 
632
+ /** The last non-empty line of an agent's output, for a one-line diagnosis. */
633
+ export function lastLine(output, cap = 200) {
634
+ const lines = String(output || "")
635
+ .split("\n")
636
+ .map((line) => line.trim())
637
+ .filter(Boolean);
638
+ return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
639
+ }
640
+
641
+ // ── MCP self-test ───────────────────────────────────────────────────────
642
+
643
+ /**
644
+ * How long to give the self-test, and whether to run it at all.
645
+ *
646
+ * `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
647
+ * run wants: the launcher is `npm exec premanmcp@latest`, so every case that
648
+ * reaches this would otherwise go to the registry.
649
+ */
650
+ export function selfTestBudgetMs() {
651
+ const raw = process.env.PREMAN_SELFTEST_MS;
652
+ if (raw === undefined || raw === "") return 60000;
653
+ const parsed = Number(raw);
654
+ return Number.isFinite(parsed) ? parsed : 60000;
655
+ }
656
+
657
+ /**
658
+ * Run the config we just wrote, exactly as the agent will, and call one tool.
659
+ *
660
+ * This is both the fastest way to finish the link and the only check that proves
661
+ * the whole chain — launcher, package, key, backend — rather than proving the
662
+ * file is on disk. It matters because the failure it catches is invisible
663
+ * otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
664
+ * redirect the server to a backend the key was never issued for, and every
665
+ * symptom of that points at the key.
666
+ *
667
+ * Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
668
+ */
669
+ export async function mcpSelfTest(
670
+ serverConfig,
671
+ { timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
672
+ ) {
673
+ if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
674
+ let child;
675
+ try {
676
+ child = spawn(serverConfig.command, serverConfig.args, {
677
+ cwd,
678
+ env: { ...process.env, ...serverConfig.env },
679
+ stdio: ["pipe", "pipe", "pipe"],
680
+ });
681
+ } catch (error) {
682
+ return { ok: false, reason: error.message };
683
+ }
684
+
685
+ return new Promise((resolve) => {
686
+ let stdout = "";
687
+ let stderr = "";
688
+ let settled = false;
689
+
690
+ const finish = (result) => {
691
+ if (settled) return;
692
+ settled = true;
693
+ clearTimeout(timer);
694
+ if (child.exitCode === null && child.signalCode === null) child.kill();
695
+ resolve(result);
696
+ };
697
+
698
+ const timer = setTimeout(
699
+ () => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
700
+ timeoutMs
701
+ );
702
+
703
+ const send = (message) => {
704
+ try {
705
+ child.stdin.write(`${JSON.stringify(message)}\n`);
706
+ } catch (error) {
707
+ finish({ ok: false, reason: error.message });
708
+ }
709
+ };
710
+
711
+ child.on("error", (error) => finish({ ok: false, reason: error.message }));
712
+ child.on("exit", (code) =>
713
+ finish({
714
+ ok: false,
715
+ reason: `the MCP server exited with code ${code}`,
716
+ stderr: lastLine(stderr),
717
+ })
718
+ );
719
+
720
+ child.stdout.setEncoding("utf8");
721
+ child.stderr.setEncoding("utf8");
722
+ child.stderr.on("data", (chunk) => {
723
+ stderr = `${stderr}${chunk}`.slice(-4000);
724
+ });
725
+ child.stdout.on("data", (chunk) => {
726
+ stdout += chunk;
727
+ let newline = stdout.indexOf("\n");
728
+ while (newline !== -1) {
729
+ const line = stdout.slice(0, newline).trim();
730
+ stdout = stdout.slice(newline + 1);
731
+ newline = stdout.indexOf("\n");
732
+ if (!line.startsWith("{")) continue;
733
+ let message;
734
+ try {
735
+ message = JSON.parse(line);
736
+ } catch {
737
+ continue;
738
+ }
739
+ if (message.id === 1) {
740
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
741
+ send({
742
+ jsonrpc: "2.0",
743
+ id: 2,
744
+ method: "tools/call",
745
+ params: { name: "preman_status", arguments: {} },
746
+ });
747
+ continue;
748
+ }
749
+ if (message.id !== 2) continue;
750
+ const text = message.result?.content?.[0]?.text;
751
+ let status = {};
752
+ try {
753
+ status = text ? JSON.parse(text) : {};
754
+ } catch {
755
+ status = { raw: text };
756
+ }
757
+ finish({ ok: !message.error, status, reason: message.error?.message || "" });
758
+ }
759
+ });
760
+
761
+ send({
762
+ jsonrpc: "2.0",
763
+ id: 1,
764
+ method: "initialize",
765
+ params: {
766
+ protocolVersion: "2024-11-05",
767
+ capabilities: {},
768
+ clientInfo: { name: "preman-connect-selftest", version: "1" },
769
+ },
770
+ });
771
+ });
772
+ }
773
+
523
774
  // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
524
775
 
525
776
  async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
@@ -651,79 +902,418 @@ function nextStepsBlock(agent) {
651
902
  return (
652
903
  "\nNext steps:\n" +
653
904
  ` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
654
- " 2. preman endpoints discover # brief for your agent → endpoints.json\n" +
655
- " 3. preman endpoints setup --file endpoints.json # register runnable requests\n" +
656
- " 4. preman test <request-id> # generate + run your first scenarios\n"
905
+ ` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
906
+ ` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
907
+ ` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
657
908
  );
658
909
  }
659
910
 
660
- async function confirm(question) {
661
- const answer = (await promptText(`${question} [Y/n]: `)).toLowerCase();
662
- return answer === "" || answer.startsWith("y");
911
+ /**
912
+ * Ask, or take the step's own default when nobody can answer.
913
+ *
914
+ * `--yes` returns the default rather than a blanket yes, which matters for
915
+ * exactly one step: the desktop app defaults to no because it downloads a
916
+ * hundred-odd megabytes and writes to /Applications. A blanket yes made
917
+ * `preman connect --yes` do that unattended, which is not what anyone means by
918
+ * "do not ask me questions" -- they get `preman install-desktop` for that.
919
+ */
920
+ async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
921
+ if (assumeYes) return defaultYes;
922
+ if (!process.stdin.isTTY) return false;
923
+ const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
924
+ if (answer === "") return defaultYes;
925
+ return answer.startsWith("y");
926
+ }
927
+
928
+ function step(title) {
929
+ process.stdout.write(`\n${title}\n`);
930
+ }
931
+
932
+ /** What the account can see right now: registry rows, and the runnable subset. */
933
+ async function endpointCounts(args) {
934
+ const inventory = await callPremanTool(args, "get_endpoints", {
935
+ include_workbench: true,
936
+ limit: 200,
937
+ });
938
+ const runnable = inventory.workbench_requests || [];
939
+ return {
940
+ registered: (inventory.endpoints || []).length,
941
+ runnable: runnable.length,
942
+ first: runnable[0] || null,
943
+ };
663
944
  }
664
945
 
665
946
  /**
666
- * Carry a freshly linked agent to its first passing test.
947
+ * The one-shot command that has an agent execute the discovery brief.
667
948
  *
668
- * Discovery itself belongs to the coding agent — the backend hands back a brief
669
- * for it to execute — so this either runs a test against what the account
670
- * already has, or prints that brief and the two commands that follow it.
949
+ * Same shape as `headlessCheckIn`, with the read tools the walk needs added to
950
+ * Claude Code's allow-list: print mode refuses anything un-allowed instead of
951
+ * asking, so an agent given only the MCP server can call PreMan and read nothing.
952
+ */
953
+ export function headlessDiscovery(agent, serverName, instructions = []) {
954
+ const prompt = [
955
+ `Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
956
+ "Work autonomously and do not ask for confirmation.",
957
+ "",
958
+ ...instructions,
959
+ ].join("\n");
960
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
961
+ if (agent.id === "claude_code") {
962
+ return {
963
+ bin: "claude",
964
+ args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
965
+ };
966
+ }
967
+ if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
968
+ return null;
969
+ }
970
+
971
+ /**
972
+ * Get this project's endpoints into PreMan without handing anyone homework.
973
+ *
974
+ * Discovery is the agent's job — only it can read the repo — but "here is a brief,
975
+ * go paste it somewhere" is the step everybody dropped out on, and the commands
976
+ * printed after it referenced a file the user never had. So run the agent on the
977
+ * brief and report the two numbers that matter.
671
978
  *
672
- * Never throws: onboarding help must not turn a successful connect into a failure.
979
+ * Never throws: a failed discovery must not fail the connect.
673
980
  */
674
- async function guidedFirstRun(args, agent) {
981
+ async function discoverEndpoints(args, agent, serverName) {
675
982
  try {
676
- const inventory = await callPremanTool(args, "get_endpoints", {
677
- include_workbench: true,
678
- limit: 50,
679
- });
680
- const runnable = (inventory.workbench_requests || [])[0];
681
- const registered = (inventory.endpoints || []).length;
682
-
683
- if (runnable) {
684
- const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
685
- if (!(await confirm(`\nRun a first test against ${label}?`))) {
686
- process.stdout.write(`Whenever you are ready: preman test ${runnable.id}\n`);
687
- return;
688
- }
689
- process.stdout.write("Generating scenarios…\n");
690
- const result = await callPremanTool(args, "generate_endpoint_tests", {
691
- target: runnable.id,
692
- run: true,
693
- allow_writes: false,
694
- max_cases: 10,
695
- });
696
- printTestSummary(result);
697
- process.stdout.write(`\nAdd your own: preman test ${runnable.id} --scenario "..."\n`);
698
- return;
983
+ const before = await endpointCounts(args);
984
+ if (before.registered) {
985
+ process.stdout.write(
986
+ `${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
987
+ );
988
+ return before;
699
989
  }
700
990
 
701
- if (registered) {
991
+ const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
992
+ const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
993
+ const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
994
+ if (!spec || !onPath(spec.bin) || blocker) {
995
+ // Say why before printing homework: "here is a brief" reads as PreMan not
996
+ // working, when the actual answer is one login away.
997
+ if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
998
+ for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
999
+ process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
1000
+ return before;
1001
+ }
1002
+
1003
+ process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
1004
+ const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
1005
+ const outcome = await runAgentOnce(spec, timeoutMs);
1006
+ const after = await endpointCounts(args);
1007
+
1008
+ if (!after.registered) {
1009
+ // Both halves: the exit code says the run failed, the agent's own last line
1010
+ // says why, and either one alone has sent someone down the wrong path.
1011
+ const why =
1012
+ [outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
1013
+ "it registered nothing";
702
1014
  process.stdout.write(
703
- `\nYou have ${registered} registered endpoint(s), but none are runnable yet:\n` +
704
- " preman endpoints setup --ids <id1,id2> # make them runnable\n" +
705
- " preman test <request-id> # generate + run your first scenarios\n"
1015
+ `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
1016
+ ` Run it yourself: ${cliInvocation()} endpoints discover\n`
706
1017
  );
707
- return;
1018
+ return after;
708
1019
  }
1020
+ process.stdout.write(
1021
+ `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
1022
+ );
1023
+ return after;
1024
+ } catch (error) {
1025
+ process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
1026
+ return { registered: 0, runnable: 0, first: null };
1027
+ }
1028
+ }
709
1029
 
710
- if (!(await confirm("\nNo endpoints in PreMan yet. Print the discovery brief for your agent?"))) {
711
- process.stdout.write(`\nWhen you are ready:\n${MANUAL_STEPS}`);
1030
+ /** Run one bounded, non-interactive agent invocation and keep its tail. */
1031
+ async function runAgentOnce(spec, timeoutMs) {
1032
+ return new Promise((resolve) => {
1033
+ let child;
1034
+ try {
1035
+ child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
1036
+ } catch (error) {
1037
+ resolve({ ok: false, reason: error.message, output: "" });
712
1038
  return;
713
1039
  }
1040
+ let output = "";
1041
+ const absorb = (chunk) => {
1042
+ output = `${output}${chunk}`.slice(-8000);
1043
+ };
1044
+ child.stdout?.setEncoding("utf8");
1045
+ child.stderr?.setEncoding("utf8");
1046
+ child.stdout?.on("data", absorb);
1047
+ child.stderr?.on("data", absorb);
1048
+
1049
+ const timer = setTimeout(() => {
1050
+ child.kill("SIGTERM");
1051
+ resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
1052
+ }, timeoutMs);
1053
+ child.on("error", (error) => {
1054
+ clearTimeout(timer);
1055
+ resolve({ ok: false, reason: error.message, output });
1056
+ });
1057
+ child.on("close", (code) => {
1058
+ clearTimeout(timer);
1059
+ resolve({
1060
+ ok: code === 0,
1061
+ reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
1062
+ output,
1063
+ });
1064
+ });
1065
+ });
1066
+ }
1067
+
1068
+ /** Generate and run scenarios against the first runnable request, if they want it. */
1069
+ async function runFirstTest(args, runnable, { assumeYes }) {
1070
+ const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
1071
+ if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
1072
+ process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
1073
+ return;
1074
+ }
1075
+ try {
1076
+ process.stdout.write("Generating scenarios…\n");
1077
+ const result = await callPremanTool(args, "generate_endpoint_tests", {
1078
+ target: runnable.id,
1079
+ run: true,
1080
+ allow_writes: false,
1081
+ max_cases: 10,
1082
+ });
1083
+ printTestSummary(result);
1084
+ } catch (error) {
1085
+ process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * Pair this machine as a runner and leave it running.
1091
+ *
1092
+ * Without this, everything PreMan finds later is advice: it can describe a fix
1093
+ * and cannot apply one. With it, the queue has somewhere to land.
1094
+ */
1095
+ async function setUpRunner(args, agent, { assumeYes }) {
1096
+ if (args.has("--no-runner")) return { state: "skipped" };
1097
+ const existing = readRunnerState();
1098
+ if (existing && runnerIsAlive()) {
1099
+ process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
1100
+ return { state: "running" };
1101
+ }
1102
+ if (
1103
+ !(await confirm(
1104
+ `Let PreMan run ${agent.label} here when it finds something to fix?`,
1105
+ { assumeYes }
1106
+ ))
1107
+ ) {
1108
+ process.stdout.write(
1109
+ `${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
1110
+ );
1111
+ return { state: "skipped" };
1112
+ }
1113
+
1114
+ try {
1115
+ if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
1116
+ await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1117
+ }
1118
+ const started = startBackground([]);
1119
+ // Claiming a runner that died two seconds later is worse than saying nothing:
1120
+ // the whole point of this step is that PreMan can act, and someone told it
1121
+ // can will wait for fixes that no device is listening for.
1122
+ const up = await confirmRunnerOnline(started);
1123
+ if (up.state === "exited") {
1124
+ process.stdout.write(
1125
+ `${MARK.fail()} The runner exited right after starting.\n` +
1126
+ (up.detail ? ` ${up.detail}\n` : "") +
1127
+ ` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
1128
+ );
1129
+ return { state: "failed", detail: up.detail };
1130
+ }
1131
+ process.stdout.write(
1132
+ `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1133
+ ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1134
+ );
1135
+ // Paired and online still cannot run anything if the agent it dispatches to
1136
+ // will not start, and every job would fail with the same opaque exit code.
1137
+ const blocker = agentBlocker(agent.id);
1138
+ if (blocker) {
1139
+ process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
1140
+ }
1141
+ return { state: "running", pid: started.pid };
1142
+ } catch (error) {
1143
+ process.stdout.write(
1144
+ `${MARK.fail()} Could not start the runner: ${error.message}\n` +
1145
+ ` Retry later: ${cliInvocation()} runner start --background\n`
1146
+ );
1147
+ return { state: "failed", detail: error.message };
1148
+ }
1149
+ }
714
1150
 
715
- const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
716
- for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
1151
+ /** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
1152
+ async function offerDesktop(args, { assumeYes }) {
1153
+ if (args.has("--no-desktop")) return { state: "skipped" };
1154
+ if (process.platform !== "darwin") return { state: "unsupported" };
1155
+ if (existsSync("/Applications/PreMan.app")) {
1156
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
1157
+ return { state: "installed" };
1158
+ }
1159
+ // Default no, unlike every other step here: this one downloads a hundred-odd
1160
+ // megabytes and writes to /Applications, which nobody should get by pressing
1161
+ // Enter to move past a prompt.
1162
+ if (
1163
+ !(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
1164
+ assumeYes,
1165
+ defaultYes: false,
1166
+ }))
1167
+ ) {
717
1168
  process.stdout.write(
718
- `\nHand this brief to ${agent.label}, then run:\n` +
719
- " preman endpoints setup --file endpoints.json\n" +
720
- " preman test <request-id>\n"
1169
+ `${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
721
1170
  );
1171
+ return { state: "skipped" };
1172
+ }
1173
+ try {
1174
+ return await installDesktopCommand([]);
722
1175
  } catch (error) {
1176
+ process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
1177
+ return { state: "failed", detail: error.message };
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Which of GitHub, AWS and Slack this account already has.
1183
+ *
1184
+ * One probe per provider because each owns its own list route; a provider whose
1185
+ * route errors is reported as unknown rather than missing, so a backend hiccup
1186
+ * does not send someone through an install they already did.
1187
+ */
1188
+ export async function integrationStatus(args, apiKey) {
1189
+ const probe = async (method, route, pick) => {
1190
+ try {
1191
+ const result = await callBackendJson(args, method, route, { token: apiKey });
1192
+ if (!result.ok) return null;
1193
+ return (pick(result) || []).length;
1194
+ } catch {
1195
+ return null;
1196
+ }
1197
+ };
1198
+ const [github, aws, slack] = await Promise.all([
1199
+ probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
1200
+ probe("GET", "/aws-links", (r) => r.links || r.list),
1201
+ probe("GET", "/slack/connections", (r) => r.connections || r.list),
1202
+ ]);
1203
+ return { github, aws, slack };
1204
+ }
1205
+
1206
+ /**
1207
+ * Show what is connected, and offer to finish what is not.
1208
+ *
1209
+ * Each install lives in the customer's browser session, so every one of these
1210
+ * opens a URL and polls our own API until the other side reports the connection
1211
+ * exists — the terminal picks the result up on its own, whether they finished in
1212
+ * the browser or in the desktop app.
1213
+ */
1214
+ async function connectIntegrations(args, apiKey, { assumeYes }) {
1215
+ if (args.has("--no-integrations")) return;
1216
+ const status = await integrationStatus(args, apiKey);
1217
+ const providers = [
1218
+ { key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
1219
+ { key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
1220
+ { key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
1221
+ ];
1222
+
1223
+ for (const provider of providers) {
1224
+ const count = status[provider.key];
1225
+ if (count === null) {
1226
+ process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
1227
+ } else if (count > 0) {
1228
+ process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
1229
+ } else {
1230
+ process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
1231
+ }
1232
+ }
1233
+
1234
+ for (const provider of providers) {
1235
+ if (status[provider.key] !== 0) continue;
1236
+ if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
1237
+ try {
1238
+ await provider.run();
1239
+ } catch (error) {
1240
+ // One provider's failure must not cost the customer the ones that worked.
1241
+ process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Install the pre-push hook, so a push is what triggers the tests.
1248
+ *
1249
+ * Not a question: testing what you just changed before it ships is the product,
1250
+ * the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
1251
+ * still there. Asking only produced accounts that never tested anything.
1252
+ */
1253
+ async function setUpPushTesting(args) {
1254
+ if (args.has("--no-hook")) return { state: "skipped" };
1255
+ const current = (() => {
1256
+ try {
1257
+ return hookStatus();
1258
+ } catch {
1259
+ return null; // not a git repository
1260
+ }
1261
+ })();
1262
+ if (!current) {
1263
+ process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1264
+ return { state: "unavailable" };
1265
+ }
1266
+ // A hook whose invocation went stale is reinstalled rather than reported as on:
1267
+ // it is the case where PreMan looks connected and silently checks nothing.
1268
+ if (current.state === "installed" && current.current) {
1269
+ process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1270
+ return { state: "installed" };
1271
+ }
1272
+
1273
+ const result = installHook(args);
1274
+ if (result.action === "conflict") {
723
1275
  process.stdout.write(
724
- `Note: ${error.message}. Run \`preman endpoints list\` when you are ready.\n`
1276
+ `${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
725
1277
  );
1278
+ return result;
726
1279
  }
1280
+ process.stdout.write(
1281
+ `${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
1282
+ " It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
1283
+ );
1284
+ return result;
1285
+ }
1286
+
1287
+ /**
1288
+ * Everything after the link, in one pass, with nothing left for the user to run.
1289
+ *
1290
+ * Order follows what a new account needs to see: what PreMan found, then where to
1291
+ * watch it, then who to tell, then when to run it.
1292
+ */
1293
+ async function guidedFirstRun(args, agent, apiKey, serverName) {
1294
+ const assumeYes = args.has("--yes");
1295
+
1296
+ step("Endpoints");
1297
+ const counts = await discoverEndpoints(args, agent, serverName);
1298
+
1299
+ if (counts.first) {
1300
+ step("First test");
1301
+ await runFirstTest(args, counts.first, { assumeYes });
1302
+ }
1303
+
1304
+ step("Runner");
1305
+ await setUpRunner(args, agent, { assumeYes });
1306
+
1307
+ step("Desktop app");
1308
+ await offerDesktop(args, { assumeYes });
1309
+
1310
+ step("Integrations");
1311
+ await connectIntegrations(args, apiKey, { assumeYes });
1312
+
1313
+ step("Testing on push");
1314
+ await setUpPushTesting(args);
1315
+
1316
+ process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
727
1317
  }
728
1318
 
729
1319
  // ── Preflight ───────────────────────────────────────────────────────────
@@ -752,6 +1342,16 @@ export async function preflight(args) {
752
1342
  );
753
1343
  }
754
1344
 
1345
+ // Explains why every command below is spelled the long way, before someone
1346
+ // types `preman …` and gets another package's CLI answering.
1347
+ const premanOwner = pathPremanOwner();
1348
+ if (premanOwner && premanOwner !== "premanmcp") {
1349
+ notes.push(
1350
+ `\`preman\` on your PATH belongs to ${premanOwner}, not this CLI, so PreMan's own ` +
1351
+ "commands are written out as `npm exec -y premanmcp@latest -- …`."
1352
+ );
1353
+ }
1354
+
755
1355
  try {
756
1356
  const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
757
1357
  signal: AbortSignal.timeout(4000),
@@ -786,9 +1386,16 @@ Connect options:
786
1386
  --skip-dispatch-credential Do not ask for a cloud-dispatch credential
787
1387
  --skip-login Write config without interactive terminal auth
788
1388
  --no-pair Do not mint a pair code
1389
+ --no-self-test Do not start the MCP server to finish the link
789
1390
  --no-auto-checkin Do not run the agent to finish the link
790
1391
  --no-wait Do not wait for the agent to check in
791
1392
  --no-guide Skip the guided first run after connecting
1393
+ --no-runner Do not pair this machine as a job runner
1394
+ --no-desktop Do not offer the desktop app
1395
+ --no-integrations Do not check or offer GitHub / AWS / Slack
1396
+ --no-hook Do not install the git pre-push hook
1397
+ --yes Take every step's default without prompting
1398
+ (the desktop app defaults to no; install-desktop)
792
1399
  --print Print the config instead of writing it
793
1400
  `;
794
1401
 
@@ -853,11 +1460,19 @@ export async function connectCommand(commandArgs) {
853
1460
 
854
1461
  if (!agent) agent = await promptAgentChoice(detectAgents());
855
1462
 
856
- const apiKey = resolveApiKey(args);
1463
+ let apiKey = resolveApiKey(args);
857
1464
 
858
1465
  let pairCode = "";
859
- if (apiKey && !args.has("--no-pair")) {
860
- pairCode = await startPairing(args, agent, apiKey);
1466
+ if (apiKey) {
1467
+ let pairing = await checkKeyAndPair(args, agent, apiKey);
1468
+ if (pairing.stale) {
1469
+ apiKey = await refreshStaleCredentials(args);
1470
+ pairing = await checkKeyAndPair(args, agent, apiKey);
1471
+ if (pairing.stale) {
1472
+ throw new ConnectError("PreMan rejected a key it just issued. Try again shortly.");
1473
+ }
1474
+ }
1475
+ pairCode = pairing.pairCode;
861
1476
  }
862
1477
 
863
1478
  const serverConfig = buildServerConfig(args, { pairCode });
@@ -894,38 +1509,83 @@ export async function connectCommand(commandArgs) {
894
1509
  return;
895
1510
  }
896
1511
 
897
- if (!(await establishCheckIn(args, agent, apiKey, { serverName, written }))) {
1512
+ if (!(await establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }))) {
898
1513
  // Still honour an explicitly-passed credential, but do not open a new prompt
899
1514
  // on top of a connect that just told the user something went wrong.
900
1515
  await captureDispatchCredential(args, agent, apiKey, { prompt: false });
901
1516
  return;
902
1517
  }
903
1518
 
904
- process.stdout.write(`Connected as ${agent.label}.\n`);
1519
+ process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
905
1520
  if (!args.has("--no-guide")) {
906
- await guidedFirstRun(args, agent);
1521
+ await guidedFirstRun(args, agent, apiKey, serverName);
907
1522
  }
908
1523
  await captureDispatchCredential(args, agent, apiKey);
909
1524
  }
910
1525
 
911
1526
  /**
912
- * Get the agent to make its first PreMan call, by whatever means work here.
1527
+ * Finish the link here, by whatever means work, in cheapest-first order.
1528
+ *
1529
+ * 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
1530
+ * seconds, and it proves the launcher/key/backend chain the agent will use.
1531
+ * 2. Failing that, run the agent headlessly — which also proves the agent can
1532
+ * load the config we wrote.
1533
+ * 3. Failing that, ask them to restart it and wait, which is all this ever did.
913
1534
  *
914
- * Prefers running it for the user; falls back to asking them to restart it and
915
- * waiting, which is all this ever did. Returns whether the check-in landed, and
916
- * prints the troubleshooting block itself when it did not.
1535
+ * Returns whether the check-in landed, and prints the troubleshooting block
1536
+ * itself when it did not.
917
1537
  */
918
- async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
919
- if (!args.has("--no-auto-checkin")) {
1538
+ async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
1539
+ const notes = [];
1540
+
1541
+ if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
1542
+ process.stdout.write("\nChecking the connection…\n");
1543
+ const test = await mcpSelfTest(serverConfig);
1544
+ const status = test.status || {};
1545
+ const repo = status.config?.repo_config;
1546
+ if (repo?.override && repo.applied?.length) {
1547
+ // The one failure that reads as a bad key: a working server talking to a
1548
+ // backend nobody chose. Name the file before it costs anyone an hour.
1549
+ notes.push(
1550
+ `${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
1551
+ `so your agent will use ${status.backend_url}.`
1552
+ );
1553
+ }
1554
+ if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
1555
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1556
+ return true;
1557
+ }
1558
+ notes.push(
1559
+ test.ok
1560
+ ? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
1561
+ : `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
1562
+ );
1563
+ }
1564
+
1565
+ const blocker = agentBlocker(agent.id);
1566
+ if (!args.has("--no-auto-checkin") && !blocker) {
920
1567
  const spec = headlessCheckIn(agent, serverName);
921
1568
  if (spec && onPath(spec.bin)) {
922
1569
  process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
923
1570
  }
924
1571
  const auto = await autoCheckIn(args, agent, apiKey, { serverName });
925
- if (auto.connected) return true;
1572
+ if (auto.connected) {
1573
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1574
+ return true;
1575
+ }
926
1576
  if (auto.ran) {
927
- process.stdout.write(`${agent.label} ran but did not check in.\n`);
1577
+ const why = lastLine(auto.output);
1578
+ process.stdout.write(
1579
+ `${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
1580
+ );
1581
+ } else if (auto.reason) {
1582
+ process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
928
1583
  }
1584
+ } else if (blocker) {
1585
+ // Starting an agent that cannot authenticate spends two minutes to learn
1586
+ // what one status probe already knows.
1587
+ process.stdout.write(`${MARK.skip()} ${blocker}\n`);
1588
+ notes.push(blocker);
929
1589
  }
930
1590
 
931
1591
  process.stdout.write(
@@ -933,10 +1593,14 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
933
1593
  "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
934
1594
  );
935
1595
 
936
- if (await waitForConnection(args, apiKey)) return true;
1596
+ if (await waitForConnection(args, apiKey)) {
1597
+ for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1598
+ return true;
1599
+ }
937
1600
 
938
1601
  process.stdout.write(
939
1602
  "No check-in yet. Troubleshooting:\n" +
1603
+ notes.map((note) => ` - ${note}\n`).join("") +
940
1604
  ` - ${agent.restartHint}\n` +
941
1605
  ` - Config written to: ${written.path}\n` +
942
1606
  ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +