premanmcp 1.1.11 → 1.1.12

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/account.js CHANGED
@@ -9,18 +9,23 @@
9
9
 
10
10
  import { existsSync, rmSync } from "node:fs";
11
11
  import os from "node:os";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
12
14
 
13
15
  import { detectCandidates } from "./detect.js";
14
16
  import { DESKTOP_SESSION_FILE } from "./desktop.js";
15
17
  import { hookStatus } from "./hook.js";
16
18
  import {
17
19
  CREDENTIALS_FILE,
20
+ MIN_NODE_MAJOR,
18
21
  backendUrl,
19
22
  callBackendJson,
20
23
  cliInvocation,
21
24
  frontendUrl,
22
25
  makeArgs,
26
+ nodeTooOld,
23
27
  openUrl,
28
+ pathPremanOwner,
24
29
  readStoredCredentials,
25
30
  resolveApiKey,
26
31
  saveStoredCredentials,
@@ -110,6 +115,49 @@ export async function doctorCommand(commandArgs = []) {
110
115
  const results = [];
111
116
  let failures = 0;
112
117
 
118
+ // The install itself, first, because everything below assumes it works.
119
+ // Doctor checked the account, the backend, the repo and the hook -- five
120
+ // things on the far side of a CLI that can run -- and nothing about the CLI.
121
+ // So the two failures that make every other line meaningless, a Node too old
122
+ // for fetch and a `preman` on PATH that belongs to something else, were the
123
+ // two it could not report.
124
+ const oldNode = nodeTooOld();
125
+ results.push(
126
+ line(
127
+ "node",
128
+ !oldNode,
129
+ oldNode ? `${process.versions.node} — needs ${MIN_NODE_MAJOR}+` : process.versions.node
130
+ )
131
+ );
132
+ if (oldNode) failures += 1;
133
+
134
+ // `preman` resolving to something other than this package is why a hook
135
+ // installed under one Node reports "Unknown command: verify" after an nvm
136
+ // switch, and why the printed instructions fall back to re-resolving the
137
+ // package from the network on every push.
138
+ const owner = pathPremanOwner();
139
+ results.push(
140
+ line(
141
+ "preman on PATH",
142
+ owner === "premanmcp",
143
+ owner === "premanmcp"
144
+ ? "premanmcp"
145
+ : owner
146
+ ? `owned by ${owner}, not premanmcp — commands will use \`${cliInvocation()}\``
147
+ : `not on PATH — commands will use \`${cliInvocation()}\``
148
+ )
149
+ );
150
+
151
+ // The MCP server is compiled, and `npm exec` fetches the published package
152
+ // rather than this checkout, so a missing dist is invisible until a host
153
+ // launches the server and gets nothing.
154
+ const serverPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "server.js");
155
+ const built = existsSync(serverPath);
156
+ results.push(
157
+ line("mcp server", built, built ? "dist/server.js" : "dist/server.js missing — run `npm run build`")
158
+ );
159
+ if (!built) failures += 1;
160
+
113
161
  const stored = readStoredCredentials();
114
162
  const token = resolveApiKey(args);
115
163
  results.push(
package/bin/cli.js CHANGED
@@ -40,6 +40,7 @@ import { HOOK_HELP, hookCommand, installHook, scheduleHookRepair } from "./hook.
40
40
  import { EVAL_HELP } from "./eval.js";
41
41
  import { TARGET_HELP, targetCommand } from "./target.js";
42
42
  import { RUNNER_HELP, runnerCommand } from "./runner.js";
43
+ import { PROBE_HELP, probeCommand } from "./probe.js";
43
44
  import { VERIFY_HELP, verifyCommand } from "./verify.js";
44
45
  import {
45
46
  DESKTOP_HELP,
@@ -59,6 +60,7 @@ import {
59
60
  buildServerConfig,
60
61
  hasKeyAvailable,
61
62
  makeArgs,
63
+ nodeTooOld,
62
64
  readStoredCredentials,
63
65
  } from "./shared.js";
64
66
 
@@ -73,6 +75,16 @@ const ROOT = path.join(__dirname, "..");
73
75
  // argument parsing is still attributable.
74
76
  process.title = "preman";
75
77
 
78
+ // Before anything else that could need it. `start` is exempt: an MCP host
79
+ // launched it, the host is the only thing reading this stream, and a line of
80
+ // prose on stdout is a protocol violation -- the preflight inside `connect`
81
+ // already refuses that case in a way the host can render.
82
+ const tooOld = nodeTooOld();
83
+ if (tooOld && !(process.argv[2] === "start" || !process.stdin.isTTY)) {
84
+ process.stderr.write(tooOld);
85
+ process.exit(1);
86
+ }
87
+
76
88
  const args = process.argv.slice(2);
77
89
  // A bare `preman` in a terminal opens the agent session: that is what the
78
90
  // product is in a terminal, and it signs the caller in on the way if they are
@@ -85,9 +97,25 @@ const args = process.argv.slice(2);
85
97
  // explicit way to ask for the server from a terminal.
86
98
  const bare = args.length === 0;
87
99
  const leadingFlag = Boolean(args[0]) && args[0].startsWith("-");
88
- const command = args[0] === "--help" || args[0] === "-h"
100
+ // Asking for help anywhere in the line is asking for help. Only the leading
101
+ // position was recognised, so `preman install --help` was dispatched to
102
+ // `install` with `--help` as an argument -- and a command whose job is to
103
+ // explain itself instead went and did the thing it was being asked about.
104
+ // Only the first two positions, because `run` and `dispatch` forward arguments
105
+ // to a tool and one of those may legitimately be asking that tool for help.
106
+ const wantsHelp = args.slice(0, 2).some((arg) => arg === "--help" || arg === "-h");
107
+ // Leading position only, unlike help: `update --version 1.1.9` is how somebody
108
+ // pins a version to install, so reading `--version` anywhere would answer that
109
+ // with a number and never run the update. Asking a CLI its version is also the
110
+ // one question it must not answer by starting something -- and it did: the flag
111
+ // fell through to the session, so `preman --version` opened a chat, and with a
112
+ // pipe on stdin it started the MCP server and said nothing at all.
113
+ const wantsVersion = args[0] === "--version" || args[0] === "-v";
114
+ const command = wantsHelp
89
115
  ? "help"
90
- : bare || leadingFlag
116
+ : wantsVersion
117
+ ? "version"
118
+ : bare || leadingFlag
91
119
  ? (process.stdin.isTTY ? "agent" : "start")
92
120
  : args[0];
93
121
  // A session or the server started this way owns every argument; a named
@@ -115,9 +143,12 @@ function printHelp() {
115
143
  ["chat [message] [--continue]", "The same session, named explicitly"],
116
144
  ["status", "Healthy / failing / recently fixed endpoints"],
117
145
  ["verify [options]", "Test endpoints against your local app"],
146
+ ["probe [options]", "Fire a population of simulated users at your local app"],
118
147
  ["hook install|uninstall|status", "Manage the git pre-push hook"],
119
148
  ["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
120
- ["eval run|doctor", "Run PreMan's queued agent eval against your local agent"],
149
+ ["runner work", "Take one queued eval and run it here, then exit"],
150
+ ["eval", "Measure your agent: priced and confirmed before it starts"],
151
+ ["eval target|doctor", "Find the agent to measure; check this can run"],
121
152
  ["doctor", "Diagnose credentials, backend, target, integrations"],
122
153
  ["install-desktop", "Download and install the PreMan desktop app"],
123
154
  ["predict [options]", "Local dev: bring the stack up and measure the forecaster"],
@@ -134,7 +165,7 @@ function printHelp() {
134
165
  ["start", "Start the PreMan MCP server (what an MCP host runs)"],
135
166
  ["link|tools|run ...", "Drive a published hosted MCP"],
136
167
  ["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
137
- ["test --agent [--only <id>]", "Measure your agent against the behaviours you turned on"],
168
+ ["test --agent [--only <id>]", "Older spelling of `eval`; still works"],
138
169
  ["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
139
170
  ["tests generate|review|setup|enrich", "Collections: generate, review, fixtures, enrich"],
140
171
  ];
@@ -147,7 +178,7 @@ function printHelp() {
147
178
 
148
179
  Usage:
149
180
  ${usageLines}
150
- ${AGENT_HELP}${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${RUNNER_HELP}${EVAL_HELP}${TARGET_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${PREDICT_HELP}${ENDPOINTS_HELP}${TEST_HELP}${TESTS_HELP}
181
+ ${AGENT_HELP}${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${PROBE_HELP}${HOOK_HELP}${RUNNER_HELP}${EVAL_HELP}${TARGET_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${PREDICT_HELP}${ENDPOINTS_HELP}${TEST_HELP}${TESTS_HELP}
151
182
  Login options:
152
183
  --email <email> Pre-fill the email prompt
153
184
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
@@ -233,6 +264,7 @@ async function updateCommand(commandArgs = []) {
233
264
 
234
265
  process.stdout.write(`Updating ${PACKAGE_NAME} (have ${before})…\n`);
235
266
  const done = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@${wanted}`], {
267
+ shell: process.platform === "win32",
236
268
  stdio: "inherit",
237
269
  });
238
270
  if (done.error || done.status !== 0) {
@@ -247,7 +279,10 @@ async function updateCommand(commandArgs = []) {
247
279
  }
248
280
  // Read from the freshly installed copy rather than this process, which is
249
281
  // still running the version we just replaced.
250
- const now = spawnSync("npm", ["view", PACKAGE_NAME, "version"], { encoding: "utf8" });
282
+ const now = spawnSync("npm", ["view", PACKAGE_NAME, "version"], {
283
+ encoding: "utf8",
284
+ shell: process.platform === "win32",
285
+ });
251
286
  const after = String(now.stdout || "").trim();
252
287
  process.stdout.write(
253
288
  after && after !== before
@@ -330,7 +365,9 @@ async function main() {
330
365
  // would only race with the one the user is watching.
331
366
  if (!["hook", "verify", "connect"].includes(command)) scheduleHookRepair();
332
367
 
333
- if (command === "agent" || command === "chat") {
368
+ if (command === "version") {
369
+ process.stdout.write(`${packageVersion() || "unknown"}\n`);
370
+ } else if (command === "agent" || command === "chat") {
334
371
  await agentCommand(commandArgs);
335
372
  } else if (command === "target") {
336
373
  await targetCommand(commandArgs, { makeArgs });
@@ -340,6 +377,8 @@ async function main() {
340
377
  await statusCommand(commandArgs);
341
378
  } else if (command === "verify") {
342
379
  await verifyCommand(commandArgs);
380
+ } else if (command === "probe") {
381
+ await probeCommand(commandArgs);
343
382
  } else if (command === "hook") {
344
383
  await hookCommand(commandArgs);
345
384
  } else if (command === "runner") {
@@ -353,7 +392,11 @@ async function main() {
353
392
  // Passing them in keeps the dependency one-directional.
354
393
  const { evalCommand } = await import("./eval.js");
355
394
  const runner = await import("./runner.js");
356
- await evalCommand(commandArgs, {
395
+ // `preman eval && deploy` has to be able to tell "the agent behaved" from
396
+ // "we never found out", so the code the command worked out is the code the
397
+ // shell sees. Left alone when there is none: a path that did not measure
398
+ // anything has no verdict to report, and forcing one would invent it.
399
+ const outcome = await evalCommand(commandArgs, {
357
400
  makeArgs,
358
401
  registerRunner: runner.registerRunner,
359
402
  readRunnerState: runner.readRunnerState,
@@ -363,6 +406,7 @@ async function main() {
363
406
  pairingIsLive: runner.pairingIsLive,
364
407
  resolveAgent: resolveAgentForPairing,
365
408
  });
409
+ if (Number.isInteger(outcome?.exitCode)) process.exitCode = outcome.exitCode;
366
410
  } else if (command === "logout") {
367
411
  await logoutCommand();
368
412
  } else if (command === "doctor") {
@@ -399,12 +443,18 @@ async function main() {
399
443
  } else if (command === "update" || command === "upgrade") {
400
444
  await updateCommand(commandArgs);
401
445
  } else if (command === "install") {
402
- // `install` meant "write Cursor's MCP config", and every documented form of
403
- // it passes a flag (`--api-key`, `--project`, `--backend`). Bare `install`
404
- // never had a use, so it is the one spelling free to become what people
405
- // actually type it expecting: set me up. Flagged forms keep working
406
- // untouched, and `--cursor` names the old behaviour explicitly.
407
- if (commandArgs.length > 0) {
446
+ // `install` meant "write Cursor's MCP config", and bare `install` never had
447
+ // a use, so it became what people actually type it expecting: set me up.
448
+ //
449
+ // That fork used to be "were there any arguments at all", which routed on
450
+ // the presence of a flag rather than on what the flag asked for. So
451
+ // `install --yes` -- the documented way to run an unattended setup -- wrote
452
+ // a Cursor config instead, and `install --help` did too, which is a command
453
+ // that cannot create an account answering by creating one. Cursor intent is
454
+ // now named. `--backend` is deliberately absent: every command takes it, so
455
+ // it says which deployment, never which program.
456
+ const CURSOR_INTENT = ["--cursor", "--name", "--project", "--print", "--skip-login", "--api-key"];
457
+ if (CURSOR_INTENT.some((flag) => commandArgs.includes(flag))) {
408
458
  await installCursorMcp();
409
459
  } else {
410
460
  await onboardCommand(commandArgs, {
@@ -436,6 +486,15 @@ async function main() {
436
486
  // differs. Routed here rather than inside testCommand because the two share
437
487
  // no arguments beyond the word `test`, and matched in first position only so
438
488
  // that an endpoint id is never overtaken by a flag further along the line.
489
+ // Signposted rather than removed. This spelling is in circulation, in
490
+ // scripts and in people's shell history, and breaking it to make a point
491
+ // about naming would cost more than the confusion it fixes.
492
+ const invocation = cliInvocation();
493
+ process.stderr.write(
494
+ `\`${invocation} test --agent\` is now \`${invocation} eval\`, which ` +
495
+ `checks the agent can be found and prices the run before starting it. ` +
496
+ `Running it for you.\n\n`
497
+ );
439
498
  const { agentTestCommand } = await import("./eval.js");
440
499
  const runner = await import("./runner.js");
441
500
  await agentTestCommand(commandArgs, {
@@ -11,7 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { execFileSync, spawnSync } from "node:child_process";
13
13
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
- import { LAUNCHER_ARGS, LAUNCHER_COMMAND, backendUrl, frontendUrl, onPath, readJsonFile, writeJsonFile } from "../shared.js";
14
+ import { LAUNCHER_ARGS, LAUNCHER_COMMAND, backendUrl, frontendUrl, launcherFor, onPath, readJsonFile, writeJsonFile } from "../shared.js";
15
15
  import { AGENTS } from "./agents.js";
16
16
 
17
17
  // ── Config writers ──────────────────────────────────────────────────────
@@ -42,6 +42,10 @@ export function writeCursorConfig({ serverName, serverConfig, projectInstall })
42
42
  execFileSync("cursor-agent", cursorMcpEnableArgs(serverName), {
43
43
  stdio: "pipe",
44
44
  timeout: 15000,
45
+ // Both agent CLIs install as `.cmd` shims on Windows, and execFile
46
+ // cannot run a batch file: it fails with EINVAL, which reads as the
47
+ // agent being broken rather than as us spawning it wrongly.
48
+ shell: process.platform === "win32",
45
49
  });
46
50
  return { path: configPath, how: "wrote and enabled" };
47
51
  } catch (error) {
@@ -79,7 +83,7 @@ export function writeClaudeConfig({ serverName, serverConfig, projectInstall })
79
83
  if (onPath("claude")) {
80
84
  const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
81
85
  try {
82
- execFileSync("claude", args, { stdio: "pipe" });
86
+ execFileSync("claude", args, { stdio: "pipe", shell: process.platform === "win32" });
83
87
  return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
84
88
  } catch (error) {
85
89
  const detail = error?.stderr?.toString().trim() || error.message;
@@ -171,6 +175,17 @@ export const WRITERS = {
171
175
 
172
176
  /** Quote only what a shell would otherwise mangle, so the line stays readable. */
173
177
  export function shellQuote(value) {
178
+ // cmd.exe does not read single quotes as quoting at all, so the POSIX form
179
+ // was handed to Windows users as something to paste and arrived at the agent
180
+ // with the quotes still in it -- an API key wrapped in apostrophes is a
181
+ // different API key. A value with a double quote in it cannot be expressed
182
+ // safely on cmd.exe, and saying so beats emitting something that will run
183
+ // and do the wrong thing.
184
+ if (process.platform === "win32") {
185
+ if (/^[A-Za-z0-9_@%+=:,./\\-]+$/.test(value)) return value;
186
+ if (value.includes('"')) return `<value containing a double quote: set it by hand>`;
187
+ return `"${value}"`;
188
+ }
174
189
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
175
190
  }
176
191
 
@@ -193,8 +208,7 @@ export function renderAllAgentSnippets(args, serverName, { projectInstall = fals
193
208
  PREMAN_FRONTEND: frontendUrl(args),
194
209
  };
195
210
  const serverConfig = {
196
- command: LAUNCHER_COMMAND,
197
- args: [...LAUNCHER_ARGS],
211
+ ...launcherFor(),
198
212
  env,
199
213
  };
200
214
 
@@ -243,7 +257,7 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
243
257
  if (!block) {
244
258
  return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
245
259
  }
246
- if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
260
+ if (!block.some((line) => line.trim() === `command = "${launcherFor().command}"`)) {
247
261
  return {
248
262
  status: "mismatch",
249
263
  detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
@@ -263,7 +277,7 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
263
277
 
264
278
  const entry = readJsonFile(written.path).mcpServers?.[serverName];
265
279
  if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
266
- if (entry.command !== LAUNCHER_COMMAND) {
280
+ if (entry.command !== launcherFor().command) {
267
281
  return {
268
282
  status: "mismatch",
269
283
  detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,