premanmcp 0.11.0 → 0.12.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/README.md CHANGED
@@ -4,14 +4,24 @@ Turn your APIs into MCP tools that coding agents can discover, call, test, and a
4
4
 
5
5
  PreMan is agent-first API infrastructure. It lets backend teams expose endpoints to AI coding agents through MCP, add an auth layer around those tools, and see exactly which agent called what.
6
6
 
7
- ## Connect
7
+ ## Start
8
+
9
+ ```bash
10
+ npm exec -y premanmcp@latest -- onboard
11
+ ```
12
+
13
+ Create the account, verify the email, and get the PreMan app installed and opened
14
+ already signed in. A bare `preman` in a terminal does the same thing.
15
+
16
+ ## Connect a coding agent
8
17
 
9
18
  ```bash
10
19
  npm exec -y premanmcp@latest -- connect
11
20
  ```
12
21
 
13
- Pick your coding agent from the list — Cursor, Claude Code, or Codex — and PreMan
14
- writes that agent's MCP config for you. No hand-edited config anywhere.
22
+ Optional, and separate from starting out. Pick your coding agent from the list — Cursor,
23
+ Claude Code, or Codex — and PreMan writes that agent's MCP config for you. No hand-edited
24
+ config anywhere.
15
25
 
16
26
  Local development form:
17
27
 
@@ -82,10 +92,18 @@ preman github # or connect it in the dashboard
82
92
  preman status # which of those are done
83
93
  ```
84
94
 
85
- `preman onboard` (or `setup`) is the prompted walk through all of it sign in, coding
86
- agent, endpoints, runner, GitHub, AWS, Slack one question per step, `b` to go back, and a
87
- summary at the end. `connect --guide` runs the old full pass inside connect itself:
88
- discovery, a first test, the runner, the desktop app and the integration prompts.
95
+ `preman onboard` (or `setup`, or a bare `preman` in a terminal) is what someone starting
96
+ out runs: create the account and verify the email, then install the PreMan app and open it
97
+ already signed in, then GitHub, AWS, Slack one question per step, `b` to go back, and a
98
+ summary at the end. Connecting a coding agent is no longer part of starting out; run
99
+ `preman connect` when you actually want one wired into an IDE. `connect --guide` still runs
100
+ the full pass inside connect itself: discovery, a first test, the runner, the desktop app
101
+ and the integration prompts.
102
+
103
+ The app opens signed in because the CLI leaves the key it just minted in
104
+ `~/.preman/desktop-session.json`, which the app reads once and deletes. An app too old to
105
+ look for it, or a machine that is not macOS, falls back to signing in on the app's own
106
+ login screen with the account you just created.
89
107
 
90
108
  Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
91
109
  project-local config, `--print` shows the config without writing it, `--no-hook` leaves
package/bin/cli.js CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  CONNECT_HELP,
23
23
  DISPATCH_HELP,
24
24
  connectCommand,
25
- discoverEndpoints,
26
25
  dispatchCommand,
27
26
  resolveAgentForPairing,
28
27
  writeCursorConfig,
@@ -39,7 +38,7 @@ import { STATUS_HELP, statusCommand } from "./status.js";
39
38
  import { HOOK_HELP, hookCommand, scheduleHookRepair } from "./hook.js";
40
39
  import { RUNNER_HELP, runnerCommand } from "./runner.js";
41
40
  import { VERIFY_HELP, verifyCommand } from "./verify.js";
42
- import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
41
+ import { DESKTOP_HELP, installDesktopCommand, openDesktopSignedIn } from "./desktop.js";
43
42
  import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
44
43
  import {
45
44
  CREDENTIALS_FILE,
@@ -57,8 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
57
56
  const ROOT = path.join(__dirname, "..");
58
57
 
59
58
  const args = process.argv.slice(2);
59
+ // A bare `preman` in a terminal is someone starting out, so it runs setup:
60
+ // account first, then the app. Piped or redirected (no TTY) it is an MCP host
61
+ // launching the server, which must stay the behaviour for `start`.
60
62
  const command = args.length === 0 && process.stdin.isTTY
61
- ? "connect"
63
+ ? "onboard"
62
64
  : args[0] === "--help" || args[0] === "-h"
63
65
  ? "help"
64
66
  : args[0] && !args[0].startsWith("-")
@@ -89,8 +91,8 @@ function printHelp() {
89
91
  ["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
90
92
  ["doctor", "Diagnose credentials, backend, target, integrations"],
91
93
  ["install-desktop", "Download and install the PreMan desktop app"],
92
- ["onboard", "Sign in, then agent, endpoints, runner, GitHub, AWS, Slack"],
93
- ["connect [options]", "Pick a coding agent and connect it"],
94
+ ["onboard", "Create an account, install the app signed in, then integrations"],
95
+ ["connect [options]", "Pick a coding agent and connect it (optional)"],
94
96
  ["dispatch [options]", "Let PreMan start agent runs for you"],
95
97
  ["aws | github | slack", "Connect one integration on its own"],
96
98
  ["login [--browser]", "Create/login to PreMan from the terminal"],
@@ -254,14 +256,13 @@ async function main() {
254
256
  } else if (command === "dispatch") {
255
257
  await dispatchCommand(commandArgs);
256
258
  } else if (command === "onboard" || command === "setup") {
257
- // makeArgs/authenticateTerminal/connectCommand are injected rather than
259
+ // makeArgs/authenticateTerminal/the desktop pair are injected rather than
258
260
  // imported there, so integrations.js stays free of a cycle back into the CLI.
259
261
  await onboardCommand(commandArgs, {
260
262
  makeArgs,
261
263
  authenticateTerminal,
262
- connectCommand,
263
- discoverEndpoints,
264
- runnerCommand,
264
+ installDesktop: installDesktopCommand,
265
+ openDesktopSignedIn,
265
266
  });
266
267
  } else if (command === "aws") {
267
268
  await awsCommand(makeArgs(commandArgs));
package/bin/desktop.js CHANGED
@@ -13,11 +13,11 @@
13
13
 
14
14
  import { spawn, spawnSync } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
 
20
- import { cliInvocation, makeArgs } from "./shared.js";
20
+ import { CREDENTIALS_DIR, cliInvocation, makeArgs } from "./shared.js";
21
21
 
22
22
  export const DESKTOP_HELP = `
23
23
  Install-desktop options:
@@ -32,6 +32,60 @@ const RELEASE_API = "https://api.github.com/repos/PreMan-Inc/PreMan-Desktop/rele
32
32
  export const APP_NAME = "PreMan.app";
33
33
  const DOWNLOAD_TIMEOUT_MS = 300_000;
34
34
 
35
+ /**
36
+ * Where the desktop app looks for a session the CLI just established.
37
+ *
38
+ * The key travels through a file rather than the `preman://` URL because a URL
39
+ * is handed to LaunchServices, which records it; the file sits in the directory
40
+ * that already holds the same key, with the same 0600 mode, so this adds no new
41
+ * class of exposure. The app deletes it on read, which is what makes it
42
+ * single-use -- the CLI cannot know whether an installed app is new enough to
43
+ * consume it, so expiry is enforced on the reading side.
44
+ */
45
+ export const DESKTOP_SESSION_FILE = path.join(CREDENTIALS_DIR, "desktop-session.json");
46
+
47
+ export function installedAppPath(destination = "/Applications") {
48
+ return path.join(destination, APP_NAME);
49
+ }
50
+
51
+ export function desktopAppInstalled(destination = "/Applications") {
52
+ return process.platform === "darwin" && existsSync(installedAppPath(destination));
53
+ }
54
+
55
+ /**
56
+ * Hand the account the CLI just signed in to over to the desktop app.
57
+ *
58
+ * Returns what actually happened rather than a boolean, because the caller has
59
+ * something different to say in each case: an app that is not installed is not
60
+ * a failure, it is the fallback where the customer signs in themselves.
61
+ */
62
+ export function writeDesktopSession(creds) {
63
+ const apiKey = String(creds?.api_key || "").trim();
64
+ if (!apiKey.startsWith("pm_live_")) return { state: "no-key" };
65
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
66
+ writeFileSync(
67
+ DESKTOP_SESSION_FILE,
68
+ `${JSON.stringify({ api_key: apiKey, user_email: creds?.user_email ?? null, created_at: new Date().toISOString() }, null, 2)}\n`,
69
+ { mode: 0o600 }
70
+ );
71
+ return { state: "written", path: DESKTOP_SESSION_FILE };
72
+ }
73
+
74
+ /**
75
+ * Install-or-not aside, get the customer into the app signed in.
76
+ *
77
+ * The session is written before the app is launched so a cold start finds it on
78
+ * first read, rather than racing a window that is already loading.
79
+ */
80
+ export function openDesktopSignedIn(creds, { destination = "/Applications" } = {}) {
81
+ if (!desktopAppInstalled(destination)) {
82
+ return { state: "not-installed" };
83
+ }
84
+ const handoff = writeDesktopSession(creds);
85
+ spawn("open", ["-a", "PreMan"], { stdio: "ignore", detached: true }).unref();
86
+ return { state: handoff.state === "written" ? "opened-signed-in" : "opened", handoff: handoff.state };
87
+ }
88
+
35
89
  /**
36
90
  * Open a Playground session URL in PreMan.app on macOS when it is installed.
37
91
  * Windows/Linux (and Mac without the app) get the website URL printed instead.
@@ -225,8 +279,8 @@ export async function installDesktopCommand(commandArgs = []) {
225
279
  chmodSync(target, 0o755);
226
280
 
227
281
  process.stdout.write(
228
- `\nInstalled ${target}\n\nNext: open PreMan and it will pair with this account.\n` +
229
- `Then run \`${cliInvocation()} status\` to see endpoint health.\n`
282
+ `\nInstalled ${target}\n\nNext: open PreMan and sign in with the account you use here.\n` +
283
+ `\`${cliInvocation()} onboard\` installs it and opens it already signed in.\n`
230
284
  );
231
285
  return { state: "installed", version, arch, path: target };
232
286
  } finally {
@@ -337,17 +337,6 @@ export async function slackCommand(args) {
337
337
  // The guided run
338
338
  // ---------------------------------------------------------------------------
339
339
 
340
- /**
341
- * Why a step that needs a connected agent cannot run on its own.
342
- *
343
- * Both of these drive the agent `connect` just linked, so skipping that step
344
- * leaves them without one -- which is a thing to say plainly, with the command
345
- * that does it later, rather than a stack trace about a missing id.
346
- */
347
- function needsAgent(command) {
348
- return `no coding agent connected yet -- run '${cliInvocation()} connect', then '${cliInvocation()} ${command}'`;
349
- }
350
-
351
340
  /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
352
341
  async function askStep(question, { assumeYes, canGoBack }) {
353
342
  if (assumeYes) return "yes";
@@ -368,7 +357,7 @@ async function askStep(question, { assumeYes, canGoBack }) {
368
357
  */
369
358
  export async function onboardCommand(
370
359
  commandArgs,
371
- { makeArgs, authenticateTerminal, connectCommand, discoverEndpoints, runnerCommand }
360
+ { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn }
372
361
  ) {
373
362
  const args = makeArgs(commandArgs);
374
363
  const assumeYes = args.has("--yes");
@@ -378,32 +367,30 @@ export async function onboardCommand(
378
367
  const creds = await authenticateTerminal(args);
379
368
  connected(`Signed in as ${creds.user_email || "your account"}.`);
380
369
 
381
- // Which agent the endpoints and runner steps drive. `connect` decided it, by
382
- // detection or by asking, and this is the answer rather than a second prompt.
383
- let linked = null;
384
-
385
370
  const steps = [
386
371
  {
387
- name: "coding agent",
388
- question: "Connect your coding agent?",
372
+ name: "PreMan app",
373
+ question: "Install the PreMan app and open it signed in?",
389
374
  run: async () => {
390
- linked = (await connectCommand([...commandArgs, "--skip-login"])) || null;
391
- },
392
- },
393
- {
394
- name: "endpoints",
395
- question: "Map this repository's endpoints?",
396
- run: () => {
397
- if (!linked?.agent) throw new Error(needsAgent("endpoints discover"));
398
- return discoverEndpoints(args, linked.agent, linked.serverName);
399
- },
400
- },
401
- {
402
- name: "runner",
403
- question: "Let PreMan run your agent here when it finds something to fix?",
404
- run: () => {
405
- if (!linked?.agent) throw new Error(needsAgent("runner start --background"));
406
- return runnerCommand(["start", "--background", "--agent", linked.agent.id]);
375
+ const installed = await installDesktop([...commandArgs]);
376
+ if (installed?.state === "unsupported") {
377
+ // Not a failure: the download link has already been printed, and the
378
+ // account this step exists to create is finished either way.
379
+ process.stdout.write("Sign in there with the account you just used.\n");
380
+ return;
381
+ }
382
+ const opened = openDesktopSignedIn(creds);
383
+ if (opened.state === "opened-signed-in") {
384
+ process.stdout.write("Opened PreMan, signed in as this account.\n");
385
+ } else if (opened.state === "not-installed") {
386
+ process.stdout.write(
387
+ "PreMan is not in /Applications yet \u2014 open it once installed and sign in.\n"
388
+ );
389
+ } else {
390
+ // The app is there but the session could not be handed over, so say so
391
+ // rather than let the customer wonder why they are at a login screen.
392
+ process.stdout.write("Opened PreMan \u2014 sign in with the account you just used.\n");
393
+ }
407
394
  },
408
395
  },
409
396
  { name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
@@ -464,8 +451,9 @@ export async function onboardCommand(
464
451
 
465
452
  export const INTEGRATIONS_HELP = `
466
453
  Setup options:
467
- preman onboard Sign in, then agent, endpoints, runner, GitHub,
468
- AWS and Slack, one prompt per step
454
+ preman onboard Create or sign in to an account, install the
455
+ PreMan app signed in, then GitHub, AWS and Slack,
456
+ one prompt per step
469
457
  preman aws Connect an AWS account and stream a log group
470
458
  preman github Install the PreMan GitHub App
471
459
  preman slack Add PreMan to a Slack workspace
package/dist/server.js CHANGED
@@ -1332,6 +1332,90 @@ export function createServer() {
1332
1332
  });
1333
1333
  }
1334
1334
  });
1335
+ // ── Collections test generation ────────────────────────────────────
1336
+ // The dashboard's Generate Tests / Review flagged / Setup fixtures /
1337
+ // Enrich with agent buttons, so an agent can run them without clicking.
1338
+ server.tool("generate_saved_tests", "Generate scheduled test suites for every endpoint already saved in a PreMan workspace. This is the dashboard's Collections 'Generate Tests' button: it clones the workspace's connected GitHub repository, matches the discovered routes onto the saved endpoints by method and path, and writes heuristic cases onto the ones it matches. No model is involved, so nothing invents a body for a DELETE. Write, destructive, low-confidence, and path-parameter routes come back flagged and disabled — clear them with review_generated_tests. This is not generate_tests, which writes cases for a single endpoint you describe.", {
1339
+ action: z
1340
+ .enum(["generate", "status"])
1341
+ .optional()
1342
+ .describe("generate (default) runs the scan; status is read-only counts"),
1343
+ integration_id: z
1344
+ .string()
1345
+ .optional()
1346
+ .describe("Connected repo to scan. Omit to use the one with the most synced endpoints."),
1347
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1348
+ }, async (args) => {
1349
+ try {
1350
+ const result = await callBackend("generate_saved_tests", args);
1351
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1352
+ }
1353
+ catch (e) {
1354
+ return toolError(e.message, inferErrorCode(e.message), {
1355
+ next_actions: [
1356
+ "Connect a GitHub repository to this workspace in Settings → Integrations, then retry.",
1357
+ ],
1358
+ related_tools: ["review_generated_tests", "setup_test_fixtures", "enrich_generated_tests"],
1359
+ });
1360
+ }
1361
+ });
1362
+ server.tool("review_generated_tests", "List and approve the generated test suites PreMan flagged for human review. This is the dashboard's 'Review flagged' button. generate_saved_tests deliberately leaves mutating and ambiguous routes disabled; this is how a reviewed suite gets turned on. Approving enables the suite's read-only schedule and does not raise the unattended write policy, so a DELETE stays gated at run time. List first and show the user each suite and its reason; only use approve_all when they asked to clear the whole queue.", {
1363
+ action: z
1364
+ .enum(["list", "approve", "approve_all"])
1365
+ .optional()
1366
+ .describe("list (default) | approve one request_id | approve_all (max 50)"),
1367
+ request_id: z.string().optional().describe("Saved request to approve; required for approve"),
1368
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1369
+ }, async (args) => {
1370
+ try {
1371
+ const result = await callBackend("review_generated_tests", args);
1372
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1373
+ }
1374
+ catch (e) {
1375
+ return toolError(e.message, inferErrorCode(e.message), {
1376
+ next_actions: ["Call review_generated_tests with action='list' to see the queue."],
1377
+ related_tools: ["generate_saved_tests", "enrich_generated_tests"],
1378
+ });
1379
+ }
1380
+ });
1381
+ server.tool("setup_test_fixtures", "Fill the {id} path parameters in saved PreMan endpoints with real record ids. This is the dashboard's 'Setup fixtures' button: a happy-path test for GET /users/{id} needs an id that exists, so PreMan calls the sibling list route read-only, harvests ids from the response, and falls back to the endpoint's schema example. It never mints a fake UUID that would only 404 and never fires a write. Harvested values are stored in workspace settings and returned as a .env snippet. This does not approve anything.", {
1382
+ action: z
1383
+ .enum(["setup", "list"])
1384
+ .optional()
1385
+ .describe("setup (default) harvests ids; list reports what is still missing"),
1386
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1387
+ }, async (args) => {
1388
+ try {
1389
+ const result = await callBackend("setup_test_fixtures", args);
1390
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1391
+ }
1392
+ catch (e) {
1393
+ return toolError(e.message, inferErrorCode(e.message), {
1394
+ next_actions: ["Save at least one endpoint with a {id} path parameter, then retry."],
1395
+ related_tools: ["generate_saved_tests", "review_generated_tests"],
1396
+ });
1397
+ }
1398
+ });
1399
+ server.tool("enrich_generated_tests", "Add model-written test cases on top of PreMan's heuristic suites. This is the dashboard's 'Enrich with agent' button. generate_saved_tests writes deterministic cases only; this pass asks a model for a few extra edge cases per suite. It touches review-cleared suites only, skips destructive and billing-sensitive requests unless workspace policy already allows that risk, skips suites already enriched, and stops after 40 suites per call so a large collection cannot fan out into hundreds of model calls. Leftovers come back as remaining.", {
1400
+ action: z
1401
+ .enum(["enrich", "list"])
1402
+ .optional()
1403
+ .describe("enrich (default) runs the pass; list returns the eligible suites"),
1404
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1405
+ }, async (args) => {
1406
+ try {
1407
+ const result = await callBackend("enrich_generated_tests", args);
1408
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1409
+ }
1410
+ catch (e) {
1411
+ return toolError(e.message, inferErrorCode(e.message), {
1412
+ next_actions: [
1413
+ "Run generate_saved_tests first, then clear the review queue with review_generated_tests.",
1414
+ ],
1415
+ related_tools: ["generate_saved_tests", "review_generated_tests"],
1416
+ });
1417
+ }
1418
+ });
1335
1419
  server.tool("share_endpoints_with_ui", "Push discovered or verified endpoints into the PreMan Playground so the user can see them, test them, and convert selected endpoints into hosted MCP tools. Use this after verify_endpoints_live or when the user explicitly asks to stream endpoints to the UI.", {
1336
1420
  endpoints: z.array(z.any()).describe("Endpoints to push. Each item should include method plus path/path_template/url; include schemas when available."),
1337
1421
  upstream_base_url: z.string().optional().describe("Default upstream base URL for testing and MCP generation, e.g. http://127.0.0.1:8000 or https://api.example.com"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
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=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs 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 scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs"
18
+ "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs 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 scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs"
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/ext-apps": "^0.1.0",