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 +56 -24
- package/bin/cli.js +37 -324
- package/bin/connect.js +432 -0
- package/bin/shared.js +348 -0
- package/package.json +1 -1
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
|
-
##
|
|
7
|
+
## Connect
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
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
|
|
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.
|
|
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": "
|
|
26
|
-
"args": ["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
npx premanmcp@latest connect --api-key pm_live_xxx
|
|
48
67
|
```
|
|
49
68
|
|
|
50
|
-
For project-local
|
|
69
|
+
For project-local config:
|
|
51
70
|
|
|
52
71
|
```bash
|
|
53
|
-
|
|
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
|
-
|
|
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": "
|
|
110
|
-
"args": ["
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
npm exec -y premanmcp@latest --
|
|
130
|
-
npm exec -y premanmcp@latest --
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ${
|
|
123
|
+
process.stdout.write(`PreMan MCP installed in ${written.path}
|
|
415
124
|
|
|
416
125
|
Server name: ${serverName}
|
|
417
|
-
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
|
+
}
|