premanmcp 0.7.0 → 0.7.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/bin/cli.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
29
29
  import {
30
30
  CREDENTIALS_FILE,
31
+ cliInvocation,
31
32
  DEFAULT_BACKEND,
32
33
  DEFAULT_FRONTEND,
33
34
  authenticateTerminal,
@@ -58,24 +59,38 @@ function hasFlag(name) {
58
59
  }
59
60
 
60
61
  function printHelp() {
62
+ // Say "preman x" only to people who can actually run it; everyone else gets
63
+ // the form that works from a bare npm install.
64
+ const cli = cliInvocation();
65
+ // Padded here rather than hand-aligned: the invocation prefix is 6 characters
66
+ // for a global install and 38 for npm exec, so a fixed layout is wrong for
67
+ // one of them.
68
+ const usage = [
69
+ ["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
70
+ ["connect [options]", "Pick a coding agent and connect it"],
71
+ ["aws | github | slack", "Connect one integration on its own"],
72
+ ["login", "Create/login to PreMan from the terminal"],
73
+ ["install [options]", "Install PreMan into Cursor MCP config"],
74
+ ["", "Start the PreMan MCP server"],
75
+ ["link|tools|run ...", "Drive a published hosted MCP"],
76
+ ["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
77
+ ["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
78
+ ];
79
+ const width = Math.max(...usage.map(([command]) => `${cli} ${command}`.trimEnd().length));
80
+ const usageLines = usage
81
+ .map(([command, blurb]) => ` ${`${cli} ${command}`.trimEnd().padEnd(width)} ${blurb}`)
82
+ .join("\n");
83
+
61
84
  process.stdout.write(`PreMan MCP
62
85
 
63
86
  Usage:
64
- preman onboard Sign in, then connect agent, GitHub, AWS, Slack
65
- preman connect [options] Pick a coding agent and connect it
66
- preman aws | github | slack Connect one integration on its own
67
- npm exec -y premanmcp@latest -- login Create/login to PreMan from the terminal
68
- npm exec -y premanmcp@latest -- install [options] Install PreMan into Cursor MCP config
69
- npm exec -y premanmcp@latest -- Start the PreMan MCP server
70
- preman link|tools|run ... Drive a published hosted MCP
71
- preman endpoints list|discover|setup ... Discover, list, and set up API endpoints
72
- preman test <id> [--scenario ...] [--stress] Generate + run tests for an endpoint
87
+ ${usageLines}
73
88
  ${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
74
89
  Login options:
75
90
  --email <email> Pre-fill the email prompt
76
91
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
77
92
 
78
- Install options (Cursor only — prefer 'preman connect'):
93
+ Install options (Cursor only — prefer '${cli} connect'):
79
94
  --api-key <key> PreMan API key. If omitted, stored CLI credentials are used
80
95
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
81
96
  --frontend <url> PreMan frontend URL. Defaults to ${DEFAULT_FRONTEND}
@@ -87,8 +102,8 @@ Install options (Cursor only — prefer 'preman connect'):
87
102
 
88
103
  Examples:
89
104
  npm exec -y premanmcp@latest -- connect
90
- preman connect --agent claude-code
91
- npm exec -y premanmcp@latest -- login
105
+ ${cli} connect --agent claude-code
106
+ ${cli} login
92
107
  npm exec -y premanmcp@latest -- install --project --backend http://127.0.0.1:8000
93
108
  ${HOSTED_HELP}`);
94
109
  }
@@ -103,7 +118,7 @@ API key: ${creds.api_key}
103
118
  Saved to: ${CREDENTIALS_FILE}
104
119
 
105
120
  You can now run:
106
- preman connect
121
+ ${cli} connect
107
122
  `);
108
123
  }
109
124
 
@@ -138,12 +153,12 @@ Server name: ${serverName}
138
153
  Backend: ${serverConfig.env.PREMAN_BACKEND}
139
154
 
140
155
  Next steps:
141
- 1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run npm exec -y premanmcp@latest -- login to create/connect your account and generate an API key."}
156
+ 1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run ${cli} login to create/connect your account and generate an API key."}
142
157
  2. Restart Cursor or toggle the PreMan MCP server off/on.
143
158
  3. In your API repo, ask your coding agent:
144
159
  "Use PreMan to convert the endpoints I choose into a hosted MCP server, then give me the Cursor/Claude install snippet."
145
160
 
146
- Tip: 'preman connect' also supports Claude Code and Codex, and links the agent to your account.
161
+ Tip: '${cli} connect' also supports Claude Code and Codex, and links the agent to your account.
147
162
  `);
148
163
  }
149
164
 
package/bin/connect.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  backendUrl,
23
23
  buildServerConfig,
24
24
  callBackendJson,
25
+ cliInvocation,
25
26
  frontendUrl,
26
27
  hasKeyAvailable,
27
28
  makeArgs,
@@ -401,11 +402,21 @@ async function captureDispatchCredential(args, agent, apiKey) {
401
402
 
402
403
  if (!secret) {
403
404
  if (!process.stdin.isTTY) return;
405
+ // Framed as the expected step rather than an optional aside. Without it
406
+ // PreMan can only suggest fixes; with it, it can run them. Presenting it as
407
+ // "optional, press Enter to skip" meant almost everyone skipped the thing
408
+ // that makes the product act rather than advise.
404
409
  process.stdout.write(
405
- `\nOptional: paste a ${agent.dispatch.credential} so PreMan can start ${agent.label} runs for you.\n`
410
+ `\nLet PreMan start ${agent.label} runs for you — it can then apply fixes and\n` +
411
+ `run checks on a schedule instead of only telling you what to do.\n`
406
412
  );
407
- secret = await promptSecret("(Enter to skip): ");
408
- if (!secret) return;
413
+ secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to set up later): `);
414
+ if (!secret) {
415
+ process.stdout.write(
416
+ `Skipped. Run '${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
417
+ );
418
+ return;
419
+ }
409
420
  if (agent.dispatch.needsRoutine && !routineId) {
410
421
  routineId = await promptText("Routine id or URL: ");
411
422
  }
@@ -224,8 +224,13 @@ export async function awsCommand(args) {
224
224
  export async function githubCommand(args) {
225
225
  const token = requireKey(args);
226
226
 
227
- const before = await callBackendJson(args, "GET", "/integrations/github", { token });
228
- const seen = new Set((before.integrations || before.repos || []).map((r) => r.id));
227
+ // This route answers with a bare array; callBackendJson exposes it as `list`.
228
+ const listRepos = async () => {
229
+ const resp = await callBackendJson(args, "GET", "/integrations/github", { token });
230
+ return resp.list || resp.integrations || resp.repos || [];
231
+ };
232
+
233
+ const seen = new Set((await listRepos()).map((r) => r.id));
229
234
 
230
235
  const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
231
236
  token,
@@ -239,11 +244,17 @@ export async function githubCommand(args) {
239
244
  process.stdout.write("Pick the repositories PreMan may read.\n");
240
245
 
241
246
  const done = await waitFor("the installation", async () => {
242
- const now = await callBackendJson(args, "GET", "/integrations/github", { token });
243
- const rows = now.integrations || now.repos || [];
247
+ // Installing the App and having repositories appear are two events: the
248
+ // callback records the installation, and a refresh materialises the repos.
249
+ // Polling the repo list alone waits for something that may never arrive on
250
+ // its own.
251
+ await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
252
+ token,
253
+ json: {},
254
+ });
244
255
  // Compare against what existed before, so a user who already had repos
245
256
  // connected is not told they are done the moment polling starts.
246
- const fresh = rows.filter((r) => !seen.has(r.id));
257
+ const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
247
258
  return fresh.length ? fresh : null;
248
259
  });
249
260
 
@@ -289,10 +300,14 @@ export async function slackCommand(args) {
289
300
  // The guided run
290
301
  // ---------------------------------------------------------------------------
291
302
 
292
- async function askYes(question, { assumeYes }) {
293
- if (assumeYes) return true;
294
- const answer = (await promptText(`${question} [Y/n]: `)).trim().toLowerCase();
295
- return answer === "" || answer === "y" || answer === "yes";
303
+ /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
304
+ async function askStep(question, { assumeYes, canGoBack }) {
305
+ if (assumeYes) return "yes";
306
+ const hint = canGoBack ? "[Y/n/b]" : "[Y/n]";
307
+ const answer = (await promptText(`${question} ${hint}: `)).trim().toLowerCase();
308
+ if (canGoBack && (answer === "b" || answer === "back")) return "back";
309
+ if (answer === "" || answer === "y" || answer === "yes") return "yes";
310
+ return "no";
296
311
  }
297
312
 
298
313
  /**
@@ -323,32 +338,54 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
323
338
  { name: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
324
339
  ];
325
340
 
326
- const done = [];
327
- const skipped = [];
328
- const failed = [];
329
-
330
- for (const step of steps) {
341
+ // Outcome per step rather than three lists, so revisiting a step replaces its
342
+ // result instead of recording it twice.
343
+ const outcome = new Map();
344
+
345
+ // Indexed rather than for..of so "back" can move the cursor. Re-running a
346
+ // step is safe: every one of these is idempotent on the backend -- an AWS
347
+ // grant returns the existing link, and an App install that already happened
348
+ // is detected rather than duplicated.
349
+ let i = 0;
350
+ while (i < steps.length) {
351
+ const step = steps[i];
331
352
  process.stdout.write(`\n── ${step.name} ──\n`);
332
- if (!(await askYes(step.question, { assumeYes }))) {
333
- skipped.push(step.name);
353
+
354
+ const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
355
+ if (choice === "back") {
356
+ // Drop the result we are about to redo, or the summary would report the
357
+ // stale outcome of a step the customer chose to revisit.
358
+ outcome.delete(steps[i - 1].name);
359
+ i -= 1;
334
360
  continue;
335
361
  }
362
+ if (choice === "no") {
363
+ outcome.set(step.name, { state: "skipped" });
364
+ i += 1;
365
+ continue;
366
+ }
367
+
336
368
  try {
337
369
  await step.run();
338
- done.push(step.name);
370
+ outcome.set(step.name, { state: "done" });
339
371
  } catch (err) {
340
372
  // Report and carry on: a failed Slack install must not cost the customer
341
373
  // the AWS connection they just finished.
342
- failed.push(`${step.name}: ${err.message}`);
374
+ outcome.set(step.name, { state: "failed", detail: err.message });
343
375
  process.stdout.write(`Could not finish ${step.name}: ${err.message}\n`);
344
376
  }
377
+ i += 1;
345
378
  }
346
379
 
347
380
  process.stdout.write("\n── done ──\n");
348
381
  // One line per step, marked, so the outcome is scannable rather than prose.
349
- for (const name of done) process.stdout.write(`${MARK.ok()} ${name}\n`);
350
- for (const name of skipped) process.stdout.write(`${MARK.skip()} ${name} (skipped)\n`);
351
- for (const failure of failed) process.stdout.write(`${MARK.fail()} ${failure}\n`);
382
+ for (const step of steps) {
383
+ const result = outcome.get(step.name);
384
+ if (!result) continue;
385
+ if (result.state === "done") process.stdout.write(`${MARK.ok()} ${step.name}\n`);
386
+ else if (result.state === "skipped") process.stdout.write(`${MARK.skip()} ${step.name} (skipped)\n`);
387
+ else process.stdout.write(`${MARK.fail()} ${step.name}: ${result.detail}\n`);
388
+ }
352
389
  process.stdout.write(`\nOpen ${frontendUrl(args)} to see your logs and endpoints.\n`);
353
390
  }
354
391
 
@@ -360,6 +397,7 @@ Setup options:
360
397
  preman slack Add PreMan to a Slack workspace
361
398
 
362
399
  --yes Accept every step without prompting (onboard)
400
+ b at any onboard prompt Go back to the previous step
363
401
  --account <id> AWS account id, skips the prompt
364
402
  --region <region> AWS region for log groups. Defaults to us-east-1
365
403
  --project-id <id> PreMan project to attach the log connector to
package/bin/shared.js CHANGED
@@ -7,6 +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
11
  import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -18,6 +19,28 @@ export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
18
19
  export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
19
20
 
20
21
  /** Wrap a raw argv slice in the positional lookup the CLI has always used. */
22
+ /**
23
+ * How to tell this user to invoke us.
24
+ *
25
+ * The help used to say "preman connect" unconditionally, which is only true
26
+ * after a global install. Anyone reading it straight out of
27
+ * `npm exec premanmcp -- --help` -- the way the docs tell them to run it --
28
+ * copied a command that answers "command not found".
29
+ */
30
+ let _invocation = null;
31
+
32
+ export function cliInvocation() {
33
+ if (_invocation) return _invocation;
34
+ const probe = process.platform === "win32" ? "where" : "which";
35
+ try {
36
+ const found = spawnSync(probe, ["preman"], { stdio: "pipe", encoding: "utf8" });
37
+ _invocation = found.status === 0 && found.stdout.trim() ? "preman" : null;
38
+ } catch {
39
+ _invocation = null;
40
+ }
41
+ return (_invocation ||= "npm exec -y premanmcp@latest --");
42
+ }
43
+
21
44
  export function makeArgs(commandArgs = []) {
22
45
  return {
23
46
  raw: commandArgs,
@@ -173,6 +196,12 @@ export async function callBackendJson(args, method, routePath, { json, token, qu
173
196
  } catch {
174
197
  body = { raw: text };
175
198
  }
199
+ // Some routes answer with a bare array. Spreading one into this object turns
200
+ // it into {0:…,1:…} and quietly loses the list, so it gets its own key.
201
+ if (Array.isArray(body)) {
202
+ return { status_code: resp.status, ok: resp.ok, list: body };
203
+ }
204
+
176
205
  return {
177
206
  status_code: resp.status,
178
207
  ok: resp.ok,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {