premanmcp 0.3.5 → 0.5.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/shared.js ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Shared CLI helpers: prompts, credential storage, backend calls, terminal auth.
3
+ *
4
+ * Extracted from bin/cli.js so `connect` and `install` can use the same login
5
+ * and config-writing behavior instead of growing a second copy. Every function
6
+ * that reads flags takes an `args` accessor (see makeArgs) rather than closing
7
+ * over a module-level argv, so a command can pass its own slice.
8
+ */
9
+
10
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { createInterface } from "node:readline/promises";
14
+
15
+ export const DEFAULT_BACKEND = "https://api.preman.live";
16
+ export const DEFAULT_FRONTEND = "https://app.preman.live";
17
+ export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
18
+ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
19
+
20
+ /** Wrap a raw argv slice in the positional lookup the CLI has always used. */
21
+ export function makeArgs(commandArgs = []) {
22
+ return {
23
+ raw: commandArgs,
24
+ value(name, fallback = "") {
25
+ const index = commandArgs.indexOf(name);
26
+ if (index === -1) return fallback;
27
+ return commandArgs[index + 1] || fallback;
28
+ },
29
+ has(name) {
30
+ return commandArgs.includes(name);
31
+ },
32
+ };
33
+ }
34
+
35
+ export function readJsonFile(filePath) {
36
+ if (!existsSync(filePath)) return {};
37
+ const raw = readFileSync(filePath, "utf8").trim();
38
+ if (!raw) return {};
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch (error) {
42
+ throw new Error(`Could not parse ${filePath}: ${error.message}`);
43
+ }
44
+ }
45
+
46
+ export function writeJsonFile(filePath, value) {
47
+ mkdirSync(path.dirname(filePath), { recursive: true });
48
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
49
+ // writeFileSync's mode only applies when it creates the file, and these
50
+ // configs can hold a pm_live_ key — tighten an existing file explicitly.
51
+ chmodSync(filePath, 0o600);
52
+ }
53
+
54
+ export function readStoredCredentials() {
55
+ try {
56
+ const raw = readFileSync(CREDENTIALS_FILE, "utf8").trim();
57
+ if (!raw) return null;
58
+ const creds = JSON.parse(raw);
59
+ if (creds && typeof creds.api_key === "string" && creds.api_key.startsWith("pm_live_")) {
60
+ return creds;
61
+ }
62
+ } catch {
63
+ // No stored credentials yet.
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function saveStoredCredentials(creds) {
69
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
70
+ writeFileSync(CREDENTIALS_FILE, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
71
+ }
72
+
73
+ export function backendUrl(args) {
74
+ return args.value("--backend", process.env.PREMAN_BACKEND || DEFAULT_BACKEND).replace(/\/+$/, "");
75
+ }
76
+
77
+ export function frontendUrl(args) {
78
+ return args.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
79
+ }
80
+
81
+ export async function promptText(question) {
82
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
83
+ try {
84
+ return (await rl.question(question)).trim();
85
+ } finally {
86
+ rl.close();
87
+ }
88
+ }
89
+
90
+ export async function promptSecret(question) {
91
+ if (!process.stdin.isTTY || !process.stdin.setRawMode) {
92
+ return promptText(question);
93
+ }
94
+
95
+ return new Promise((resolve) => {
96
+ const stdin = process.stdin;
97
+ const stdout = process.stdout;
98
+ const wasRaw = stdin.isRaw;
99
+ let value = "";
100
+
101
+ function cleanup() {
102
+ stdin.off("data", onData);
103
+ stdin.setRawMode(Boolean(wasRaw));
104
+ stdin.pause();
105
+ }
106
+
107
+ function onData(chunk) {
108
+ const text = String(chunk);
109
+ if (text === "\u0003") {
110
+ stdout.write("\n");
111
+ cleanup();
112
+ process.exit(130);
113
+ }
114
+ if (text === "\r" || text === "\n" || text === "\u0004") {
115
+ stdout.write("\n");
116
+ cleanup();
117
+ resolve(value);
118
+ return;
119
+ }
120
+ if (text === "\u007f" || text === "\b") {
121
+ if (value.length) {
122
+ value = value.slice(0, -1);
123
+ stdout.write("\b \b");
124
+ }
125
+ return;
126
+ }
127
+ value += text;
128
+ stdout.write("*");
129
+ }
130
+
131
+ stdout.write(question);
132
+ stdin.setRawMode(true);
133
+ stdin.resume();
134
+ stdin.setEncoding("utf8");
135
+ stdin.on("data", onData);
136
+ });
137
+ }
138
+
139
+ export async function promptPasswordTwice() {
140
+ const password = await promptSecret("Create password: ");
141
+ if (!password || password.length < 6) {
142
+ throw new Error("Password must be at least 6 characters.");
143
+ }
144
+ const confirm = await promptSecret("Confirm password: ");
145
+ if (password !== confirm) {
146
+ throw new Error("Passwords do not match.");
147
+ }
148
+ return password;
149
+ }
150
+
151
+ export async function callBackendJson(args, method, routePath, { json, token, query } = {}) {
152
+ const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
153
+ if (query) {
154
+ for (const [key, value] of Object.entries(query)) {
155
+ if (value != null && value !== "") url.searchParams.set(key, String(value));
156
+ }
157
+ }
158
+
159
+ const headers = { Accept: "application/json" };
160
+ const hasBody = json !== undefined && json !== null;
161
+ if (hasBody) headers["Content-Type"] = "application/json";
162
+ if (token) headers.Authorization = `Bearer ${token}`;
163
+
164
+ const resp = await fetch(url, {
165
+ method,
166
+ headers,
167
+ body: hasBody ? JSON.stringify(json) : undefined,
168
+ });
169
+ const text = await resp.text();
170
+ let body = {};
171
+ try {
172
+ body = text ? JSON.parse(text) : {};
173
+ } catch {
174
+ body = { raw: text };
175
+ }
176
+ return {
177
+ status_code: resp.status,
178
+ ok: resp.ok,
179
+ ...body,
180
+ };
181
+ }
182
+
183
+ export function assertOk(result, action) {
184
+ if (result.ok) return;
185
+ const detail = result.detail || result.message || result.raw || `${action} failed`;
186
+ throw new Error(`${action} failed: ${result.status_code} ${detail}`);
187
+ }
188
+
189
+ async function verifyUnconfirmedAccount(args, email) {
190
+ process.stdout.write("This email exists but is not verified. Sending a new OTP.\n");
191
+ const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
192
+ assertOk(resend, "resend OTP");
193
+ const otp = await promptText("Verification code: ");
194
+ const verified = await callBackendJson(args, "POST", "/auth/verify-otp", { json: { email, otp } });
195
+ assertOk(verified, "verify OTP");
196
+ return verified.access_token;
197
+ }
198
+
199
+ async function createAccountFromTerminal(args, email) {
200
+ const signup = await callBackendJson(args, "POST", "/auth/start-signup", {
201
+ json: { email },
202
+ });
203
+
204
+ if (signup.ok) {
205
+ process.stdout.write("Verification code sent. Check your email.\n");
206
+ const otp = await promptText("Verification code: ");
207
+ const password = await promptPasswordTwice();
208
+ const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
209
+ json: { email, otp, new_password: password },
210
+ });
211
+ assertOk(setPassword, "set password");
212
+ return String(setPassword.access_token || "");
213
+ }
214
+
215
+ if (signup.status_code !== 404) {
216
+ assertOk(signup, "start signup");
217
+ }
218
+
219
+ process.stdout.write(
220
+ "This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
221
+ );
222
+ const password = await promptPasswordTwice();
223
+ const legacySignup = await callBackendJson(args, "POST", "/auth/signup", {
224
+ json: { email, password },
225
+ });
226
+ assertOk(legacySignup, "signup");
227
+ process.stdout.write("Verification code sent. Check your email.\n");
228
+ const otp = await promptText("Verification code: ");
229
+ const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
230
+ json: { email, otp },
231
+ });
232
+ assertOk(verified, "verify OTP");
233
+ return String(verified.access_token || "");
234
+ }
235
+
236
+ /**
237
+ * Get a usable pm_live_ key: an explicit one, or by logging in / signing up
238
+ * right here in the terminal. Always persists to ~/.preman/credentials.json.
239
+ */
240
+ export async function authenticateTerminal(args) {
241
+ const explicitKey = args.value("--api-key", process.env.PREMAN_API_KEY || "");
242
+ if (explicitKey && explicitKey.startsWith("pm_live_")) {
243
+ const creds = {
244
+ api_key: explicitKey,
245
+ backend_url: backendUrl(args),
246
+ user_email: args.value("--email", ""),
247
+ device_name: os.hostname(),
248
+ created_at: new Date().toISOString(),
249
+ };
250
+ saveStoredCredentials(creds);
251
+ return creds;
252
+ }
253
+
254
+ const email = (args.value("--email", "") || await promptText("Email: ")).trim().toLowerCase();
255
+ if (!email) throw new Error("Email is required.");
256
+
257
+ process.stdout.write(`Checking PreMan account for ${email}...\n`);
258
+ const account = await callBackendJson(args, "GET", "/auth/needs-password", { query: { email } });
259
+ assertOk(account, "check account");
260
+
261
+ let accessToken = "";
262
+
263
+ if (!account.exists) {
264
+ process.stdout.write("No PreMan account found. Creating one now.\n");
265
+ accessToken = await createAccountFromTerminal(args, email);
266
+ } else if (account.needs_password) {
267
+ process.stdout.write("This account needs a password. Sending an OTP first.\n");
268
+ const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
269
+ assertOk(resend, "resend OTP");
270
+ const otp = await promptText("Verification code: ");
271
+ const password = await promptPasswordTwice();
272
+ const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
273
+ json: { email, otp, new_password: password },
274
+ });
275
+ assertOk(setPassword, "set password");
276
+ accessToken = String(setPassword.access_token || "");
277
+ } else {
278
+ const password = await promptSecret("Password: ");
279
+ const login = await callBackendJson(args, "POST", "/auth/login", {
280
+ json: { email, password },
281
+ });
282
+ if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
283
+ accessToken = await verifyUnconfirmedAccount(args, email);
284
+ } else {
285
+ assertOk(login, "login");
286
+ accessToken = String(login.access_token || "");
287
+ }
288
+ }
289
+
290
+ if (!accessToken) throw new Error("PreMan did not return an access token.");
291
+
292
+ const keyName = `PreMan MCP CLI (${os.hostname()})`;
293
+ const key = await callBackendJson(args, "POST", "/api-keys", {
294
+ token: accessToken,
295
+ json: { name: keyName },
296
+ });
297
+ assertOk(key, "create API key");
298
+ if (!key.key || !String(key.key).startsWith("pm_live_")) {
299
+ throw new Error("PreMan did not return a valid API key.");
300
+ }
301
+
302
+ const creds = {
303
+ api_key: String(key.key),
304
+ backend_url: backendUrl(args),
305
+ user_email: email,
306
+ device_name: os.hostname(),
307
+ created_at: new Date().toISOString(),
308
+ };
309
+ saveStoredCredentials(creds);
310
+ return creds;
311
+ }
312
+
313
+ /** Does this invocation already have a key, without prompting for one? */
314
+ export function hasKeyAvailable(args) {
315
+ return Boolean(
316
+ args.value("--api-key", "") || process.env.PREMAN_API_KEY || readStoredCredentials()
317
+ );
318
+ }
319
+
320
+ /** Resolve the key to embed in a written config, if any. */
321
+ export function resolveApiKey(args) {
322
+ const explicit = args.value("--api-key", process.env.PREMAN_API_KEY || "");
323
+ if (explicit) return explicit;
324
+ const stored = readStoredCredentials();
325
+ return stored ? stored.api_key : "";
326
+ }
327
+
328
+ /**
329
+ * The MCP server block written into every agent's config.
330
+ *
331
+ * Matches the backend's install snippets (`build_install_snippets`) so the
332
+ * copy-paste path and this writer cannot drift.
333
+ */
334
+ export function buildServerConfig(args, { pairCode = "" } = {}) {
335
+ const env = {
336
+ PREMAN_BACKEND: backendUrl(args),
337
+ PREMAN_FRONTEND: frontendUrl(args),
338
+ };
339
+ const apiKey = resolveApiKey(args);
340
+ if (apiKey) env.PREMAN_API_KEY = apiKey;
341
+ if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
342
+
343
+ return {
344
+ command: "npx",
345
+ args: ["-y", "premanmcp@latest"],
346
+ env,
347
+ };
348
+ }
package/dist/server.js CHANGED
@@ -42,6 +42,62 @@ applyRepoPremanConfig();
42
42
  const BACKEND_URL = process.env.PREMAN_BACKEND || "https://api.preman.live";
43
43
  const FRONTEND_URL = process.env.PREMAN_FRONTEND || "https://app.preman.live";
44
44
  let API_KEY = process.env.PREMAN_API_KEY || "";
45
+ function detectCodingAgent() {
46
+ const forced = (process.env.PREMAN_CODING_AGENT || "").trim().toLowerCase();
47
+ if (forced)
48
+ return forced.replace("-", "_");
49
+ if (process.env.CLAUDECODE || process.env.CLAUDE_CODE)
50
+ return "claude_code";
51
+ if (process.env.CODEX_HOME || process.env.OPENAI_CODEX)
52
+ return "codex";
53
+ if (process.env.CURSOR_AGENT || process.env.CURSOR_TRACE_ID || process.env.CURSOR_SESSION_ID) {
54
+ return "cursor";
55
+ }
56
+ return "cursor";
57
+ }
58
+ /** Prove this MCP session is live to the workbench coding-agent link. */
59
+ async function heartbeatWorkbenchLink() {
60
+ if (!API_KEY)
61
+ return null;
62
+ try {
63
+ const resp = await fetch(`${BACKEND_URL}/workbench/coding-agent/heartbeat`, {
64
+ method: "POST",
65
+ headers: {
66
+ Authorization: `Bearer ${API_KEY}`,
67
+ "Content-Type": "application/json",
68
+ },
69
+ body: JSON.stringify({
70
+ pair_code: process.env.PREMAN_PAIR_CODE || undefined,
71
+ agent: detectCodingAgent(),
72
+ project_path: process.cwd(),
73
+ client_label: "premanmcp",
74
+ source: "preman_status",
75
+ }),
76
+ });
77
+ const text = await resp.text();
78
+ let data = {};
79
+ try {
80
+ data = text ? JSON.parse(text) : {};
81
+ }
82
+ catch {
83
+ data = { detail: text };
84
+ }
85
+ if (!resp.ok) {
86
+ return {
87
+ ok: false,
88
+ status: resp.status,
89
+ detail: data.detail || data.message || text,
90
+ };
91
+ }
92
+ return { ok: true, ...data };
93
+ }
94
+ catch (err) {
95
+ return {
96
+ ok: false,
97
+ detail: err instanceof Error ? err.message : String(err),
98
+ };
99
+ }
100
+ }
45
101
  const PREMAN_CONTROL_PLANE_HOSTS = new Set([
46
102
  "api.preman.live",
47
103
  "preman.live",
@@ -920,6 +976,7 @@ export function createServer() {
920
976
  }],
921
977
  };
922
978
  }
979
+ const workbench = await heartbeatWorkbenchLink();
923
980
  return {
924
981
  content: [{
925
982
  type: "text",
@@ -930,13 +987,17 @@ export function createServer() {
930
987
  backend_url: BACKEND_URL,
931
988
  frontend_base_url: FRONTEND_BASE,
932
989
  endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
990
+ coding_agent: workbench,
933
991
  _agent_hints: {
934
992
  next_actions: [
993
+ workbench && workbench.connected
994
+ ? "Coding agent is linked to PreMan workbench — call discover_endpoints_from_codebase or get_endpoints."
995
+ : "If PreMan workbench shows pairing, set PREMAN_PAIR_CODE then call preman_status again.",
935
996
  "browser_navigate to `endpoints_page_url` (SPA shell + ot_agent_route) to open PreMan on the Endpoints page (sign in if prompted), then call get_endpoints.",
936
997
  "Call get_endpoints to see registered API endpoints.",
937
998
  "Call test_api to test an endpoint.",
938
999
  ],
939
- related_tools: ["get_endpoints", "test_api", "import_collection"],
1000
+ related_tools: ["get_endpoints", "test_api", "import_collection", "discover_endpoints_from_codebase"],
940
1001
  },
941
1002
  }),
942
1003
  }],
@@ -1206,6 +1267,54 @@ export function createServer() {
1206
1267
  return toolError(e.message, inferErrorCode(e.message));
1207
1268
  }
1208
1269
  });
1270
+ // ── preman_get_fix_task ───────────────────────────────────────────
1271
+ server.tool("preman_get_fix_task", "Pull pending coding-agent fix tasks built from fired PreMan alerts. Each task packages a failing endpoint: title, expected vs actual, failure stats, and a reproducible curl (package.repro.curl). Fix the endpoint using the curl, then call preman_complete_fix_task with the fix_task_id. Check package.auto_pr: when eligible is true, push your fix on the branch it names and call preman_open_fix_pr.", {
1272
+ status: z.enum(["open", "delivered", "resolved"]).optional().default("open").describe("'open' (default) hands out new tasks and marks them delivered; 'delivered' re-fetches ones already pulled; 'resolved' for history"),
1273
+ limit: z.number().optional().default(5).describe("Max tasks to return (capped at 20)"),
1274
+ }, async (args) => {
1275
+ try {
1276
+ const result = await callBackend("preman_get_fix_task", args);
1277
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1278
+ }
1279
+ catch (e) {
1280
+ return toolError(e.message, inferErrorCode(e.message), {
1281
+ next_actions: ["No open fix tasks means no alerts have been handed off. Create a handoff from a fired alert in the dashboard."],
1282
+ });
1283
+ }
1284
+ });
1285
+ // ── preman_complete_fix_task ──────────────────────────────────────
1286
+ server.tool("preman_complete_fix_task", "Mark a fix task resolved once its endpoint failure is fixed.", {
1287
+ fix_task_id: z.string().describe("The id from a preman_get_fix_task result"),
1288
+ resolution_note: z.string().optional().default("").describe("Optional note on what was fixed"),
1289
+ }, async (args) => {
1290
+ try {
1291
+ const result = await callBackend("preman_complete_fix_task", args);
1292
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1293
+ }
1294
+ catch (e) {
1295
+ return toolError(e.message, inferErrorCode(e.message), {
1296
+ related_tools: ["preman_get_fix_task"],
1297
+ });
1298
+ }
1299
+ });
1300
+ // ── preman_open_fix_pr ────────────────────────────────────────────
1301
+ server.tool("preman_open_fix_pr", "Open a pull request for a fix branch you already pushed (Auto-PR, Tier 2). Only for fix tasks whose package.auto_pr.eligible is true. Patch and push the preman/fix-* branch with your own git credentials first — PreMan never pushes code. PreMan then verifies the branch exists, re-checks the endpoint in production, and opens a PR with that evidence. PreMan never merges: a human reviews and merges.", {
1302
+ fix_task_id: z.string().describe("The id from a preman_get_fix_task result"),
1303
+ branch: z.string().describe("The branch you pushed — must match package.auto_pr.branch"),
1304
+ summary: z.string().optional().default("").describe("Short description of the fix, included in the PR body"),
1305
+ local_rerun: z.string().optional().default("").describe("Your local test/re-run output, included as agent-reported evidence"),
1306
+ }, async (args) => {
1307
+ try {
1308
+ const result = await callBackend("preman_open_fix_pr", args);
1309
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1310
+ }
1311
+ catch (e) {
1312
+ return toolError(e.message, inferErrorCode(e.message), {
1313
+ related_tools: ["preman_get_fix_task", "preman_complete_fix_task"],
1314
+ next_actions: ["Auto-PR requires the repo to have opted in and the fix task to be an API_BUG failure mapped to that repo."],
1315
+ });
1316
+ }
1317
+ });
1209
1318
  return server;
1210
1319
  }
1211
1320
  // ── Start ─────────────────────────────────────────────────────────────
@@ -58,7 +58,7 @@ async function callAuthJson(base, method, path, opts) {
58
58
  */
59
59
  export function registerUserAuthFlowTools(server, backendUrl) {
60
60
  const base = normalizeBase(backendUrl);
61
- server.tool("user_auth_start_signup", "Start signup with email only. Sends an OTP; next call user_auth_set_password with email, OTP, and new password. Uses POST /auth/start-signup on PREMAN_BACKEND (no API key).", {
61
+ server.tool("user_auth_start_signup", "Start signup with email only. Sends an OTP; next call user_auth_set_password with email, OTP, and new password. Uses POST /auth/start-signup on PREMAN_BACKEND (no API key). If the backend does not support this endpoint yet, use user_auth_signup instead.", {
62
62
  email: z.string().describe("User email"),
63
63
  }, async (args) => {
64
64
  try {
@@ -66,6 +66,12 @@ export function registerUserAuthFlowTools(server, backendUrl) {
66
66
  json: { email: args.email },
67
67
  });
68
68
  if (!r.ok) {
69
+ if (r.status_code === 404) {
70
+ return toolError("This backend does not support email-only signup yet. Use user_auth_signup with email and password, then user_auth_verify_otp.", "backend_error", {
71
+ next_actions: ["Call user_auth_signup with email and password.", "Then call user_auth_verify_otp with the email code."],
72
+ related_tools: ["user_auth_signup", "user_auth_verify_otp"],
73
+ });
74
+ }
69
75
  return toolError(String(r.detail ?? r.message ?? "start signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
70
76
  next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
71
77
  related_tools: ["user_auth_set_password", "user_auth_login"],
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.3.5",
3
+ "version": "0.5.0",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "premanmcp": "bin/cli.js",
8
- "preman-mcp": "bin/cli.js"
8
+ "preman-mcp": "bin/cli.js",
9
+ "preman": "bin/cli.js"
9
10
  },
10
11
  "scripts": {
11
12
  "build": "tsc -p tsconfig.server.json",