premanmcp 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/hook.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `preman hook install|uninstall|status` — the git pre-push entry point.
3
+ *
4
+ * The single most important property here: **PreMan must never block a push.**
5
+ * No backend, no credentials, no detectable local target, a crash, or a timeout
6
+ * all exit 0 with a notice. A tool that stops `git push` when its own
7
+ * infrastructure is down gets uninstalled the same day, so the hook is advisory
8
+ * by construction rather than by configuration.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+
15
+ import { cliInvocation, makeArgs } from "./shared.js";
16
+ import { BLOCK_EXIT_CODE } from "./verify.js";
17
+
18
+ export const HOOK_HELP = `
19
+ Hook options:
20
+ install Write the pre-push hook into this repository
21
+ uninstall Remove PreMan's pre-push hook
22
+ status Report whether the hook is installed
23
+ --force Overwrite a foreign pre-push hook (a backup is kept)
24
+ `;
25
+
26
+ const MARKER = "# >>> preman pre-push >>>";
27
+ const END_MARKER = "# <<< preman pre-push <<<";
28
+ const HOOK_TIMEOUT_SECONDS = 120;
29
+
30
+ function gitDir() {
31
+ const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
32
+ if (result.status !== 0) {
33
+ throw new Error("not inside a git repository");
34
+ }
35
+ return path.resolve(result.stdout.trim());
36
+ }
37
+
38
+ function hookPath() {
39
+ return path.join(gitDir(), "hooks", "pre-push");
40
+ }
41
+
42
+ function hookBody() {
43
+ // `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
44
+ // non-zero and still exit 0 itself.
45
+ //
46
+ // Git's ref payload arrives on this script's stdin and is what tells PreMan
47
+ // which commits are actually being pushed, so it is piped straight through.
48
+ return `#!/bin/sh
49
+ ${MARKER}
50
+ # Managed by PreMan. Runs the local endpoint suite before the push leaves.
51
+ # Advisory unless this repository opted into blocking: only exit code
52
+ # ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
53
+ if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
54
+ PREMAN_HOOK=1 ${cliInvocation()} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
55
+ preman_status=$?
56
+ if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
57
+ exit ${BLOCK_EXIT_CODE}
58
+ fi
59
+ if [ "$preman_status" -ne 0 ]; then
60
+ printf '[preman] checks skipped (%s)\\n' "advisory" >&2
61
+ fi
62
+ fi
63
+ ${END_MARKER}
64
+ exit 0
65
+ `;
66
+ }
67
+
68
+ function isOurHook(text) {
69
+ return text.includes(MARKER);
70
+ }
71
+
72
+ export function installHook(args) {
73
+ const target = hookPath();
74
+ mkdirSync(path.dirname(target), { recursive: true });
75
+
76
+ if (existsSync(target)) {
77
+ const existing = readFileSync(target, "utf8");
78
+ if (isOurHook(existing)) {
79
+ writeFileSync(target, hookBody(), { mode: 0o755 });
80
+ chmodSync(target, 0o755);
81
+ return { path: target, action: "updated" };
82
+ }
83
+ if (!args.has("--force")) {
84
+ return {
85
+ path: target,
86
+ action: "conflict",
87
+ detail: "a pre-push hook already exists; re-run with --force to replace it",
88
+ };
89
+ }
90
+ const backup = `${target}.preman-backup`;
91
+ writeFileSync(backup, existing, { mode: 0o755 });
92
+ writeFileSync(target, hookBody(), { mode: 0o755 });
93
+ chmodSync(target, 0o755);
94
+ return { path: target, action: "replaced", detail: `previous hook saved to ${backup}` };
95
+ }
96
+
97
+ writeFileSync(target, hookBody(), { mode: 0o755 });
98
+ chmodSync(target, 0o755);
99
+ return { path: target, action: "installed" };
100
+ }
101
+
102
+ export function uninstallHook() {
103
+ const target = hookPath();
104
+ if (!existsSync(target)) return { path: target, action: "absent" };
105
+ const existing = readFileSync(target, "utf8");
106
+ if (!isOurHook(existing)) {
107
+ return { path: target, action: "foreign", detail: "left in place; PreMan did not write it" };
108
+ }
109
+ unlinkSync(target);
110
+ const backup = `${target}.preman-backup`;
111
+ if (existsSync(backup)) {
112
+ writeFileSync(target, readFileSync(backup, "utf8"), { mode: 0o755 });
113
+ unlinkSync(backup);
114
+ return { path: target, action: "restored", detail: "previous hook restored from backup" };
115
+ }
116
+ return { path: target, action: "removed" };
117
+ }
118
+
119
+ export function hookStatus() {
120
+ const target = hookPath();
121
+ if (!existsSync(target)) return { path: target, state: "absent" };
122
+ const existing = readFileSync(target, "utf8");
123
+ return { path: target, state: isOurHook(existing) ? "installed" : "foreign" };
124
+ }
125
+
126
+ export async function hookCommand(commandArgs = []) {
127
+ const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
128
+ const args = makeArgs(commandArgs);
129
+
130
+ if (sub === "install") {
131
+ const result = installHook(args);
132
+ if (result.action === "conflict") {
133
+ process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
134
+ return result;
135
+ }
136
+ process.stdout.write(
137
+ `Pre-push hook ${result.action}: ${result.path}\n` +
138
+ (result.detail ? ` ${result.detail}\n` : "") +
139
+ `\nPreMan will now check affected endpoints before each push.\n` +
140
+ `It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
141
+ );
142
+ return result;
143
+ }
144
+
145
+ if (sub === "uninstall") {
146
+ const result = uninstallHook();
147
+ process.stdout.write(
148
+ `Pre-push hook ${result.action}: ${result.path}\n` + (result.detail ? ` ${result.detail}\n` : "")
149
+ );
150
+ return result;
151
+ }
152
+
153
+ if (sub === "status") {
154
+ const result = hookStatus();
155
+ const label = {
156
+ installed: "installed (PreMan)",
157
+ foreign: "present, but not written by PreMan",
158
+ absent: "not installed",
159
+ }[result.state];
160
+ process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
161
+ return result;
162
+ }
163
+
164
+ throw new Error(`unknown hook subcommand: ${sub}${HOOK_HELP}`);
165
+ }
@@ -14,12 +14,11 @@
14
14
  * hand, and no third-party credential ever touches the terminal.
15
15
  */
16
16
 
17
- import { spawn } from "node:child_process";
18
-
19
17
  import {
20
18
  assertOk,
21
19
  callBackendJson,
22
20
  frontendUrl,
21
+ openUrl,
23
22
  promptText,
24
23
  resolveApiKey,
25
24
  } from "./shared.js";
@@ -42,9 +41,9 @@ function paint(code, text) {
42
41
  }
43
42
 
44
43
  export const MARK = {
45
- ok: () => paint("32", "✓"),
46
- skip: () => paint("90", "○"),
47
- fail: () => paint("31", "✗"),
44
+ ok: () => paint("32", "?"),
45
+ skip: () => paint("90", "?"),
46
+ fail: () => paint("31", "?"),
48
47
  };
49
48
 
50
49
  /** A finished step, marked so it can be found at a glance. */
@@ -52,25 +51,6 @@ export function connected(message) {
52
51
  process.stdout.write(`${MARK.ok()} ${message}\n`);
53
52
  }
54
53
 
55
- /** Open a URL in the customer's default browser, best effort. */
56
- export function openUrl(url) {
57
- const command =
58
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
59
- try {
60
- const child = spawn(command, [url], {
61
- stdio: "ignore",
62
- detached: true,
63
- shell: process.platform === "win32",
64
- });
65
- child.unref();
66
- return true;
67
- } catch {
68
- // Headless boxes and locked-down shells are normal. The URL is printed
69
- // either way, so this is a convenience, never the only path.
70
- return false;
71
- }
72
- }
73
-
74
54
  function present(url, what) {
75
55
  process.stdout.write(`\n ${url}\n\n`);
76
56
  if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
@@ -349,7 +329,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
349
329
  let i = 0;
350
330
  while (i < steps.length) {
351
331
  const step = steps[i];
352
- process.stdout.write(`\n── ${step.name} ──\n`);
332
+ process.stdout.write(`\n?? ${step.name} ??\n`);
353
333
 
354
334
  const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
355
335
  if (choice === "back") {
@@ -377,7 +357,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
377
357
  i += 1;
378
358
  }
379
359
 
380
- process.stdout.write("\n── done ──\n");
360
+ process.stdout.write("\n?? done ??\n");
381
361
  // One line per step, marked, so the outcome is scannable rather than prose.
382
362
  for (const step of steps) {
383
363
  const result = outcome.get(step.name);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Live terminal rendering for concurrent work.
3
+ *
4
+ * A pre-push run is the moment the developer is watching, so silence for the
5
+ * length of a 120-second budget reads as a hang. This renders one line per
6
+ * worker, updated in place, with finished results scrolling above them.
7
+ *
8
+ * Everything degrades to plain sequential lines when stdout is not a TTY. A
9
+ * hook's output is routinely piped, captured by a GUI git client, or read in
10
+ * CI, and cursor-movement escapes in a log file are worse than no animation.
11
+ */
12
+
13
+ const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
14
+ const FRAME_MS = 80;
15
+
16
+ const CLEAR_LINE = "\u001b[2K";
17
+ const HIDE_CURSOR = "\u001b[?25l";
18
+ const SHOW_CURSOR = "\u001b[?25h";
19
+
20
+ const GREEN = "\u001b[32m";
21
+ const RED = "\u001b[31m";
22
+ const YELLOW = "\u001b[33m";
23
+ const DIM = "\u001b[2m";
24
+ const RESET = "\u001b[0m";
25
+
26
+ function truncate(text, width) {
27
+ const value = String(text ?? "");
28
+ return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}\u2026`;
29
+ }
30
+
31
+ export function createReporter({ stream = process.stdout, live } = {}) {
32
+ const isLive = live ?? Boolean(stream.isTTY);
33
+ const slots = new Map();
34
+ let rendered = 0;
35
+ let frame = 0;
36
+ let timer = null;
37
+ const colour = (code, text) => (isLive ? `${code}${text}${RESET}` : text);
38
+
39
+ function width() {
40
+ return Math.max(40, Math.min(stream.columns || 100, 140));
41
+ }
42
+
43
+ function clearLive() {
44
+ if (!isLive || rendered === 0) return;
45
+ stream.write(`\u001b[${rendered}A`);
46
+ for (let i = 0; i < rendered; i += 1) stream.write(`${CLEAR_LINE}\n`);
47
+ stream.write(`\u001b[${rendered}A`);
48
+ rendered = 0;
49
+ }
50
+
51
+ function drawLive() {
52
+ if (!isLive || slots.size === 0) return;
53
+ const spin = FRAMES[frame % FRAMES.length];
54
+ for (const label of slots.values()) {
55
+ stream.write(`${CLEAR_LINE} ${colour(YELLOW, spin)} ${truncate(label, width() - 6)}\n`);
56
+ }
57
+ rendered = slots.size;
58
+ }
59
+
60
+ function repaint() {
61
+ clearLive();
62
+ drawLive();
63
+ }
64
+
65
+ return {
66
+ isLive,
67
+
68
+ /** Print a line that stays in the scrollback, above the live region. */
69
+ line(text) {
70
+ clearLive();
71
+ stream.write(`${text}\n`);
72
+ drawLive();
73
+ },
74
+
75
+ /** Claim a worker line. `slot` is any stable key for that worker. */
76
+ busy(slot, label) {
77
+ slots.set(slot, label);
78
+ if (!isLive) return;
79
+ if (timer === null) {
80
+ stream.write(HIDE_CURSOR);
81
+ timer = setInterval(() => {
82
+ frame += 1;
83
+ repaint();
84
+ }, FRAME_MS);
85
+ if (typeof timer.unref === "function") timer.unref();
86
+ }
87
+ repaint();
88
+ },
89
+
90
+ /** Release a worker line and record its outcome in the scrollback. */
91
+ done(slot, { ok, skipped, label, detail }) {
92
+ slots.delete(slot);
93
+ const mark = skipped ? colour(DIM, "\u00b7") : ok ? colour(GREEN, "\u2713") : colour(RED, "\u2717");
94
+ const tail = detail ? ` ${colour(DIM, detail)}` : "";
95
+ clearLive();
96
+ stream.write(` ${mark} ${truncate(label, width() - 12)}${tail}\n`);
97
+ drawLive();
98
+ },
99
+
100
+ stop() {
101
+ if (timer !== null) {
102
+ clearInterval(timer);
103
+ timer = null;
104
+ }
105
+ clearLive();
106
+ slots.clear();
107
+ if (isLive) stream.write(SHOW_CURSOR);
108
+ },
109
+ };
110
+ }
package/bin/shared.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * over a module-level argv, so a command can pass its own slice.
8
8
  */
9
9
 
10
- import { spawnSync } from "node:child_process";
10
+ import { spawn, spawnSync } from "node:child_process";
11
11
  import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
@@ -101,6 +101,36 @@ export function frontendUrl(args) {
101
101
  return args.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
102
102
  }
103
103
 
104
+ /**
105
+ * Open a URL in the user's default browser, best effort.
106
+ *
107
+ * Returns false without spawning anything when nobody is there to look at it:
108
+ * `PREMAN_NO_BROWSER` for an explicit no, and a non-TTY stdout for CI, piped
109
+ * output, and test runs. Every caller prints the URL as well, so this is only
110
+ * ever a convenience — a suite that exercises these flows should not be able to
111
+ * throw tabs at whoever is running it.
112
+ */
113
+ export function openUrl(url) {
114
+ const optOut = (process.env.PREMAN_NO_BROWSER || "").trim().toLowerCase();
115
+ if (optOut && !["0", "false", "no"].includes(optOut)) return false;
116
+ if (!process.stdout.isTTY) return false;
117
+
118
+ const opener =
119
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
120
+ try {
121
+ const child = spawn(opener, [url], {
122
+ stdio: "ignore",
123
+ detached: true,
124
+ shell: process.platform === "win32",
125
+ });
126
+ child.unref();
127
+ return true;
128
+ } catch {
129
+ // Headless boxes and locked-down shells are normal.
130
+ return false;
131
+ }
132
+ }
133
+
104
134
  export async function promptText(question) {
105
135
  const rl = createInterface({ input: process.stdin, output: process.stdout });
106
136
  try {
@@ -171,7 +201,12 @@ export async function promptPasswordTwice() {
171
201
  return password;
172
202
  }
173
203
 
174
- export async function callBackendJson(args, method, routePath, { json, token, query } = {}) {
204
+ export async function callBackendJson(
205
+ args,
206
+ method,
207
+ routePath,
208
+ { json, token, query, headers: extraHeaders } = {}
209
+ ) {
175
210
  const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
176
211
  if (query) {
177
212
  for (const [key, value] of Object.entries(query)) {
@@ -183,6 +218,11 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
183
218
  const hasBody = json !== undefined && json !== null;
184
219
  if (hasBody) headers["Content-Type"] = "application/json";
185
220
  if (token) headers.Authorization = `Bearer ${token}`;
221
+ if (extraHeaders) {
222
+ for (const [key, value] of Object.entries(extraHeaders)) {
223
+ if (value != null && value !== "") headers[key] = String(value);
224
+ }
225
+ }
186
226
 
187
227
  const resp = await fetch(url, {
188
228
  method,
@@ -225,7 +265,50 @@ async function verifyUnconfirmedAccount(args, email) {
225
265
  return verified.access_token;
226
266
  }
227
267
 
268
+ /**
269
+ * A password only if the caller asked for one.
270
+ *
271
+ * The terminal's durable credential is the pm_live_ key, so signup does not
272
+ * need a password and no longer asks for one. `--password` is for anyone who
273
+ * wants to be able to sign in to the dashboard immediately; `--password <value>`
274
+ * skips the prompt for scripted runs.
275
+ */
276
+ async function optionalPassword(args) {
277
+ if (!args.has("--password")) return "";
278
+ const supplied = args.value("--password", "");
279
+ // Bare `--password` followed by another flag means "prompt me", not "my
280
+ // password is --backend".
281
+ if (supplied && !supplied.startsWith("--")) {
282
+ if (supplied.length < 6) throw new Error("Password must be at least 6 characters.");
283
+ return supplied;
284
+ }
285
+ return promptPasswordTwice();
286
+ }
287
+
288
+ /**
289
+ * Turn a verification code into a session token.
290
+ *
291
+ * Without a password this is one call: verify-otp signs the user in and the
292
+ * backend keeps the account passwordless, so the dashboard knows to offer a
293
+ * code rather than a password field.
294
+ */
295
+ async function completeWithCode(args, email, otp, password) {
296
+ if (password) {
297
+ const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
298
+ json: { email, otp, new_password: password },
299
+ });
300
+ assertOk(setPassword, "set password");
301
+ return String(setPassword.access_token || "");
302
+ }
303
+ const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
304
+ json: { email, otp },
305
+ });
306
+ assertOk(verified, "verify OTP");
307
+ return String(verified.access_token || "");
308
+ }
309
+
228
310
  async function createAccountFromTerminal(args, email) {
311
+ const password = await optionalPassword(args);
229
312
  const signup = await callBackendJson(args, "POST", "/auth/start-signup", {
230
313
  json: { email },
231
314
  });
@@ -233,24 +316,21 @@ async function createAccountFromTerminal(args, email) {
233
316
  if (signup.ok) {
234
317
  process.stdout.write("Verification code sent. Check your email.\n");
235
318
  const otp = await promptText("Verification code: ");
236
- const password = await promptPasswordTwice();
237
- const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
238
- json: { email, otp, new_password: password },
239
- });
240
- assertOk(setPassword, "set password");
241
- return String(setPassword.access_token || "");
319
+ return completeWithCode(args, email, otp, password);
242
320
  }
243
321
 
244
322
  if (signup.status_code !== 404) {
245
323
  assertOk(signup, "start signup");
246
324
  }
247
325
 
326
+ // Backends predating start-signup have no passwordless route in, so this one
327
+ // path still has to ask.
248
328
  process.stdout.write(
249
329
  "This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
250
330
  );
251
- const password = await promptPasswordTwice();
331
+ const legacyPassword = password || (await promptPasswordTwice());
252
332
  const legacySignup = await callBackendJson(args, "POST", "/auth/signup", {
253
- json: { email, password },
333
+ json: { email, password: legacyPassword },
254
334
  });
255
335
  assertOk(legacySignup, "signup");
256
336
  process.stdout.write("Verification code sent. Check your email.\n");
@@ -293,26 +373,31 @@ export async function authenticateTerminal(args) {
293
373
  process.stdout.write("No PreMan account found. Creating one now.\n");
294
374
  accessToken = await createAccountFromTerminal(args, email);
295
375
  } else if (account.needs_password) {
296
- process.stdout.write("This account needs a password. Sending an OTP first.\n");
376
+ // No password on this account, so a code is the only way in.
377
+ const password = await optionalPassword(args);
378
+ process.stdout.write("Sending a sign-in code to your email.\n");
297
379
  const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
298
380
  assertOk(resend, "resend OTP");
299
381
  const otp = await promptText("Verification code: ");
300
- const password = await promptPasswordTwice();
301
- const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
302
- json: { email, otp, new_password: password },
303
- });
304
- assertOk(setPassword, "set password");
305
- accessToken = String(setPassword.access_token || "");
382
+ accessToken = await completeWithCode(args, email, otp, password);
306
383
  } else {
307
- const password = await promptSecret("Password: ");
308
- const login = await callBackendJson(args, "POST", "/auth/login", {
309
- json: { email, password },
310
- });
311
- if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
312
- accessToken = await verifyUnconfirmedAccount(args, email);
384
+ const password = await promptSecret("Password (Enter to get a code by email): ");
385
+ if (!password) {
386
+ const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
387
+ assertOk(resend, "resend OTP");
388
+ process.stdout.write("Sign-in code sent. Check your email.\n");
389
+ const otp = await promptText("Verification code: ");
390
+ accessToken = await completeWithCode(args, email, otp, "");
313
391
  } else {
314
- assertOk(login, "login");
315
- accessToken = String(login.access_token || "");
392
+ const login = await callBackendJson(args, "POST", "/auth/login", {
393
+ json: { email, password },
394
+ });
395
+ if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
396
+ accessToken = await verifyUnconfirmedAccount(args, email);
397
+ } else {
398
+ assertOk(login, "login");
399
+ accessToken = String(login.access_token || "");
400
+ }
316
401
  }
317
402
  }
318
403
 
@@ -360,6 +445,16 @@ export function resolveApiKey(args) {
360
445
  * Matches the backend's install snippets (`build_install_snippets`) so the
361
446
  * copy-paste path and this writer cannot drift.
362
447
  */
448
+ // npm exec rather than npx: same resolution, but it is the command every install
449
+ // of npm ships, and it is what the rest of our instructions use. The trailing "--"
450
+ // stops npm from claiming flags meant for the server.
451
+ //
452
+ // Exported so the post-write verifier checks for the launcher we actually emit.
453
+ // These drifted once already -- every writer emitted "npm" while the verifier
454
+ // demanded "npx", so every connect printed a mismatch warning.
455
+ export const LAUNCHER_COMMAND = "npm";
456
+ export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
457
+
363
458
  export function buildServerConfig(args, { pairCode = "" } = {}) {
364
459
  const env = {
365
460
  PREMAN_BACKEND: backendUrl(args),
@@ -370,11 +465,8 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
370
465
  if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
371
466
 
372
467
  return {
373
- // npm exec rather than npx: same resolution, but it is the command every
374
- // install of npm ships, and it is what the rest of our instructions use.
375
- // The trailing "--" stops npm from claiming flags meant for the server.
376
- command: "npm",
377
- args: ["exec", "-y", "premanmcp@latest", "--"],
468
+ command: LAUNCHER_COMMAND,
469
+ args: [...LAUNCHER_ARGS],
378
470
  env,
379
471
  };
380
472
  }