premanmcp 0.10.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/README.md CHANGED
@@ -68,9 +68,13 @@ Once linked, `connect` finishes onboarding without handing you homework:
68
68
  | Testing on push | Installs the git pre-push hook, so `git push` checks the endpoints you touched |
69
69
 
70
70
  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.
71
+ project-local config, `--print` shows the config without writing it, `--yes` takes every
72
+ step's default without asking, `--no-guide` skips all of them, and `--no-runner` /
73
+ `--no-desktop` / `--no-integrations` / `--no-hook` skip one each.
74
+
75
+ `--yes` deliberately does *not* install the desktop app: that step's default is no, because
76
+ it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
77
+ when you want it.
74
78
 
75
79
  In CI or any non-interactive shell, run `connect --agent <name> --api-key pm_live_…`.
76
80
  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();
@@ -878,8 +908,17 @@ function nextStepsBlock(agent) {
878
908
  );
879
909
  }
880
910
 
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
+ */
881
920
  async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
882
- if (assumeYes) return true;
921
+ if (assumeYes) return defaultYes;
883
922
  if (!process.stdin.isTTY) return false;
884
923
  const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
885
924
  if (answer === "") return defaultYes;
@@ -951,7 +990,11 @@ async function discoverEndpoints(args, agent, serverName) {
951
990
 
952
991
  const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
953
992
  const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
954
- if (!spec || !onPath(spec.bin)) {
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`);
955
998
  for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
956
999
  process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
957
1000
  return before;
@@ -963,7 +1006,11 @@ async function discoverEndpoints(args, agent, serverName) {
963
1006
  const after = await endpointCounts(args);
964
1007
 
965
1008
  if (!after.registered) {
966
- const why = outcome.reason || lastLine(outcome.output) || "it registered nothing";
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";
967
1014
  process.stdout.write(
968
1015
  `${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
969
1016
  ` Run it yourself: ${cliInvocation()} endpoints discover\n`
@@ -1069,10 +1116,28 @@ async function setUpRunner(args, agent, { assumeYes }) {
1069
1116
  await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
1070
1117
  }
1071
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
+ }
1072
1131
  process.stdout.write(
1073
- `${MARK.ok()} Runner running (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1132
+ `${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
1074
1133
  ` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
1075
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
+ }
1076
1141
  return { state: "running", pid: started.pid };
1077
1142
  } catch (error) {
1078
1143
  process.stdout.write(
@@ -1178,8 +1243,14 @@ async function connectIntegrations(args, apiKey, { assumeYes }) {
1178
1243
  }
1179
1244
  }
1180
1245
 
1181
- /** Install the pre-push hook, so a push is what triggers the tests. */
1182
- async function setUpPushTesting(args, { assumeYes }) {
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) {
1183
1254
  if (args.has("--no-hook")) return { state: "skipped" };
1184
1255
  const current = (() => {
1185
1256
  try {
@@ -1192,14 +1263,12 @@ async function setUpPushTesting(args, { assumeYes }) {
1192
1263
  process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
1193
1264
  return { state: "unavailable" };
1194
1265
  }
1195
- if (current.state === "installed") {
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) {
1196
1269
  process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
1197
1270
  return { state: "installed" };
1198
1271
  }
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
1272
 
1204
1273
  const result = installHook(args);
1205
1274
  if (result.action === "conflict") {
@@ -1242,7 +1311,7 @@ async function guidedFirstRun(args, agent, apiKey, serverName) {
1242
1311
  await connectIntegrations(args, apiKey, { assumeYes });
1243
1312
 
1244
1313
  step("Testing on push");
1245
- await setUpPushTesting(args, { assumeYes });
1314
+ await setUpPushTesting(args);
1246
1315
 
1247
1316
  process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
1248
1317
  }
@@ -1273,6 +1342,16 @@ export async function preflight(args) {
1273
1342
  );
1274
1343
  }
1275
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
+
1276
1355
  try {
1277
1356
  const resp = await fetch(new URL("health", `${backendUrl(args)}/`), {
1278
1357
  signal: AbortSignal.timeout(4000),
@@ -1315,7 +1394,8 @@ Connect options:
1315
1394
  --no-desktop Do not offer the desktop app
1316
1395
  --no-integrations Do not check or offer GitHub / AWS / Slack
1317
1396
  --no-hook Do not install the git pre-push hook
1318
- --yes Accept every optional step without prompting
1397
+ --yes Take every step's default without prompting
1398
+ (the desktop app defaults to no; install-desktop)
1319
1399
  --print Print the config instead of writing it
1320
1400
  `;
1321
1401
 
@@ -1482,7 +1562,8 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
1482
1562
  );
1483
1563
  }
1484
1564
 
1485
- if (!args.has("--no-auto-checkin")) {
1565
+ const blocker = agentBlocker(agent.id);
1566
+ if (!args.has("--no-auto-checkin") && !blocker) {
1486
1567
  const spec = headlessCheckIn(agent, serverName);
1487
1568
  if (spec && onPath(spec.bin)) {
1488
1569
  process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
@@ -1500,6 +1581,11 @@ async function establishCheckIn(args, agent, apiKey, { serverName, written, serv
1500
1581
  } else if (auto.reason) {
1501
1582
  process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
1502
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);
1503
1589
  }
1504
1590
 
1505
1591
  process.stdout.write(
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";
@@ -739,9 +748,56 @@ Runner options:
739
748
  --full-access Let the agent run commands, not just edit files
740
749
  `;
741
750
 
751
+ function logSize() {
752
+ try {
753
+ return statSync(RUNNER_LOG_FILE).size;
754
+ } catch {
755
+ return 0;
756
+ }
757
+ }
758
+
759
+ /** Whatever the daemon has logged since `offset`, exactly (the log is appended to). */
760
+ export function runnerLogSince(offset = 0) {
761
+ try {
762
+ const buffer = readFileSync(RUNNER_LOG_FILE);
763
+ return buffer.subarray(Math.min(Math.max(0, offset), buffer.length)).toString("utf8");
764
+ } catch {
765
+ return "";
766
+ }
767
+ }
768
+
769
+ function tailLine(text, cap = 240) {
770
+ const lines = String(text || "")
771
+ .split("\n")
772
+ .map((line) => line.trim())
773
+ .filter(Boolean);
774
+ return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
775
+ }
776
+
777
+ /**
778
+ * Wait for a just-spawned daemon to prove it is up.
779
+ *
780
+ * A pid from `spawn` is not a running runner: registration can be rejected, the
781
+ * token can be dead, the stream can refuse the connection, and every one of those
782
+ * exits within a second or two of a "Runner running (pid …)" line nobody had any
783
+ * reason to doubt. The log offset is taken before the spawn so an earlier
784
+ * daemon's "connected" line cannot be mistaken for this one's.
785
+ */
786
+ export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000, intervalMs = 250 } = {}) {
787
+ const deadline = Date.now() + timeoutMs;
788
+ for (;;) {
789
+ const fresh = runnerLogSince(offset);
790
+ if (/connected as runner/.test(fresh)) return { state: "online", detail: "" };
791
+ if (!runnerIsAlive(pid)) return { state: "exited", detail: tailLine(fresh) };
792
+ if (Date.now() >= deadline) return { state: "starting", detail: tailLine(fresh) };
793
+ await sleep(intervalMs);
794
+ }
795
+ }
796
+
742
797
  /** Start the daemon detached, so a terminal can be closed without killing it. */
743
798
  export function startBackground(commandArgs) {
744
799
  ensureDir();
800
+ const offset = logSize();
745
801
  // 0600 like everything else under ~/.preman: nothing in here is a credential
746
802
  // today, and that is not a property to leave depending on future log lines.
747
803
  const log = openSync(RUNNER_LOG_FILE, "a", 0o600);
@@ -753,7 +809,7 @@ export function startBackground(commandArgs) {
753
809
  );
754
810
  child.unref();
755
811
  writeFileSync(RUNNER_PID_FILE, `${child.pid}\n`, { mode: 0o600 });
756
- return { pid: child.pid, log: RUNNER_LOG_FILE };
812
+ return { pid: child.pid, log: RUNNER_LOG_FILE, offset };
757
813
  }
758
814
 
759
815
  async function startForeground(args, commandArgs) {
@@ -827,10 +883,20 @@ export async function runnerCommand(commandArgs = []) {
827
883
  return { state: "running", pid: readPid() };
828
884
  }
829
885
  const started = startBackground(commandArgs.filter((value) => value !== "start"));
886
+ const up = await confirmRunnerOnline(started);
887
+ if (up.state === "exited") {
888
+ rmSync(RUNNER_PID_FILE, { force: true });
889
+ process.stdout.write(
890
+ `Runner exited right after starting.\n` +
891
+ (up.detail ? ` ${up.detail}\n` : "") +
892
+ ` log: ${started.log}\n`
893
+ );
894
+ return { ...started, state: "exited" };
895
+ }
830
896
  process.stdout.write(
831
- `Runner started in the background (pid ${started.pid}).\n log: ${started.log}\n`
897
+ `Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}).\n log: ${started.log}\n`
832
898
  );
833
- return started;
899
+ return { ...started, state: up.state };
834
900
  }
835
901
  await startForeground(args, commandArgs);
836
902
  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.1",
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-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",