premanmcp 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,13 +48,19 @@ to go restart anything, in cheapest-first order:
48
48
  1. **Self-test.** It starts the MCP server exactly as your agent will and calls
49
49
  `preman_status` over stdio. That both completes the link and proves the whole chain —
50
50
  launcher, package, key, backend. `--no-self-test` turns it off.
51
- 2. **Headless agent run** (`claude -p`, `cursor-agent -p`, `codex exec`), which also
52
- proves your agent can load what was written. `--no-auto-checkin` turns it off.
51
+ 2. **Agent run**, which also proves your agent can load what was written. It opens your
52
+ agent interactively in a new terminal window the session you go on to use — and falls
53
+ back to a headless run (`claude -p`, `cursor-agent -p`, `codex exec`) where no window can
54
+ be opened, such as CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1`
55
+ keeps it headless.
53
56
  3. **Wait**, if neither is possible: restart your agent and it links on its first call.
54
57
 
55
58
  A self-test that answers from an unexpected backend is reported with the file that
56
59
  redirected it — a repo-local `preman-mcp.config.json` with `"PREMAN_CONFIG_OVERRIDE": true`
57
- wins over the MCP config env, and otherwise only fills in what the env leaves unset.
60
+ wins over the MCP config env, and otherwise only fills in what the env leaves unset. When
61
+ that file overrides `PREMAN_BACKEND`, `connect` stops there rather than waiting: an agent
62
+ started in that directory reads the same file and checks in somewhere else, so it names the
63
+ file and tells you how to connect to either backend.
58
64
 
59
65
  Once linked, `connect` finishes onboarding without handing you homework:
60
66
 
@@ -68,9 +74,13 @@ Once linked, `connect` finishes onboarding without handing you homework:
68
74
  | Testing on push | Installs the git pre-push hook, so `git push` checks the endpoints you touched |
69
75
 
70
76
  Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
71
- project-local config, `--print` shows the config without writing it, `--yes` accepts every
72
- optional step, `--no-guide` skips all of them, and `--no-runner` / `--no-desktop` /
73
- `--no-integrations` / `--no-hook` skip one each.
77
+ project-local config, `--print` shows the config without writing it, `--yes` takes every
78
+ step's default without asking, `--no-guide` skips all of them, and `--no-runner` /
79
+ `--no-desktop` / `--no-integrations` / `--no-hook` skip one each.
80
+
81
+ `--yes` deliberately does *not* install the desktop app: that step's default is no, because
82
+ it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
83
+ when you want it.
74
84
 
75
85
  In CI or any non-interactive shell, run `connect --agent <name> --api-key pm_live_…`.
76
86
  Without `--agent` there is nothing to prompt on, so `connect` prints ready-to-paste
package/bin/account.js CHANGED
@@ -178,7 +178,7 @@ export async function watchCommand(commandArgs = []) {
178
178
  const integrationId = args.value("--integration", positional[1] || "");
179
179
  if (!runId || !integrationId) {
180
180
  throw new Error(
181
- "usage: watch <run-id> <integration-id> (both are shown by `preman status`)"
181
+ `usage: watch <run-id> <integration-id> (both are shown by \`${cliInvocation()} status\`)`
182
182
  );
183
183
  }
184
184
 
package/bin/api_tools.js CHANGED
@@ -45,7 +45,7 @@ export async function callTool(args, tool, toolArguments) {
45
45
  const token = resolveApiKey(args);
46
46
  if (!token) {
47
47
  throw new CliError(
48
- "No PreMan API key. Run `preman login` or pass --api-key pm_live_...",
48
+ `No PreMan API key. Run \`${cliInvocation()} login\` or pass --api-key pm_live_...`,
49
49
  2,
50
50
  );
51
51
  }
@@ -110,7 +110,7 @@ export async function endpointsCommand(commandArgs) {
110
110
  if (args.has("--json-out")) return printJson(result);
111
111
  for (const line of result.instructions || []) process.stdout.write(`${line}\n`);
112
112
  process.stdout.write(
113
- "\nHand this brief to your coding agent, then run `preman endpoints setup --file endpoints.json`.\n",
113
+ `\nHand this brief to your coding agent, then run \`${cliInvocation()} endpoints setup --file endpoints.json\`.\n`,
114
114
  );
115
115
  return undefined;
116
116
  }
package/bin/connect.js CHANGED
@@ -19,7 +19,13 @@ import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
19
19
  import { installDesktopCommand } from "./desktop.js";
20
20
  import { installHook, hookStatus } from "./hook.js";
21
21
  import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
22
- import { readRunnerState, registerRunner, runnerIsAlive, startBackground } from "./runner.js";
22
+ import {
23
+ confirmRunnerOnline,
24
+ readRunnerState,
25
+ registerRunner,
26
+ runnerIsAlive,
27
+ startBackground,
28
+ } from "./runner.js";
23
29
  import {
24
30
  apiKeyIsExplicit,
25
31
  assertOk,
@@ -34,6 +40,7 @@ import {
34
40
  LAUNCHER_ARGS,
35
41
  LAUNCHER_COMMAND,
36
42
  makeArgs,
43
+ pathPremanOwner,
37
44
  promptSecret,
38
45
  promptText,
39
46
  readJsonFile,
@@ -103,6 +110,29 @@ function onPath(binary) {
103
110
  return probe.status === 0;
104
111
  }
105
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
+
106
136
  /** Best guess at which agent this machine actually uses, for the default pick. */
107
137
  function detectAgents() {
108
138
  const home = os.homedir();
@@ -479,6 +509,9 @@ export async function waitForConnection(
479
509
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
480
510
  timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
481
511
  stopWhen = null,
512
+ // Called once per unsuccessful poll, so a wait measured in minutes can show
513
+ // that it is still a wait rather than a hang.
514
+ onPoll = null,
482
515
  } = {}
483
516
  ) {
484
517
  const deadline = Date.now() + timeoutMs;
@@ -495,6 +528,7 @@ export async function waitForConnection(
495
528
  });
496
529
  if (status.ok && status.connected) return true;
497
530
  if (stopWhen && stopWhen()) return false;
531
+ if (onPoll) onPoll();
498
532
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
499
533
  }
500
534
  } finally {
@@ -503,6 +537,22 @@ export async function waitForConnection(
503
537
  return false;
504
538
  }
505
539
 
540
+ /** A dot per poll, and the newline that closes the run of them. */
541
+ function pollTicker() {
542
+ let dots = 0;
543
+ return {
544
+ tick() {
545
+ dots += 1;
546
+ process.stdout.write(".");
547
+ },
548
+ end() {
549
+ if (!dots) return;
550
+ dots = 0;
551
+ process.stdout.write("\n");
552
+ },
553
+ };
554
+ }
555
+
506
556
  // ── Auto check-in ───────────────────────────────────────────────────────
507
557
 
508
558
  /**
@@ -514,7 +564,7 @@ export async function waitForConnection(
514
564
  * prompting.
515
565
  */
516
566
  export function headlessCheckIn(agent, serverName) {
517
- const prompt = `Call the ${serverName} MCP tool preman_status and report the result.`;
567
+ const prompt = checkInPrompt(serverName);
518
568
  if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
519
569
  if (agent.id === "claude_code") {
520
570
  return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
@@ -523,13 +573,122 @@ export function headlessCheckIn(agent, serverName) {
523
573
  return null;
524
574
  }
525
575
 
576
+ function checkInPrompt(serverName) {
577
+ return `Call the ${serverName} MCP tool preman_status and report the result.`;
578
+ }
579
+
580
+ /**
581
+ * How to start each agent as the session the user actually works in.
582
+ *
583
+ * Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
584
+ * the agent anybody goes on to use, and its failures are invisible because
585
+ * nobody is looking at it. The interactive form is the same prompt without that
586
+ * flag — it needs a terminal of its own, which is what openInNewTerminal is for.
587
+ */
588
+ export function interactiveCheckIn(agent, serverName) {
589
+ const prompt = checkInPrompt(serverName);
590
+ if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
591
+ if (agent.id === "claude_code") {
592
+ return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
593
+ }
594
+ if (agent.id === "codex") return { bin: "codex", args: [prompt] };
595
+ return null;
596
+ }
597
+
598
+ /** A command line for a spec, quoted for the shell the terminal will start. */
599
+ export function commandLine(spec) {
600
+ return [spec.bin, ...spec.args].map(shellQuote).join(" ");
601
+ }
602
+
603
+ /** Escape a shell line into an AppleScript string literal. */
604
+ function appleScriptString(value) {
605
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
606
+ }
607
+
608
+ /**
609
+ * The terminal emulator to open a window in, or null when we know of none.
610
+ *
611
+ * `sync` marks the launchers that hand the work to an app and return — those
612
+ * report their own failure through an exit code, which is worth waiting for.
613
+ * The rest *are* the window, so they are spawned detached and outlive us.
614
+ */
615
+ function terminalLauncher(line) {
616
+ if (process.platform === "darwin") {
617
+ const iterm = existsSync("/Applications/iTerm.app");
618
+ const script = iterm
619
+ ? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
620
+ : `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
621
+ return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
622
+ }
623
+
624
+ if (process.platform === "win32") {
625
+ return {
626
+ bin: "cmd.exe",
627
+ args: ["/c", "start", "cmd.exe", "/k", line],
628
+ label: "Command Prompt",
629
+ sync: true,
630
+ };
631
+ }
632
+
633
+ // Keep the shell alive afterwards: an agent that exits immediately would
634
+ // otherwise take its own error message off the screen with it.
635
+ const body = `${line}; exec ${process.env.SHELL || "sh"}`;
636
+ const candidates = [
637
+ { bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
638
+ { bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
639
+ { bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
640
+ { bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
641
+ ];
642
+ return candidates.find((candidate) => onPath(candidate.bin)) || null;
643
+ }
644
+
645
+ /**
646
+ * Open `command` in a new terminal window, best effort.
647
+ *
648
+ * Same contract as openUrl: never throws, and declines rather than guessing when
649
+ * nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
650
+ * stdout for CI, piped output and test runs. Callers fall back to a headless run.
651
+ */
652
+ export function openInNewTerminal(command, options = {}) {
653
+ const cwd = options?.cwd || process.cwd();
654
+ const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
655
+ if (optOut && !["0", "false", "no"].includes(optOut)) {
656
+ return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
657
+ }
658
+ if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
659
+
660
+ const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
661
+ if (!launcher) return { opened: false, reason: "no terminal emulator found" };
662
+
663
+ try {
664
+ if (launcher.sync) {
665
+ const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
666
+ if (done.error) return { opened: false, reason: done.error.message };
667
+ if (done.status !== 0) {
668
+ return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
669
+ }
670
+ } else {
671
+ const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
672
+ child.unref();
673
+ }
674
+ return { opened: true, terminal: launcher.label };
675
+ } catch (error) {
676
+ return { opened: false, reason: error.message };
677
+ }
678
+ }
679
+
526
680
  /**
527
681
  * Finish the link ourselves instead of asking the user to go restart their agent.
528
682
  *
529
683
  * The config on disk is already correct at this point; all that is missing is
530
684
  * 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.
685
+ * is a dead end in the one terminal they are sitting in.
686
+ *
687
+ * So start the agent. Interactively, in a window of its own, because that is the
688
+ * session the user keeps: a print-mode run answers once, exits, and leaves them
689
+ * exactly where they started. Only when no window can be opened — CI, SSH, a
690
+ * machine with no terminal emulator — does this fall back to the headless run,
691
+ * which still finishes the link even though nobody sees it happen.
533
692
  *
534
693
  * Returns `ran: false` when the agent's binary is absent or will not start, and
535
694
  * the caller falls back to the printed instructions.
@@ -547,8 +706,23 @@ export async function autoCheckIn(
547
706
  ),
548
707
  serverName = "preman",
549
708
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
709
+ onLaunch = () => {},
710
+ onPoll = null,
550
711
  } = {}
551
712
  ) {
713
+ const session = interactiveCheckIn(agent, serverName);
714
+ if (session && onPath(session.bin)) {
715
+ const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd: process.cwd() });
716
+ if (opened) {
717
+ onLaunch({ mode: "interactive", bin: session.bin, terminal });
718
+ // Detached, so there is no exit to watch for and no output to quote: the
719
+ // agent is in front of the user now, and the deadline is all that bounds
720
+ // this. Whoever is watching the window can Ctrl+C out of the wait.
721
+ const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
722
+ return { ran: true, connected, command: session.bin, interactive: true, terminal };
723
+ }
724
+ }
725
+
552
726
  const spec = headlessCheckIn(agent, serverName);
553
727
  if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
554
728
  if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
@@ -562,6 +736,7 @@ export async function autoCheckIn(
562
736
  } catch (error) {
563
737
  return { ran: false, connected: false, reason: error.message };
564
738
  }
739
+ onLaunch({ mode: "headless", bin: spec.bin });
565
740
 
566
741
  let spawnError = null;
567
742
  let exitedAt = 0;
@@ -588,6 +763,7 @@ export async function autoCheckIn(
588
763
  const connected = await waitForConnection(args, apiKey, {
589
764
  intervalMs,
590
765
  timeoutMs,
766
+ onPoll,
591
767
  stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
592
768
  });
593
769
  if (spawnError && !connected) {
@@ -878,8 +1054,17 @@ function nextStepsBlock(agent) {
878
1054
  );
879
1055
  }
880
1056
 
1057
+ /**
1058
+ * Ask, or take the step's own default when nobody can answer.
1059
+ *
1060
+ * `--yes` returns the default rather than a blanket yes, which matters for
1061
+ * exactly one step: the desktop app defaults to no because it downloads a
1062
+ * hundred-odd megabytes and writes to /Applications. A blanket yes made
1063
+ * `preman connect --yes` do that unattended, which is not what anyone means by
1064
+ * "do not ask me questions" -- they get `preman install-desktop` for that.
1065
+ */
881
1066
  async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
882
- if (assumeYes) return true;
1067
+ if (assumeYes) return defaultYes;
883
1068
  if (!process.stdin.isTTY) return false;
884
1069
  const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
885
1070
  if (answer === "") return defaultYes;
@@ -951,7 +1136,11 @@ async function discoverEndpoints(args, agent, serverName) {
951
1136
 
952
1137
  const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
953
1138
  const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
954
- if (!spec || !onPath(spec.bin)) {
1139
+ const blocker = spec && onPath(spec.bin) ? agentBlocker(agent.id) : "";
1140
+ if (!spec || !onPath(spec.bin) || blocker) {
1141
+ // Say why before printing homework: "here is a brief" reads as PreMan not
1142
+ // working, when the actual answer is one login away.
1143
+ if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
955
1144
  for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
956
1145
  process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
957
1146
  return before;
@@ -963,7 +1152,11 @@ async function discoverEndpoints(args, agent, serverName) {
963
1152
  const after = await endpointCounts(args);
964
1153
 
965
1154
  if (!after.registered) {
966
- const why = outcome.reason || lastLine(outcome.output) || "it registered nothing";
1155
+ // Both halves: the exit code says the run failed, the agent's own last line
1156
+ // says why, and either one alone has sent someone down the wrong path.
1157
+ const why =
1158
+ [outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
1159
+ "it registered nothing";
967
1160
  process.stdout.write(
968
1161
  `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
969
1162
  ` Run it yourself: ${cliInvocation()} endpoints discover\n`
@@ -1069,10 +1262,28 @@ async function setUpRunner(args, agent, { assumeYes }) {
1069
1262
  await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1070
1263
  }
1071
1264
  const started = startBackground([]);
1265
+ // Claiming a runner that died two seconds later is worse than saying nothing:
1266
+ // the whole point of this step is that PreMan can act, and someone told it
1267
+ // can will wait for fixes that no device is listening for.
1268
+ const up = await confirmRunnerOnline(started);
1269
+ if (up.state === "exited") {
1270
+ process.stdout.write(
1271
+ `${MARK.fail()} The runner exited right after starting.\n` +
1272
+ (up.detail ? ` ${up.detail}\n` : "") +
1273
+ ` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
1274
+ );
1275
+ return { state: "failed", detail: up.detail };
1276
+ }
1072
1277
  process.stdout.write(
1073
- `${MARK.ok()} Runner running (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1278
+ `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1074
1279
  ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1075
1280
  );
1281
+ // Paired and online still cannot run anything if the agent it dispatches to
1282
+ // will not start, and every job would fail with the same opaque exit code.
1283
+ const blocker = agentBlocker(agent.id);
1284
+ if (blocker) {
1285
+ process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
1286
+ }
1076
1287
  return { state: "running", pid: started.pid };
1077
1288
  } catch (error) {
1078
1289
  process.stdout.write(
@@ -1178,8 +1389,14 @@ async function connectIntegrations(args, apiKey, { assumeYes }) {
1178
1389
  }
1179
1390
  }
1180
1391
 
1181
- /** Install the pre-push hook, so a push is what triggers the tests. */
1182
- async function setUpPushTesting(args, { assumeYes }) {
1392
+ /**
1393
+ * Install the pre-push hook, so a push is what triggers the tests.
1394
+ *
1395
+ * Not a question: testing what you just changed before it ships is the product,
1396
+ * the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
1397
+ * still there. Asking only produced accounts that never tested anything.
1398
+ */
1399
+ async function setUpPushTesting(args) {
1183
1400
  if (args.has("--no-hook")) return { state: "skipped" };
1184
1401
  const current = (() => {
1185
1402
  try {
@@ -1192,14 +1409,12 @@ async function setUpPushTesting(args, { assumeYes }) {
1192
1409
  process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1193
1410
  return { state: "unavailable" };
1194
1411
  }
1195
- if (current.state === "installed") {
1412
+ // A hook whose invocation went stale is reinstalled rather than reported as on:
1413
+ // it is the case where PreMan looks connected and silently checks nothing.
1414
+ if (current.state === "installed" && current.current) {
1196
1415
  process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1197
1416
  return { state: "installed" };
1198
1417
  }
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
1418
 
1204
1419
  const result = installHook(args);
1205
1420
  if (result.action === "conflict") {
@@ -1242,7 +1457,7 @@ async function guidedFirstRun(args, agent, apiKey, serverName) {
1242
1457
  await connectIntegrations(args, apiKey, { assumeYes });
1243
1458
 
1244
1459
  step("Testing on push");
1245
- await setUpPushTesting(args, { assumeYes });
1460
+ await setUpPushTesting(args);
1246
1461
 
1247
1462
  process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
1248
1463
  }
@@ -1273,6 +1488,16 @@ export async function preflight(args) {
1273
1488
  );
1274
1489
  }
1275
1490
 
1491
+ // Explains why every command below is spelled the long way, before someone
1492
+ // types `preman …` and gets another package's CLI answering.
1493
+ const premanOwner = pathPremanOwner();
1494
+ if (premanOwner && premanOwner !== "premanmcp") {
1495
+ notes.push(
1496
+ `\`preman\` on your PATH belongs to ${premanOwner}, not this CLI, so PreMan's own ` +
1497
+ "commands are written out as `npm exec -y premanmcp@latest -- …`."
1498
+ );
1499
+ }
1500
+
1276
1501
  try {
1277
1502
  const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
1278
1503
  signal: AbortSignal.timeout(4000),
@@ -1315,7 +1540,8 @@ Connect options:
1315
1540
  --no-desktop Do not offer the desktop app
1316
1541
  --no-integrations Do not check or offer GitHub / AWS / Slack
1317
1542
  --no-hook Do not install the git pre-push hook
1318
- --yes Accept every optional step without prompting
1543
+ --yes Take every step's default without prompting
1544
+ (the desktop app defaults to no; install-desktop)
1319
1545
  --print Print the config instead of writing it
1320
1546
  `;
1321
1547
 
@@ -1443,20 +1669,52 @@ export async function connectCommand(commandArgs) {
1443
1669
  await captureDispatchCredential(args, agent, apiKey);
1444
1670
  }
1445
1671
 
1672
+ /**
1673
+ * A repo config that sends every agent started here to a backend the key we just
1674
+ * wrote was not issued for, or null.
1675
+ *
1676
+ * Only an override can do this: without the flag the MCP config's env wins, and
1677
+ * a self-test that disagrees with it is a transient, not a redirect. It matters
1678
+ * because it is the one failure waiting cannot fix — the agent starts in the
1679
+ * same directory, reads the same file, and checks in somewhere else forever.
1680
+ */
1681
+ function backendRedirect(repo, status, serverConfig) {
1682
+ if (!repo?.override || !(repo.applied || []).includes("PREMAN_BACKEND")) return null;
1683
+ const trim = (url) => String(url || "").replace(/\/+$/, "");
1684
+ const actual = trim(status.backend_url);
1685
+ const wanted = trim(serverConfig?.env?.PREMAN_BACKEND);
1686
+ if (!actual || !wanted || actual === wanted) return null;
1687
+ return { path: repo.path, actual, wanted };
1688
+ }
1689
+
1446
1690
  /**
1447
1691
  * Finish the link here, by whatever means work, in cheapest-first order.
1448
1692
  *
1449
1693
  * 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
1450
1694
  * 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.
1695
+ * 2. Failing that, start the agent — in its own window when there is a desktop
1696
+ * to open one on, headlessly otherwise — which also proves the agent can load
1697
+ * the config we wrote.
1453
1698
  * 3. Failing that, ask them to restart it and wait, which is all this ever did.
1454
1699
  *
1700
+ * Every diagnosis is printed the moment it is known rather than saved for the
1701
+ * end: the steps below are measured in minutes, and a note that explains what is
1702
+ * happening is worth nothing after it has stopped happening.
1703
+ *
1455
1704
  * Returns whether the check-in landed, and prints the troubleshooting block
1456
1705
  * itself when it did not.
1457
1706
  */
1458
1707
  async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
1459
1708
  const notes = [];
1709
+ const ticker = pollTicker();
1710
+ const say = (text) => {
1711
+ ticker.end();
1712
+ process.stdout.write(text);
1713
+ };
1714
+ const note = (text) => {
1715
+ notes.push(text);
1716
+ say(`Note: ${text}\n`);
1717
+ };
1460
1718
 
1461
1719
  if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
1462
1720
  process.stdout.write("\nChecking the connection…\n");
@@ -1466,55 +1724,81 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
1466
1724
  if (repo?.override && repo.applied?.length) {
1467
1725
  // The one failure that reads as a bad key: a working server talking to a
1468
1726
  // backend nobody chose. Name the file before it costs anyone an hour.
1469
- notes.push(
1727
+ note(
1470
1728
  `${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
1471
1729
  `so your agent will use ${status.backend_url}.`
1472
1730
  );
1473
1731
  }
1474
1732
  if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
1475
- for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1476
1733
  return true;
1477
1734
  }
1478
- notes.push(
1735
+ note(
1479
1736
  test.ok
1480
1737
  ? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
1481
1738
  : `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
1482
1739
  );
1483
- }
1484
1740
 
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`);
1741
+ const redirect = backendRedirect(repo, status, serverConfig);
1742
+ if (redirect) {
1743
+ const flag = `--agent ${agent.id.replace("_", "-")}`;
1744
+ say(
1745
+ `\nNot linked: ${redirect.path} forces PREMAN_BACKEND=${redirect.actual} for anything\n` +
1746
+ `started in this directory, so ${agent.label} cannot check in against ${redirect.wanted}.\n` +
1747
+ ` - Run '${cliInvocation()} connect' from your own project instead of this directory.\n` +
1748
+ ` - Or connect to that backend: ${cliInvocation()} connect ${flag} --backend ${redirect.actual}\n` +
1749
+ ` (needs a key issued by it, and that API running).\n` +
1750
+ ` - Config written to: ${written.path}\n`
1751
+ );
1752
+ return false;
1489
1753
  }
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`);
1754
+ }
1755
+
1756
+ const blocker = agentBlocker(agent.id);
1757
+ let launch = {};
1758
+ if (!args.has("--no-auto-checkin") && !blocker) {
1759
+ launch = await autoCheckIn(args, agent, apiKey, {
1760
+ serverName,
1761
+ onPoll: ticker.tick,
1762
+ onLaunch: ({ mode, terminal }) =>
1763
+ say(
1764
+ mode === "interactive"
1765
+ ? `\nOpened a new ${terminal} window running ${agent.label}.\n`
1766
+ : `\nStarting ${agent.label} to finish the link…\n`
1767
+ ),
1768
+ });
1769
+ if (launch.connected) {
1770
+ ticker.end();
1493
1771
  return true;
1494
1772
  }
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`);
1773
+ if (launch.ran && !launch.interactive) {
1774
+ const why = lastLine(launch.output);
1775
+ say(`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`);
1776
+ } else if (!launch.ran && launch.reason) {
1777
+ say(`Could not run ${agent.label}: ${launch.reason}\n`);
1502
1778
  }
1779
+ } else if (blocker) {
1780
+ // Starting an agent that cannot authenticate spends two minutes to learn
1781
+ // what one status probe already knows.
1782
+ say(`${MARK.skip()} ${blocker}\n`);
1783
+ notes.push(blocker);
1503
1784
  }
1504
1785
 
1505
- process.stdout.write(
1506
- `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
1507
- "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1786
+ say(
1787
+ launch.interactive
1788
+ ? `\nAnswer it in the ${agent.label} window it links on its first PreMan call.\n` +
1789
+ "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1790
+ : `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
1791
+ "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
1508
1792
  );
1509
1793
 
1510
- if (await waitForConnection(args, apiKey)) {
1511
- for (const note of notes) process.stdout.write(`Note: ${note}\n`);
1794
+ if (await waitForConnection(args, apiKey, { onPoll: ticker.tick })) {
1795
+ ticker.end();
1512
1796
  return true;
1513
1797
  }
1514
1798
 
1515
- process.stdout.write(
1799
+ say(
1516
1800
  "No check-in yet. Troubleshooting:\n" +
1517
- notes.map((note) => ` - ${note}\n`).join("") +
1801
+ notes.map((entry) => ` - ${entry}\n`).join("") +
1518
1802
  ` - ${agent.restartHint}\n` +
1519
1803
  ` - Config written to: ${written.path}\n` +
1520
1804
  ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
package/bin/hook.js CHANGED
@@ -56,8 +56,10 @@ if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
56
56
  if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
57
57
  exit ${BLOCK_EXIT_CODE}
58
58
  fi
59
- if [ "$preman_status" -ne 0 ]; then
60
- printf '[preman] checks skipped (%s)\\n' "advisory" >&2
59
+ if [ "$preman_status" -eq 127 ]; then
60
+ printf '[preman] the PreMan CLI is not on PATH; push checks skipped\\n' >&2
61
+ elif [ "$preman_status" -ne 0 ]; then
62
+ printf '[preman] checks skipped (advisory, exit %s)\\n' "$preman_status" >&2
61
63
  fi
62
64
  fi
63
65
  ${END_MARKER}
@@ -116,11 +118,21 @@ export function uninstallHook() {
116
118
  return { path: target, action: "removed" };
117
119
  }
118
120
 
121
+ /**
122
+ * `current` is what tells a caller an installed hook still needs rewriting.
123
+ *
124
+ * A hook is generated shell holding one invocation, and that invocation can go
125
+ * stale — the machine gained a `preman` that is not ours, or lost the one that
126
+ * was. "Installed" then means a file exists that calls the wrong thing on every
127
+ * push, so anything that skips work when the hook is present has to be able to
128
+ * tell the difference.
129
+ */
119
130
  export function hookStatus() {
120
131
  const target = hookPath();
121
- if (!existsSync(target)) return { path: target, state: "absent" };
132
+ if (!existsSync(target)) return { path: target, state: "absent", current: false };
122
133
  const existing = readFileSync(target, "utf8");
123
- return { path: target, state: isOurHook(existing) ? "installed" : "foreign" };
134
+ if (!isOurHook(existing)) return { path: target, state: "foreign", current: false };
135
+ return { path: target, state: "installed", current: existing === hookBody() };
124
136
  }
125
137
 
126
138
  export async function hookCommand(commandArgs = []) {
@@ -17,6 +17,7 @@
17
17
  import {
18
18
  assertOk,
19
19
  callBackendJson,
20
+ cliInvocation,
20
21
  frontendUrl,
21
22
  openUrl,
22
23
  promptText,
@@ -74,8 +75,10 @@ export class Unrecoverable extends Error {}
74
75
  * Returns the truthy value from ``check``, or null on timeout. Ordinary
75
76
  * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
76
77
  */
77
- async function waitFor(label, check) {
78
- const deadline = Date.now() + POLL_TIMEOUT_MS;
78
+ async function waitFor(label, check, { hint = "", hintAfterMs = 60000 } = {}) {
79
+ const startedAt = Date.now();
80
+ const deadline = startedAt + POLL_TIMEOUT_MS;
81
+ let hinted = false;
79
82
  process.stdout.write(`Waiting for ${label}`);
80
83
  while (Date.now() < deadline) {
81
84
  try {
@@ -91,6 +94,12 @@ async function waitFor(label, check) {
91
94
  }
92
95
  /* keep waiting; the customer is elsewhere */
93
96
  }
97
+ // A minute of dots is indistinguishable from a hang, and the reason this
98
+ // waits forever is usually something the customer can act on.
99
+ if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
100
+ hinted = true;
101
+ process.stdout.write(`\n${hint}\nStill waiting`);
102
+ }
94
103
  process.stdout.write(".");
95
104
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
96
105
  }
@@ -101,7 +110,7 @@ async function waitFor(label, check) {
101
110
  function requireKey(args) {
102
111
  const apiKey = resolveApiKey(args);
103
112
  if (!apiKey) {
104
- throw new Error("Not signed in. Run 'preman login' first.");
113
+ throw new Error(`Not signed in. Run '${cliInvocation()} login' first.`);
105
114
  }
106
115
  return apiKey;
107
116
  }
@@ -156,7 +165,7 @@ export async function awsCommand(args) {
156
165
  }
157
166
 
158
167
  if (!verified) {
159
- process.stdout.write("Timed out. Re-run 'preman aws' once the stack finishes.\n");
168
+ process.stdout.write(`Timed out. Re-run '${cliInvocation()} aws' once the stack finishes.\n`);
160
169
  return;
161
170
  }
162
171
  connected(`AWS connected: ${verified.role_arn}`);
@@ -223,23 +232,35 @@ export async function githubCommand(args) {
223
232
  present(url, "install the PreMan GitHub App");
224
233
  process.stdout.write("Pick the repositories PreMan may read.\n");
225
234
 
226
- const done = await waitFor("the installation", async () => {
227
- // Installing the App and having repositories appear are two events: the
228
- // callback records the installation, and a refresh materialises the repos.
229
- // Polling the repo list alone waits for something that may never arrive on
230
- // its own.
231
- await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
232
- token,
233
- json: {},
234
- });
235
- // Compare against what existed before, so a user who already had repos
236
- // connected is not told they are done the moment polling starts.
237
- const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
238
- return fresh.length ? fresh : null;
239
- });
235
+ const done = await waitFor(
236
+ "the installation",
237
+ async () => {
238
+ // Installing the App and having repositories appear are two events: the
239
+ // callback records the installation, and a refresh materialises the repos.
240
+ // Polling the repo list alone waits for something that may never arrive on
241
+ // its own.
242
+ await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
243
+ token,
244
+ json: {},
245
+ });
246
+ // Compare against what existed before, so a user who already had repos
247
+ // connected is not told they are done the moment polling starts.
248
+ const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
249
+ return fresh.length ? fresh : null;
250
+ },
251
+ {
252
+ hint:
253
+ "PreMan has not heard from GitHub yet. Finish the install in the browser tab —\n" +
254
+ "pick at least one repository and confirm — and GitHub will send you back here.",
255
+ }
256
+ );
240
257
 
241
258
  if (!done) {
242
- process.stdout.write("Timed out. Re-run 'preman github' if the install finished.\n");
259
+ process.stdout.write(
260
+ `Timed out: GitHub never told PreMan about an installation.\n` +
261
+ ` - Check that the App is installed: https://github.com/settings/installations\n` +
262
+ ` - Then re-run '${cliInvocation()} github'.\n`
263
+ );
243
264
  return;
244
265
  }
245
266
  connected(`GitHub connected: ${done.length} repository(ies).`);
@@ -270,7 +291,7 @@ export async function slackCommand(args) {
270
291
  });
271
292
 
272
293
  if (!done) {
273
- process.stdout.write("Timed out. Re-run 'preman slack' if the install finished.\n");
294
+ process.stdout.write(`Timed out. Re-run '${cliInvocation()} slack' if the install finished.\n`);
274
295
  return;
275
296
  }
276
297
  connected(`Slack connected: ${done[0].team_name || done[0].id}`);
package/bin/runner.js CHANGED
@@ -24,7 +24,16 @@
24
24
 
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import { createHash } from "node:crypto";
27
- import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import {
28
+ chmodSync,
29
+ existsSync,
30
+ mkdirSync,
31
+ openSync,
32
+ readFileSync,
33
+ rmSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from "node:fs";
28
37
  import os from "node:os";
29
38
  import path from "node:path";
30
39
  import { fileURLToPath } from "node:url";
@@ -603,6 +612,32 @@ function defaultLog(message) {
603
612
  process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
604
613
  }
605
614
 
615
+ /**
616
+ * One heartbeat. Never throws.
617
+ *
618
+ * It runs on a timer, and a rejected fetch inside a timer callback has nobody to
619
+ * catch it — Node turns that unhandled rejection into process exit, so a single
620
+ * connect timeout to the backend used to take the whole daemon down mid-run and
621
+ * leave a machine that reads as paired and never runs anything again. A missed
622
+ * heartbeat only costs one TTL window: the next one puts this runner back online.
623
+ */
624
+ export async function sendHeartbeat(args, state, { busy = false, log = () => {} } = {}) {
625
+ try {
626
+ // Reported honestly because the matcher prefers idle runners: a busy device
627
+ // claiming to be idle wins work it will only sit on until the lease expires.
628
+ const result = await callBackendJson(
629
+ args,
630
+ "POST",
631
+ "/workbench/coding-agent/local-runner/heartbeat",
632
+ { token: state.runner_token, json: { state: busy ? "busy" : "idle" } }
633
+ );
634
+ return { revoked: result.status_code === 401 };
635
+ } catch (error) {
636
+ log(`heartbeat failed: ${error.message}`);
637
+ return { revoked: false };
638
+ }
639
+ }
640
+
606
641
  /**
607
642
  * Hold the stream and run what it leases, until stopped.
608
643
  *
@@ -624,17 +659,12 @@ export async function runnerLoop(
624
659
  `${state.backend_url || backendUrl(args)}/`
625
660
  );
626
661
 
627
- const heartbeat = setInterval(async () => {
628
- // Reported honestly because the matcher prefers idle runners: a busy device
629
- // claiming to be idle wins work it will only sit on until the lease expires.
630
- const result = await callBackendJson(args, "POST", "/workbench/coding-agent/local-runner/heartbeat", {
631
- token: state.runner_token,
632
- json: { state: busy ? "busy" : "idle" },
633
- });
634
- if (result.status_code === 401) {
662
+ const heartbeat = setInterval(() => {
663
+ void sendHeartbeat(args, state, { busy, log }).then(({ revoked }) => {
664
+ if (!revoked) return;
635
665
  log("runner registration is no longer active; stopping");
636
666
  stopped = true;
637
- }
667
+ });
638
668
  }, HEARTBEAT_MS);
639
669
 
640
670
  try {
@@ -739,9 +769,56 @@ Runner options:
739
769
  --full-access Let the agent run commands, not just edit files
740
770
  `;
741
771
 
772
+ function logSize() {
773
+ try {
774
+ return statSync(RUNNER_LOG_FILE).size;
775
+ } catch {
776
+ return 0;
777
+ }
778
+ }
779
+
780
+ /** Whatever the daemon has logged since `offset`, exactly (the log is appended to). */
781
+ export function runnerLogSince(offset = 0) {
782
+ try {
783
+ const buffer = readFileSync(RUNNER_LOG_FILE);
784
+ return buffer.subarray(Math.min(Math.max(0, offset), buffer.length)).toString("utf8");
785
+ } catch {
786
+ return "";
787
+ }
788
+ }
789
+
790
+ function tailLine(text, cap = 240) {
791
+ const lines = String(text || "")
792
+ .split("\n")
793
+ .map((line) => line.trim())
794
+ .filter(Boolean);
795
+ return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
796
+ }
797
+
798
+ /**
799
+ * Wait for a just-spawned daemon to prove it is up.
800
+ *
801
+ * A pid from `spawn` is not a running runner: registration can be rejected, the
802
+ * token can be dead, the stream can refuse the connection, and every one of those
803
+ * exits within a second or two of a "Runner running (pid …)" line nobody had any
804
+ * reason to doubt. The log offset is taken before the spawn so an earlier
805
+ * daemon's "connected" line cannot be mistaken for this one's.
806
+ */
807
+ export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000, intervalMs = 250 } = {}) {
808
+ const deadline = Date.now() + timeoutMs;
809
+ for (;;) {
810
+ const fresh = runnerLogSince(offset);
811
+ if (/connected as runner/.test(fresh)) return { state: "online", detail: "" };
812
+ if (!runnerIsAlive(pid)) return { state: "exited", detail: tailLine(fresh) };
813
+ if (Date.now() >= deadline) return { state: "starting", detail: tailLine(fresh) };
814
+ await sleep(intervalMs);
815
+ }
816
+ }
817
+
742
818
  /** Start the daemon detached, so a terminal can be closed without killing it. */
743
819
  export function startBackground(commandArgs) {
744
820
  ensureDir();
821
+ const offset = logSize();
745
822
  // 0600 like everything else under ~/.preman: nothing in here is a credential
746
823
  // today, and that is not a property to leave depending on future log lines.
747
824
  const log = openSync(RUNNER_LOG_FILE, "a", 0o600);
@@ -753,7 +830,7 @@ export function startBackground(commandArgs) {
753
830
  );
754
831
  child.unref();
755
832
  writeFileSync(RUNNER_PID_FILE, `${child.pid}\n`, { mode: 0o600 });
756
- return { pid: child.pid, log: RUNNER_LOG_FILE };
833
+ return { pid: child.pid, log: RUNNER_LOG_FILE, offset };
757
834
  }
758
835
 
759
836
  async function startForeground(args, commandArgs) {
@@ -786,6 +863,16 @@ async function startForeground(args, commandArgs) {
786
863
  process.on("SIGINT", () => void shutdown("SIGINT"));
787
864
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
788
865
 
866
+ // A daemon that dies must not leave its pid file behind claiming otherwise.
867
+ // Nothing is swallowed: the reason is logged and the exit code says it failed.
868
+ const die = (kind) => (error) => {
869
+ log(`${kind}: ${error?.stack || error}`);
870
+ rmSync(RUNNER_PID_FILE, { force: true });
871
+ process.exit(1);
872
+ };
873
+ process.on("uncaughtException", die("uncaught exception"));
874
+ process.on("unhandledRejection", die("unhandled rejection"));
875
+
789
876
  try {
790
877
  const result = await runnerLoop(args, state, {
791
878
  log,
@@ -827,10 +914,20 @@ export async function runnerCommand(commandArgs = []) {
827
914
  return { state: "running", pid: readPid() };
828
915
  }
829
916
  const started = startBackground(commandArgs.filter((value) => value !== "start"));
917
+ const up = await confirmRunnerOnline(started);
918
+ if (up.state === "exited") {
919
+ rmSync(RUNNER_PID_FILE, { force: true });
920
+ process.stdout.write(
921
+ `Runner exited right after starting.\n` +
922
+ (up.detail ? ` ${up.detail}\n` : "") +
923
+ ` log: ${started.log}\n`
924
+ );
925
+ return { ...started, state: "exited" };
926
+ }
830
927
  process.stdout.write(
831
- `Runner started in the background (pid ${started.pid}).\n log: ${started.log}\n`
928
+ `Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}).\n log: ${started.log}\n`
832
929
  );
833
- return started;
930
+ return { ...started, state: up.state };
834
931
  }
835
932
  await startForeground(args, commandArgs);
836
933
  return { state: "stopped" };
package/bin/shared.js CHANGED
@@ -8,7 +8,15 @@
8
8
  */
9
9
 
10
10
  import { spawn, spawnSync } from "node:child_process";
11
- import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ mkdirSync,
19
+ } from "node:fs";
12
20
  import os from "node:os";
13
21
  import path from "node:path";
14
22
  import { createInterface } from "node:readline/promises";
@@ -29,16 +37,55 @@ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
29
37
  */
30
38
  let _invocation = null;
31
39
 
32
- export function cliInvocation() {
33
- if (_invocation) return _invocation;
40
+ /**
41
+ * The package that owns the first `preman` on PATH, or "".
42
+ *
43
+ * `preman` is not ours by name alone: `preman-sdk` publishes a bin called
44
+ * exactly that, and whichever package lost the race still resolves. Assuming it
45
+ * is us writes hints and a git hook that call the other CLI — which answers
46
+ * "Unknown command: verify" and turns every push into a silent
47
+ * "[preman] checks skipped", the worst possible failure for a tool whose whole
48
+ * job is running checks on push.
49
+ */
50
+ export function pathPremanOwner() {
34
51
  const probe = process.platform === "win32" ? "where" : "which";
52
+ let resolved = "";
35
53
  try {
36
54
  const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
37
- _invocation = found.status === 0 && found.stdout.trim() ? "preman" : null;
55
+ if (found.status !== 0) return "";
56
+ resolved = String(found.stdout || "").split("\n")[0].trim();
38
57
  } catch {
39
- _invocation = null;
58
+ return "";
40
59
  }
41
- return (_invocation ||= "npm exec -y premanmcp@latest --");
60
+ if (!resolved) return "";
61
+
62
+ // Walk up from the real file, not the symlink: a bin is a link into the
63
+ // package directory, and only that directory's manifest names the owner.
64
+ try {
65
+ let dir = path.dirname(realpathSync(resolved));
66
+ for (let depth = 0; depth < 5; depth += 1) {
67
+ const manifest = path.join(dir, "package.json");
68
+ if (existsSync(manifest)) {
69
+ return String(JSON.parse(readFileSync(manifest, "utf8")).name || "");
70
+ }
71
+ const parent = path.dirname(dir);
72
+ if (parent === dir) break;
73
+ dir = parent;
74
+ }
75
+ } catch {
76
+ return "";
77
+ }
78
+ return "";
79
+ }
80
+
81
+ export function cliInvocation() {
82
+ return (_invocation ||=
83
+ pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
84
+ }
85
+
86
+ /** Test seam: `cliInvocation` memoizes a PATH probe for the life of the process. */
87
+ export function resetCliInvocation() {
88
+ _invocation = null;
42
89
  }
43
90
 
44
91
  export function makeArgs(commandArgs = []) {
package/dist/server.js CHANGED
@@ -1043,6 +1043,7 @@ export function createServer() {
1043
1043
  backend_url: BACKEND_URL,
1044
1044
  frontend_base_url: FRONTEND_BASE,
1045
1045
  endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
1046
+ config: configSource(),
1046
1047
  message: "Not authenticated. Run `npm exec -y premanmcp@latest -- login`, or use user_auth_* then preman_create_api_key, or run preman_login.",
1047
1048
  }),
1048
1049
  }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,9 +13,9 @@
13
13
  "dev": "tsx src/server.ts",
14
14
  "open-mcp-preview": "node scripts/emit-mcp-preview.mjs",
15
15
  "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
- "test": "npm run test:connect && npm run test:node",
16
+ "test": "npm run build && npm run test:connect && npm run test:node",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
- "test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
18
+ "test:node": "node --test --test-timeout=30000 scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs"
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/ext-apps": "^0.1.0",