premanmcp 0.4.0 → 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/README.md CHANGED
@@ -4,26 +4,33 @@ 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
- ## Install
7
+ ## Connect
8
8
 
9
9
  ```bash
10
- npm exec -y premanmcp@latest -- install
10
+ npx premanmcp@latest connect
11
11
  ```
12
12
 
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.
15
+
13
16
  Local development form:
14
17
 
15
18
  ```bash
16
- node bin/cli.js install
19
+ node bin/cli.js connect
17
20
  ```
18
21
 
19
- First-time users are prompted for email, OTP, and password directly in the terminal. PreMan creates or connects the account, generates an API key, saves it to `~/.preman/credentials.json`, then writes a `preman` MCP server into `~/.cursor/mcp.json`:
22
+ First-time users are prompted for email, OTP, and password directly in the terminal.
23
+ PreMan creates or connects the account, generates an API key, saves it to
24
+ `~/.preman/credentials.json`, then writes a `preman` MCP server into the config your
25
+ agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
26
+ `~/.codex/config.toml`):
20
27
 
21
28
  ```json
22
29
  {
23
30
  "mcpServers": {
24
31
  "preman": {
25
- "command": "npm",
26
- "args": ["exec", "-y", "--package", "premanmcp@latest", "--", "premanmcp"],
32
+ "command": "npx",
33
+ "args": ["-y", "premanmcp@latest"],
27
34
  "env": {
28
35
  "PREMAN_BACKEND": "https://api.preman.live",
29
36
  "PREMAN_FRONTEND": "https://app.preman.live"
@@ -33,9 +40,21 @@ First-time users are prompted for email, OTP, and password directly in the termi
33
40
  }
34
41
  ```
35
42
 
36
- Restart Cursor or toggle the MCP server off/on after install. The MCP server loads the saved key automatically.
43
+ Restart your agent afterwards, then ask it to `run preman_status` to finish linking.
44
+
45
+ Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
46
+ project-local config, `--print` shows the config without writing it.
47
+
48
+ `preman install` still exists and does the Cursor-only half of this.
49
+
50
+ ### Cloud dispatch (optional)
37
51
 
38
- You can also create or connect your account before install:
52
+ `connect` offers to store a coding-agent credential a Cursor API key, or a Claude
53
+ Code routine token and id. With one saved, PreMan can start an agent run for you when
54
+ it finds a failing endpoint instead of handing back a prompt to paste. Press Enter to
55
+ skip; everything else still works.
56
+
57
+ You can also create or connect your account first:
39
58
 
40
59
  ```bash
41
60
  npm exec -y premanmcp@latest -- login
@@ -44,13 +63,13 @@ npm exec -y premanmcp@latest -- login
44
63
  You can also pass the key directly:
45
64
 
46
65
  ```bash
47
- npm exec -y premanmcp@latest -- install --api-key pm_live_xxx
66
+ npx premanmcp@latest connect --api-key pm_live_xxx
48
67
  ```
49
68
 
50
- For project-local installation:
69
+ For project-local config:
51
70
 
52
71
  ```bash
53
- npm exec -y premanmcp@latest -- install --project
72
+ npx premanmcp@latest connect --project
54
73
  ```
55
74
 
56
75
  ## What It Does
@@ -94,20 +113,18 @@ Pull my pending PreMan fix tasks and fix the failing endpoint.
94
113
 
95
114
  ## Cursor
96
115
 
97
- The installer targets Cursor by default:
98
-
99
116
  ```bash
100
- npm exec -y premanmcp@latest -- install
117
+ npx premanmcp@latest connect --agent cursor
101
118
  ```
102
119
 
103
- Manual Cursor config:
120
+ Manual Cursor config, if you would rather write it yourself:
104
121
 
105
122
  ```json
106
123
  {
107
124
  "mcpServers": {
108
125
  "preman": {
109
- "command": "npm",
110
- "args": ["exec", "-y", "--package", "premanmcp@latest", "--", "premanmcp"]
126
+ "command": "npx",
127
+ "args": ["-y", "premanmcp@latest"]
111
128
  }
112
129
  }
113
130
  }
@@ -116,18 +133,33 @@ Manual Cursor config:
116
133
  ## Claude Code
117
134
 
118
135
  ```bash
119
- claude mcp add preman -- npm exec -y --package premanmcp@latest -- premanmcp
136
+ npx premanmcp@latest connect --agent claude-code
120
137
  ```
121
138
 
139
+ Equivalent manual command:
140
+
141
+ ```bash
142
+ claude mcp add preman -- npx -y premanmcp@latest
143
+ ```
144
+
145
+ ## Codex
146
+
147
+ ```bash
148
+ npx premanmcp@latest connect --agent codex
149
+ ```
150
+
151
+ Writes an `[mcp_servers.preman]` block into `~/.codex/config.toml`.
152
+
122
153
  ## CLI
123
154
 
124
155
  ```bash
125
- npm exec -y premanmcp@latest -- # Start the MCP server
126
- npm exec -y premanmcp@latest -- login # Create/login and generate a PreMan API key
127
- npm exec -y premanmcp@latest -- install # Install Cursor MCP config
128
- npm exec -y premanmcp@latest -- install --project # Write .cursor/mcp.json in current repo
129
- npm exec -y premanmcp@latest -- install --print # Print config without writing
130
- npm exec -y premanmcp@latest -- install --api-key KEY # Install with your PreMan API key
156
+ npx premanmcp@latest connect # Pick an agent and connect it
157
+ npx premanmcp@latest connect --agent codex # Skip the picker
158
+ npx premanmcp@latest connect --project # Write project-local config
159
+ npx premanmcp@latest connect --print # Print config without writing
160
+ npm exec -y premanmcp@latest -- # Start the MCP server
161
+ npm exec -y premanmcp@latest -- login # Create/login and generate a PreMan API key
162
+ npm exec -y premanmcp@latest -- install # Cursor-only installer (legacy)
131
163
  ```
132
164
 
133
165
  Options:
package/bin/cli.js CHANGED
@@ -11,20 +11,26 @@
11
11
  */
12
12
 
13
13
  import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { existsSync } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
- import { createInterface } from "node:readline/promises";
18
17
  import { fileURLToPath } from "node:url";
19
18
 
19
+ import { CONNECT_HELP, connectCommand, writeCursorConfig } from "./connect.js";
20
20
  import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
21
+ import {
22
+ CREDENTIALS_FILE,
23
+ DEFAULT_BACKEND,
24
+ DEFAULT_FRONTEND,
25
+ authenticateTerminal,
26
+ buildServerConfig,
27
+ hasKeyAvailable,
28
+ makeArgs,
29
+ readStoredCredentials,
30
+ } from "./shared.js";
21
31
 
22
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
33
  const ROOT = path.join(__dirname, "..");
24
- const DEFAULT_BACKEND = "https://api.preman.live";
25
- const DEFAULT_FRONTEND = "https://app.preman.live";
26
- const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
27
- const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
28
34
 
29
35
  const args = process.argv.slice(2);
30
36
  const command = args[0] === "--help" || args[0] === "-h"
@@ -33,31 +39,31 @@ const command = args[0] === "--help" || args[0] === "-h"
33
39
  ? args[0]
34
40
  : "start";
35
41
  const commandArgs = command === "start" ? args : args.slice(1);
42
+ const cliArgs = makeArgs(commandArgs);
36
43
 
37
44
  function argValue(name, fallback = "") {
38
- const index = commandArgs.indexOf(name);
39
- if (index === -1) return fallback;
40
- return commandArgs[index + 1] || fallback;
45
+ return cliArgs.value(name, fallback);
41
46
  }
42
47
 
43
48
  function hasFlag(name) {
44
- return commandArgs.includes(name);
49
+ return cliArgs.has(name);
45
50
  }
46
51
 
47
52
  function printHelp() {
48
53
  process.stdout.write(`PreMan MCP
49
54
 
50
55
  Usage:
56
+ preman connect [options] Pick a coding agent and connect it
51
57
  npm exec -y premanmcp@latest -- login Create/login to PreMan from the terminal
52
58
  npm exec -y premanmcp@latest -- install [options] Install PreMan into Cursor MCP config
53
59
  npm exec -y premanmcp@latest -- Start the PreMan MCP server
54
60
  preman link|tools|run ... Drive a published hosted MCP
55
-
61
+ ${CONNECT_HELP}
56
62
  Login options:
57
63
  --email <email> Pre-fill the email prompt
58
64
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
59
65
 
60
- Install options:
66
+ Install options (Cursor only — prefer 'preman connect'):
61
67
  --api-key <key> PreMan API key. If omitted, stored CLI credentials are used
62
68
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
63
69
  --frontend <url> PreMan frontend URL. Defaults to ${DEFAULT_FRONTEND}
@@ -68,296 +74,15 @@ Install options:
68
74
  --print Print the config instead of writing it
69
75
 
70
76
  Examples:
77
+ npx premanmcp@latest connect
78
+ preman connect --agent claude-code
71
79
  npm exec -y premanmcp@latest -- login
72
- npm exec -y premanmcp@latest -- install
73
- npm exec -y premanmcp@latest -- install --api-key pm_live_xxx
74
80
  npm exec -y premanmcp@latest -- install --project --backend http://127.0.0.1:8000
75
81
  ${HOSTED_HELP}`);
76
82
  }
77
83
 
78
- function readJsonFile(filePath) {
79
- if (!existsSync(filePath)) return {};
80
- const raw = readFileSync(filePath, "utf8").trim();
81
- if (!raw) return {};
82
- try {
83
- return JSON.parse(raw);
84
- } catch (error) {
85
- throw new Error(`Could not parse ${filePath}: ${error.message}`);
86
- }
87
- }
88
-
89
- function readStoredCredentials() {
90
- try {
91
- const raw = readFileSync(CREDENTIALS_FILE, "utf8").trim();
92
- if (!raw) return null;
93
- const creds = JSON.parse(raw);
94
- if (creds && typeof creds.api_key === "string" && creds.api_key.startsWith("pm_live_")) {
95
- return creds;
96
- }
97
- } catch {
98
- // No stored credentials yet.
99
- }
100
- return null;
101
- }
102
-
103
- function saveStoredCredentials(creds) {
104
- mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
105
- writeFileSync(CREDENTIALS_FILE, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
106
- }
107
-
108
- function backendUrl() {
109
- return argValue("--backend", process.env.PREMAN_BACKEND || DEFAULT_BACKEND).replace(/\/+$/, "");
110
- }
111
-
112
- async function promptText(question) {
113
- const rl = createInterface({ input: process.stdin, output: process.stdout });
114
- try {
115
- return (await rl.question(question)).trim();
116
- } finally {
117
- rl.close();
118
- }
119
- }
120
-
121
- async function promptSecret(question) {
122
- if (!process.stdin.isTTY || !process.stdin.setRawMode) {
123
- return promptText(question);
124
- }
125
-
126
- return new Promise((resolve) => {
127
- const stdin = process.stdin;
128
- const stdout = process.stdout;
129
- const wasRaw = stdin.isRaw;
130
- let value = "";
131
-
132
- function cleanup() {
133
- stdin.off("data", onData);
134
- stdin.setRawMode(Boolean(wasRaw));
135
- stdin.pause();
136
- }
137
-
138
- function onData(chunk) {
139
- const text = String(chunk);
140
- if (text === "\u0003") {
141
- stdout.write("\n");
142
- cleanup();
143
- process.exit(130);
144
- }
145
- if (text === "\r" || text === "\n" || text === "\u0004") {
146
- stdout.write("\n");
147
- cleanup();
148
- resolve(value);
149
- return;
150
- }
151
- if (text === "\u007f" || text === "\b") {
152
- if (value.length) {
153
- value = value.slice(0, -1);
154
- stdout.write("\b \b");
155
- }
156
- return;
157
- }
158
- value += text;
159
- stdout.write("*");
160
- }
161
-
162
- stdout.write(question);
163
- stdin.setRawMode(true);
164
- stdin.resume();
165
- stdin.setEncoding("utf8");
166
- stdin.on("data", onData);
167
- });
168
- }
169
-
170
- async function promptPasswordTwice() {
171
- const password = await promptSecret("Create password: ");
172
- if (!password || password.length < 6) {
173
- throw new Error("Password must be at least 6 characters.");
174
- }
175
- const confirm = await promptSecret("Confirm password: ");
176
- if (password !== confirm) {
177
- throw new Error("Passwords do not match.");
178
- }
179
- return password;
180
- }
181
-
182
- async function callBackendJson(method, routePath, { json, token, query } = {}) {
183
- const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl()}/`);
184
- if (query) {
185
- for (const [key, value] of Object.entries(query)) {
186
- if (value != null && value !== "") url.searchParams.set(key, String(value));
187
- }
188
- }
189
-
190
- const headers = { Accept: "application/json" };
191
- const hasBody = json !== undefined && json !== null;
192
- if (hasBody) headers["Content-Type"] = "application/json";
193
- if (token) headers.Authorization = `Bearer ${token}`;
194
-
195
- const resp = await fetch(url, {
196
- method,
197
- headers,
198
- body: hasBody ? JSON.stringify(json) : undefined,
199
- });
200
- const text = await resp.text();
201
- let body = {};
202
- try {
203
- body = text ? JSON.parse(text) : {};
204
- } catch {
205
- body = { raw: text };
206
- }
207
- return {
208
- status_code: resp.status,
209
- ok: resp.ok,
210
- ...body,
211
- };
212
- }
213
-
214
- function assertOk(result, action) {
215
- if (result.ok) return;
216
- const detail = result.detail || result.message || result.raw || `${action} failed`;
217
- throw new Error(`${action} failed: ${result.status_code} ${detail}`);
218
- }
219
-
220
- async function verifyUnconfirmedAccount(email) {
221
- process.stdout.write("This email exists but is not verified. Sending a new OTP.\n");
222
- const resend = await callBackendJson("POST", "/auth/resend-otp", { json: { email } });
223
- assertOk(resend, "resend OTP");
224
- const otp = await promptText("Verification code: ");
225
- const verified = await callBackendJson("POST", "/auth/verify-otp", { json: { email, otp } });
226
- assertOk(verified, "verify OTP");
227
- return verified.access_token;
228
- }
229
-
230
- async function createAccountFromTerminal(email) {
231
- const signup = await callBackendJson("POST", "/auth/start-signup", {
232
- json: { email },
233
- });
234
-
235
- if (signup.ok) {
236
- process.stdout.write("Verification code sent. Check your email.\n");
237
- const otp = await promptText("Verification code: ");
238
- const password = await promptPasswordTwice();
239
- const setPassword = await callBackendJson("POST", "/auth/set-password", {
240
- json: { email, otp, new_password: password },
241
- });
242
- assertOk(setPassword, "set password");
243
- return String(setPassword.access_token || "");
244
- }
245
-
246
- if (signup.status_code !== 404) {
247
- assertOk(signup, "start signup");
248
- }
249
-
250
- process.stdout.write(
251
- "This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
252
- );
253
- const password = await promptPasswordTwice();
254
- const legacySignup = await callBackendJson("POST", "/auth/signup", {
255
- json: { email, password },
256
- });
257
- assertOk(legacySignup, "signup");
258
- process.stdout.write("Verification code sent. Check your email.\n");
259
- const otp = await promptText("Verification code: ");
260
- const verified = await callBackendJson("POST", "/auth/verify-otp", {
261
- json: { email, otp },
262
- });
263
- assertOk(verified, "verify OTP");
264
- return String(verified.access_token || "");
265
- }
266
-
267
- async function authenticateTerminal() {
268
- const explicitKey = argValue("--api-key", process.env.PREMAN_API_KEY || "");
269
- if (explicitKey && explicitKey.startsWith("pm_live_")) {
270
- const creds = {
271
- api_key: explicitKey,
272
- backend_url: backendUrl(),
273
- user_email: argValue("--email", ""),
274
- device_name: os.hostname(),
275
- created_at: new Date().toISOString(),
276
- };
277
- saveStoredCredentials(creds);
278
- return creds;
279
- }
280
-
281
- const email = (argValue("--email", "") || await promptText("Email: ")).trim().toLowerCase();
282
- if (!email) throw new Error("Email is required.");
283
-
284
- process.stdout.write(`Checking PreMan account for ${email}...\n`);
285
- const account = await callBackendJson("GET", "/auth/needs-password", { query: { email } });
286
- assertOk(account, "check account");
287
-
288
- let accessToken = "";
289
-
290
- if (!account.exists) {
291
- process.stdout.write("No PreMan account found. Creating one now.\n");
292
- accessToken = await createAccountFromTerminal(email);
293
- } else if (account.needs_password) {
294
- process.stdout.write("This account needs a password. Sending an OTP first.\n");
295
- const resend = await callBackendJson("POST", "/auth/resend-otp", { json: { email } });
296
- assertOk(resend, "resend OTP");
297
- const otp = await promptText("Verification code: ");
298
- const password = await promptPasswordTwice();
299
- const setPassword = await callBackendJson("POST", "/auth/set-password", {
300
- json: { email, otp, new_password: password },
301
- });
302
- assertOk(setPassword, "set password");
303
- accessToken = String(setPassword.access_token || "");
304
- } else {
305
- const password = await promptSecret("Password: ");
306
- const login = await callBackendJson("POST", "/auth/login", {
307
- json: { email, password },
308
- });
309
- if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
310
- accessToken = await verifyUnconfirmedAccount(email);
311
- } else {
312
- assertOk(login, "login");
313
- accessToken = String(login.access_token || "");
314
- }
315
- }
316
-
317
- if (!accessToken) throw new Error("PreMan did not return an access token.");
318
-
319
- const keyName = `PreMan MCP CLI (${os.hostname()})`;
320
- const key = await callBackendJson("POST", "/api-keys", {
321
- token: accessToken,
322
- json: { name: keyName },
323
- });
324
- assertOk(key, "create API key");
325
- if (!key.key || !String(key.key).startsWith("pm_live_")) {
326
- throw new Error("PreMan did not return a valid API key.");
327
- }
328
-
329
- const creds = {
330
- api_key: String(key.key),
331
- backend_url: backendUrl(),
332
- user_email: email,
333
- device_name: os.hostname(),
334
- created_at: new Date().toISOString(),
335
- };
336
- saveStoredCredentials(creds);
337
- return creds;
338
- }
339
-
340
- function buildServerConfig() {
341
- const apiKey = argValue("--api-key", process.env.PREMAN_API_KEY || "");
342
- const backend = argValue("--backend", process.env.PREMAN_BACKEND || DEFAULT_BACKEND);
343
- const frontend = argValue("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND);
344
- const env = {
345
- PREMAN_BACKEND: backend,
346
- PREMAN_FRONTEND: frontend,
347
- };
348
- if (apiKey) {
349
- env.PREMAN_API_KEY = apiKey;
350
- }
351
-
352
- return {
353
- command: "npm",
354
- args: ["exec", "-y", "--package", "premanmcp@latest", "--", "premanmcp"],
355
- env,
356
- };
357
- }
358
-
359
84
  async function loginCommand() {
360
- const creds = await authenticateTerminal();
85
+ const creds = await authenticateTerminal(cliArgs);
361
86
  process.stdout.write(`PreMan account ready.
362
87
 
363
88
  Email: ${creds.user_email || "unknown"}
@@ -366,61 +91,47 @@ API key: ${creds.api_key}
366
91
  Saved to: ${CREDENTIALS_FILE}
367
92
 
368
93
  You can now run:
369
- npm exec -y premanmcp@latest -- install
94
+ preman connect
370
95
  `);
371
96
  }
372
97
 
98
+ /**
99
+ * Cursor-only installer, kept for the documented `install` flow. `connect` is
100
+ * the same write plus agent choice, pairing, and dispatch setup.
101
+ */
373
102
  async function installCursorMcp() {
374
103
  const serverName = argValue("--name", "preman");
375
104
  const projectInstall = hasFlag("--project");
376
105
 
377
- if (
378
- !hasFlag("--print") &&
379
- !hasFlag("--skip-login") &&
380
- !argValue("--api-key", "") &&
381
- !process.env.PREMAN_API_KEY &&
382
- !readStoredCredentials()
383
- ) {
106
+ if (!hasFlag("--print") && !hasFlag("--skip-login") && !hasKeyAvailable(cliArgs)) {
384
107
  process.stdout.write("First, let's create or connect your PreMan account.\n");
385
- await authenticateTerminal();
108
+ await authenticateTerminal(cliArgs);
386
109
  process.stdout.write("\n");
387
110
  }
388
111
 
389
- const serverConfig = buildServerConfig();
112
+ const serverConfig = buildServerConfig(cliArgs);
390
113
 
391
114
  if (hasFlag("--print")) {
392
115
  process.stdout.write(`${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`);
393
116
  return;
394
117
  }
395
118
 
396
- const configPath = projectInstall
397
- ? path.join(process.cwd(), ".cursor", "mcp.json")
398
- : path.join(os.homedir(), ".cursor", "mcp.json");
399
-
400
- const config = {
401
- ...readJsonFile(configPath),
402
- };
403
- config.mcpServers = {
404
- ...(config.mcpServers || {}),
405
- [serverName]: serverConfig,
406
- };
119
+ const written = writeCursorConfig({ serverName, serverConfig, projectInstall });
407
120
 
408
- const rendered = `${JSON.stringify(config, null, 2)}\n`;
409
- mkdirSync(path.dirname(configPath), { recursive: true });
410
- writeFileSync(configPath, rendered, { mode: 0o600 });
411
-
412
- const hasInlineKey = Boolean(config.mcpServers[serverName].env.PREMAN_API_KEY);
121
+ const hasInlineKey = Boolean(serverConfig.env.PREMAN_API_KEY);
413
122
  const hasStoredKey = Boolean(readStoredCredentials());
414
- process.stdout.write(`PreMan MCP installed in ${configPath}
123
+ process.stdout.write(`PreMan MCP installed in ${written.path}
415
124
 
416
125
  Server name: ${serverName}
417
- Backend: ${config.mcpServers[serverName].env.PREMAN_BACKEND}
126
+ Backend: ${serverConfig.env.PREMAN_BACKEND}
418
127
 
419
128
  Next steps:
420
129
  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."}
421
130
  2. Restart Cursor or toggle the PreMan MCP server off/on.
422
131
  3. In your API repo, ask your coding agent:
423
132
  "Use PreMan to convert the endpoints I choose into a hosted MCP server, then give me the Cursor/Claude install snippet."
133
+
134
+ Tip: 'preman connect' also supports Claude Code and Codex, and links the agent to your account.
424
135
  `);
425
136
  }
426
137
 
@@ -454,6 +165,8 @@ function startServer() {
454
165
  async function main() {
455
166
  if (command === "login") {
456
167
  await loginCommand();
168
+ } else if (command === "connect") {
169
+ await connectCommand(commandArgs);
457
170
  } else if (command === "install") {
458
171
  await installCursorMcp();
459
172
  } else if (command === "link") {
package/bin/connect.js ADDED
@@ -0,0 +1,432 @@
1
+ /**
2
+ * `preman connect` — pick a coding agent and get connected, in one command.
3
+ *
4
+ * Replaces the hand-edited MCP config: the user picks Cursor / Claude Code /
5
+ * Codex from a list, PreMan logs them in if needed, writes that agent's config
6
+ * itself, and binds the connection to their account with a pair code.
7
+ *
8
+ * Optionally captures a cloud-dispatch credential in the same pass so PreMan can
9
+ * start agent runs for them later (SCRUM-124). That step is always skippable and
10
+ * never fails the connect.
11
+ */
12
+
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ import {
19
+ assertOk,
20
+ authenticateTerminal,
21
+ backendUrl,
22
+ buildServerConfig,
23
+ callBackendJson,
24
+ hasKeyAvailable,
25
+ makeArgs,
26
+ promptSecret,
27
+ promptText,
28
+ readJsonFile,
29
+ resolveApiKey,
30
+ writeJsonFile,
31
+ } from "./shared.js";
32
+
33
+ const EXIT_USAGE = 2;
34
+
35
+ class ConnectError extends Error {
36
+ constructor(message, exitCode = 1) {
37
+ super(message);
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Supported agents. `id` matches the backend's normalize_agent() vocabulary so
44
+ * the link record and this CLI cannot disagree about who is connected.
45
+ */
46
+ const AGENTS = [
47
+ {
48
+ id: "cursor",
49
+ label: "Cursor",
50
+ aliases: ["cursor"],
51
+ dispatch: { credential: "Cursor API key", needsRoutine: false },
52
+ },
53
+ {
54
+ id: "claude_code",
55
+ label: "Claude Code",
56
+ aliases: ["claude", "claude-code", "claude_code", "claudecode"],
57
+ dispatch: { credential: "Claude Code routine token", needsRoutine: true },
58
+ },
59
+ {
60
+ id: "codex",
61
+ label: "Codex",
62
+ aliases: ["codex", "openai-codex", "openai_codex"],
63
+ dispatch: null, // No public fire API; stays on the copy-paste path.
64
+ },
65
+ ];
66
+
67
+ function findAgent(value) {
68
+ const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
69
+ return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
70
+ }
71
+
72
+ function onPath(binary) {
73
+ const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
74
+ stdio: "ignore",
75
+ });
76
+ return probe.status === 0;
77
+ }
78
+
79
+ /** Best guess at which agent this machine actually uses, for the default pick. */
80
+ function detectAgents() {
81
+ const home = os.homedir();
82
+ return {
83
+ cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
84
+ claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
85
+ codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
86
+ };
87
+ }
88
+
89
+ async function promptAgentChoice(detected) {
90
+ process.stdout.write("Which coding agent?\n");
91
+ AGENTS.forEach((agent, index) => {
92
+ const mark = detected[agent.id] ? " (detected)" : "";
93
+ process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
94
+ });
95
+
96
+ const defaultIndex = Math.max(
97
+ 0,
98
+ AGENTS.findIndex((a) => detected[a.id])
99
+ );
100
+ const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
101
+ if (!answer) return AGENTS[defaultIndex];
102
+
103
+ const byNumber = Number.parseInt(answer, 10);
104
+ if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
105
+ return AGENTS[byNumber - 1];
106
+ }
107
+ const byName = findAgent(answer);
108
+ if (byName) return byName;
109
+ throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
110
+ }
111
+
112
+ // ── Config writers ──────────────────────────────────────────────────────
113
+
114
+ function cursorConfigPath(projectInstall) {
115
+ return projectInstall
116
+ ? path.join(process.cwd(), ".cursor", "mcp.json")
117
+ : path.join(os.homedir(), ".cursor", "mcp.json");
118
+ }
119
+
120
+ export function writeCursorConfig({ serverName, serverConfig, projectInstall }) {
121
+ const configPath = cursorConfigPath(projectInstall);
122
+ const config = { ...readJsonFile(configPath) };
123
+ config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
124
+ writeJsonFile(configPath, config);
125
+ return { path: configPath, how: "wrote" };
126
+ }
127
+
128
+ /**
129
+ * Claude Code owns ~/.claude.json, so prefer its own CLI. Fall back to writing
130
+ * the config directly when `claude` is not installed — a user can connect before
131
+ * installing the agent.
132
+ */
133
+ export function writeClaudeConfig({ serverName, serverConfig, projectInstall }) {
134
+ if (onPath("claude")) {
135
+ const envArgs = Object.entries(serverConfig.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
136
+ const args = [
137
+ "mcp",
138
+ "add",
139
+ serverName,
140
+ "--scope",
141
+ projectInstall ? "project" : "user",
142
+ ...envArgs,
143
+ "--",
144
+ serverConfig.command,
145
+ ...serverConfig.args,
146
+ ];
147
+ try {
148
+ execFileSync("claude", args, { stdio: "pipe" });
149
+ return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
150
+ } catch (error) {
151
+ const detail = error?.stderr?.toString().trim() || error.message;
152
+ process.stdout.write(`Note: claude mcp add failed (${detail}); writing the config directly.\n`);
153
+ }
154
+ }
155
+
156
+ if (projectInstall) {
157
+ const configPath = path.join(process.cwd(), ".mcp.json");
158
+ const config = { ...readJsonFile(configPath) };
159
+ config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
160
+ writeJsonFile(configPath, config);
161
+ return { path: configPath, how: "wrote" };
162
+ }
163
+
164
+ // Merge only mcpServers: the user config holds a lot of unrelated state.
165
+ // CLAUDE_CONFIG_DIR relocates that file, and writing to ~ when it is set
166
+ // produces a config Claude Code never reads.
167
+ const configPath = path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
168
+ const config = { ...readJsonFile(configPath) };
169
+ config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
170
+ writeJsonFile(configPath, config);
171
+ return { path: configPath, how: "wrote" };
172
+ }
173
+
174
+ /**
175
+ * Render the `[mcp_servers.<name>]` block for Codex's TOML config. Only strings
176
+ * and string arrays appear here, so a hand-rolled writer is enough — no TOML
177
+ * dependency for one fixed shape.
178
+ */
179
+ export function renderCodexToml(serverName, serverConfig) {
180
+ const str = (value) => JSON.stringify(String(value));
181
+ const lines = [
182
+ `[mcp_servers.${serverName}]`,
183
+ `command = ${str(serverConfig.command)}`,
184
+ `args = [${serverConfig.args.map(str).join(", ")}]`,
185
+ ];
186
+ const env = Object.entries(serverConfig.env);
187
+ if (env.length) {
188
+ lines.push("", `[mcp_servers.${serverName}.env]`);
189
+ for (const [key, value] of env) lines.push(`${key} = ${str(value)}`);
190
+ }
191
+ return `${lines.join("\n")}\n`;
192
+ }
193
+
194
+ /** Replace an existing preman block, or append a new one, leaving the rest alone. */
195
+ export function upsertCodexToml(existing, serverName, block) {
196
+ const header = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`, "m");
197
+ if (!header.test(existing)) {
198
+ const separator = existing.trim() ? "\n" : "";
199
+ return `${existing.trimEnd()}${separator}\n${block}`;
200
+ }
201
+
202
+ const lines = existing.split("\n");
203
+ const out = [];
204
+ let skipping = false;
205
+ for (const line of lines) {
206
+ const isOurHeader = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`).test(line);
207
+ if (isOurHeader) {
208
+ skipping = true;
209
+ continue;
210
+ }
211
+ if (skipping && /^\[/.test(line) && !isOurHeader) skipping = false;
212
+ if (!skipping) out.push(line);
213
+ }
214
+ return `${out.join("\n").trimEnd()}\n\n${block}`;
215
+ }
216
+
217
+ export function writeCodexConfig({ serverName, serverConfig }) {
218
+ const configPath = path.join(
219
+ process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
220
+ "config.toml"
221
+ );
222
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
223
+ const next = upsertCodexToml(existing, serverName, renderCodexToml(serverName, serverConfig));
224
+ mkdirSync(path.dirname(configPath), { recursive: true });
225
+ writeFileSync(configPath, next, { mode: 0o600 });
226
+ chmodSync(configPath, 0o600); // mode above is ignored for an existing file
227
+ return { path: configPath, how: "wrote" };
228
+ }
229
+
230
+ const WRITERS = {
231
+ cursor: writeCursorConfig,
232
+ claude_code: writeClaudeConfig,
233
+ codex: writeCodexConfig,
234
+ };
235
+
236
+ // ── Pairing ─────────────────────────────────────────────────────────────
237
+
238
+ async function startPairing(args, agent, apiKey) {
239
+ const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
240
+ token: apiKey,
241
+ json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
242
+ });
243
+ if (!result.ok) {
244
+ process.stdout.write(
245
+ `Note: could not start pairing (${result.status_code}); the config still works, ` +
246
+ "your agent will link on its first PreMan call.\n"
247
+ );
248
+ return "";
249
+ }
250
+ return String(result.pair_code || "");
251
+ }
252
+
253
+ async function waitForConnection(args, apiKey, { intervalMs = 3000, timeoutMs = 300000 } = {}) {
254
+ const deadline = Date.now() + timeoutMs;
255
+ let interrupted = false;
256
+ const onInterrupt = () => {
257
+ interrupted = true;
258
+ };
259
+ process.on("SIGINT", onInterrupt);
260
+
261
+ try {
262
+ while (Date.now() < deadline && !interrupted) {
263
+ const status = await callBackendJson(args, "GET", "/workbench/coding-agent", {
264
+ token: apiKey,
265
+ });
266
+ if (status.ok && status.connected) return true;
267
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
268
+ }
269
+ } finally {
270
+ process.off("SIGINT", onInterrupt);
271
+ }
272
+ return false;
273
+ }
274
+
275
+ // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
276
+
277
+ async function captureDispatchCredential(args, agent, apiKey) {
278
+ if (!agent.dispatch) return;
279
+ if (args.has("--skip-dispatch-credential")) return;
280
+
281
+ let secret = args.value("--dispatch-credential", "");
282
+ let routineId = args.value("--routine-id", "");
283
+
284
+ if (!secret) {
285
+ if (!process.stdin.isTTY) return;
286
+ process.stdout.write(
287
+ `\nOptional: paste a ${agent.dispatch.credential} so PreMan can start ${agent.label} runs for you.\n`
288
+ );
289
+ secret = await promptSecret("(Enter to skip): ");
290
+ if (!secret) return;
291
+ if (agent.dispatch.needsRoutine && !routineId) {
292
+ routineId = await promptText("Routine id or URL: ");
293
+ }
294
+ }
295
+
296
+ if (agent.dispatch.needsRoutine && !routineId) {
297
+ process.stdout.write("Skipped: a routine id is required alongside the token.\n");
298
+ return;
299
+ }
300
+
301
+ const body = agent.dispatch.needsRoutine
302
+ ? { provider: agent.id, secret, routine_id: extractRoutineId(routineId) }
303
+ : { provider: agent.id, secret };
304
+
305
+ const result = await callBackendJson(args, "PUT", "/workbench/coding-agent/dispatch", {
306
+ token: apiKey,
307
+ json: body,
308
+ });
309
+
310
+ if (result.ok) {
311
+ process.stdout.write("Cloud dispatch enabled — PreMan can now start runs for you.\n");
312
+ } else if (result.status_code === 404 || result.status_code === 405) {
313
+ process.stdout.write("Note: this PreMan backend does not support cloud dispatch yet; skipped.\n");
314
+ } else {
315
+ process.stdout.write(`Note: could not save the credential (${result.status_code}); skipped.\n`);
316
+ }
317
+ }
318
+
319
+ /** Accept either a bare trig_… id or the routine URL it appears in. */
320
+ export function extractRoutineId(value) {
321
+ const raw = String(value || "").trim();
322
+ const match = raw.match(/trig_[A-Za-z0-9]+/);
323
+ return match ? match[0] : raw;
324
+ }
325
+
326
+ // ── Command ─────────────────────────────────────────────────────────────
327
+
328
+ export const CONNECT_HELP = `
329
+ Connect options:
330
+ --agent <name> cursor | claude-code | codex (skips the picker)
331
+ --project Write project-local config instead of the user config
332
+ --api-key <key> PreMan API key. If omitted, stored credentials are used
333
+ --email <email> Pre-fill the email prompt when logging in
334
+ --backend <url> PreMan backend URL
335
+ --frontend <url> PreMan frontend URL
336
+ --name <name> MCP server name. Defaults to preman
337
+ --dispatch-credential <t> Cloud-dispatch token (non-interactive)
338
+ --routine-id <id> Claude Code routine id, with --dispatch-credential
339
+ --skip-dispatch-credential Do not ask for a cloud-dispatch credential
340
+ --skip-login Write config without interactive terminal auth
341
+ --no-pair Do not mint a pair code
342
+ --no-wait Do not wait for the agent to check in
343
+ --print Print the config instead of writing it
344
+ `;
345
+
346
+ export async function connectCommand(commandArgs) {
347
+ const args = makeArgs(commandArgs);
348
+ const serverName = args.value("--name", "preman");
349
+ const projectInstall = args.has("--project");
350
+ const printOnly = args.has("--print");
351
+ const interactive = Boolean(process.stdin.isTTY);
352
+
353
+ let agent = findAgent(args.value("--agent", ""));
354
+ if (!agent && args.value("--agent", "")) {
355
+ throw new ConnectError(
356
+ `Unknown agent: ${args.value("--agent", "")}. Use cursor, claude-code, or codex.`,
357
+ EXIT_USAGE
358
+ );
359
+ }
360
+
361
+ if (!agent) {
362
+ if (!interactive) {
363
+ throw new ConnectError(
364
+ "preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
365
+ "and --api-key pm_live_… (or --print).",
366
+ EXIT_USAGE
367
+ );
368
+ }
369
+ agent = await promptAgentChoice(detectAgents());
370
+ }
371
+
372
+ if (printOnly) {
373
+ const serverConfig = buildServerConfig(args);
374
+ if (agent.id === "codex") {
375
+ process.stdout.write(renderCodexToml(serverName, serverConfig));
376
+ } else {
377
+ process.stdout.write(`${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`);
378
+ }
379
+ return;
380
+ }
381
+
382
+ if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
383
+ if (!interactive) {
384
+ throw new ConnectError(
385
+ "No PreMan credentials. Pass --api-key pm_live_… or set PREMAN_API_KEY.",
386
+ EXIT_USAGE
387
+ );
388
+ }
389
+ process.stdout.write("First, let's connect your PreMan account.\n");
390
+ await authenticateTerminal(args);
391
+ process.stdout.write("\n");
392
+ }
393
+
394
+ const apiKey = resolveApiKey(args);
395
+
396
+ let pairCode = "";
397
+ if (apiKey && !args.has("--no-pair")) {
398
+ pairCode = await startPairing(args, agent, apiKey);
399
+ }
400
+
401
+ const serverConfig = buildServerConfig(args, { pairCode });
402
+ const written = WRITERS[agent.id]({ serverName, serverConfig, projectInstall });
403
+
404
+ process.stdout.write(
405
+ `\n${agent.label} connected: ${written.how} ${written.path}\n` +
406
+ `Server name: ${serverName}\n` +
407
+ `Backend: ${serverConfig.env.PREMAN_BACKEND}\n`
408
+ );
409
+
410
+ // Not gated on TTY: --dispatch-credential is the non-interactive path, and the
411
+ // prompt inside only runs when there is a terminal to prompt on.
412
+ await captureDispatchCredential(args, agent, apiKey);
413
+
414
+ if (!pairCode || args.has("--no-wait") || !interactive) {
415
+ process.stdout.write(
416
+ `\nRestart ${agent.label}, then ask it: "run preman_status" to finish linking.\n`
417
+ );
418
+ return;
419
+ }
420
+
421
+ process.stdout.write(
422
+ `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
423
+ "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
424
+ );
425
+ const connected = await waitForConnection(args, apiKey);
426
+ process.stdout.write(
427
+ connected
428
+ ? `Connected as ${agent.label}.\n`
429
+ : `No check-in yet. Open ${agent.label} and ask it to "run preman_status" — ` +
430
+ "it will link on its first PreMan call.\n"
431
+ );
432
+ }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.4.0",
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": {