premanmcp 0.8.0 → 0.9.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 +23 -13
- package/bin/account.js +2 -13
- package/bin/cli.js +11 -2
- package/bin/connect.js +260 -53
- package/bin/integrations.js +6 -26
- package/bin/shared.js +99 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,11 +19,12 @@ Local development form:
|
|
|
19
19
|
node bin/cli.js connect
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
First-time users are
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
`~/.
|
|
22
|
+
First-time users are asked for an email and the code sent to it — no password: the
|
|
23
|
+
terminal's credential is the `pm_live_` key. Pass `--password` to also set one for
|
|
24
|
+
dashboard sign-in, or set it later from the dashboard. PreMan creates or connects the
|
|
25
|
+
account, generates an API key, saves it to `~/.preman/credentials.json`, then writes a
|
|
26
|
+
`preman` MCP server into the config your agent actually reads (`~/.cursor/mcp.json`,
|
|
27
|
+
Claude Code's MCP config, or `~/.codex/config.toml`):
|
|
27
28
|
|
|
28
29
|
```json
|
|
29
30
|
{
|
|
@@ -40,11 +41,12 @@ agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
|
|
|
40
41
|
}
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
Restart your agent afterwards, then ask it to `run preman_status` to finish linking.
|
|
44
|
-
|
|
45
44
|
`connect` reads the config back after writing it, and prints a copy-paste snippet if it
|
|
46
|
-
cannot confirm the entry landed. It then
|
|
47
|
-
|
|
45
|
+
cannot confirm the entry landed. It then finishes the link itself by running your agent
|
|
46
|
+
headlessly (`claude -p`, `cursor-agent -p`, `codex exec`) so it makes one PreMan call —
|
|
47
|
+
that check-in is the only real proof the agent loaded the server. If the agent's CLI is
|
|
48
|
+
not on PATH, `connect` asks you to restart it and waits instead. `--no-auto-checkin`
|
|
49
|
+
turns the spawn off.
|
|
48
50
|
|
|
49
51
|
Once the agent has checked in, `connect` walks you into your first test: it runs one
|
|
50
52
|
against an endpoint you already have, or prints the discovery brief to hand your agent,
|
|
@@ -62,10 +64,18 @@ setup blocks for all three agents and exits 2.
|
|
|
62
64
|
|
|
63
65
|
### Cloud dispatch (optional)
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
With a coding-agent credential saved — a Cursor API key from
|
|
68
|
+
`cursor.com/dashboard → Integrations → API Keys`, or a Claude Code routine token and id
|
|
69
|
+
from `claude.ai/code/routines → your routine → Add API trigger` — PreMan can start an
|
|
70
|
+
agent run for you when it finds a failing endpoint, instead of handing back a prompt to
|
|
71
|
+
paste.
|
|
72
|
+
|
|
73
|
+
`connect` asks for it once you are already set up, so skipping costs nothing. Come back
|
|
74
|
+
to it any time:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm exec -y premanmcp@latest -- dispatch
|
|
78
|
+
```
|
|
69
79
|
|
|
70
80
|
You can also create or connect your account first:
|
|
71
81
|
|
package/bin/account.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, rmSync } from "node:fs";
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
11
|
import os from "node:os";
|
|
13
12
|
|
|
14
13
|
import { detectCandidates } from "./detect.js";
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
cliInvocation,
|
|
20
19
|
frontendUrl,
|
|
21
20
|
makeArgs,
|
|
21
|
+
openUrl,
|
|
22
22
|
readStoredCredentials,
|
|
23
23
|
resolveApiKey,
|
|
24
24
|
saveStoredCredentials,
|
|
@@ -35,17 +35,6 @@ Account options:
|
|
|
35
35
|
const POLL_INTERVAL_MS = 3000;
|
|
36
36
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
37
37
|
|
|
38
|
-
function openBrowser(url) {
|
|
39
|
-
const opener =
|
|
40
|
-
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
41
|
-
try {
|
|
42
|
-
spawn(opener, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
38
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
39
|
|
|
51
40
|
export async function loginBrowser(args) {
|
|
@@ -61,7 +50,7 @@ export async function loginBrowser(args) {
|
|
|
61
50
|
`\nApprove this device in your browser:\n ${verificationUrl}\n\n` +
|
|
62
51
|
`Confirmation code: ${start.user_code}\n\n`
|
|
63
52
|
);
|
|
64
|
-
if (!
|
|
53
|
+
if (!openUrl(verificationUrl)) {
|
|
65
54
|
process.stdout.write("Could not open a browser automatically — open the link above.\n");
|
|
66
55
|
}
|
|
67
56
|
process.stdout.write("Waiting for approval…\n");
|
package/bin/cli.js
CHANGED
|
@@ -17,7 +17,13 @@ import path from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
19
|
import { ENDPOINTS_HELP, TEST_HELP, endpointsCommand, testCommand } from "./api_tools.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CONNECT_HELP,
|
|
22
|
+
DISPATCH_HELP,
|
|
23
|
+
connectCommand,
|
|
24
|
+
dispatchCommand,
|
|
25
|
+
writeCursorConfig,
|
|
26
|
+
} from "./connect.js";
|
|
21
27
|
import {
|
|
22
28
|
INTEGRATIONS_HELP,
|
|
23
29
|
awsCommand,
|
|
@@ -78,6 +84,7 @@ function printHelp() {
|
|
|
78
84
|
["install-desktop", "Download and install the PreMan desktop app"],
|
|
79
85
|
["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
|
|
80
86
|
["connect [options]", "Pick a coding agent and connect it"],
|
|
87
|
+
["dispatch [options]", "Let PreMan start agent runs for you"],
|
|
81
88
|
["aws | github | slack", "Connect one integration on its own"],
|
|
82
89
|
["login [--browser]", "Create/login to PreMan from the terminal"],
|
|
83
90
|
["logout", "Delete stored CLI credentials"],
|
|
@@ -97,7 +104,7 @@ function printHelp() {
|
|
|
97
104
|
|
|
98
105
|
Usage:
|
|
99
106
|
${usageLines}
|
|
100
|
-
${INTEGRATIONS_HELP}${CONNECT_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
107
|
+
${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
101
108
|
Login options:
|
|
102
109
|
--email <email> Pre-fill the email prompt
|
|
103
110
|
--backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
|
|
@@ -224,6 +231,8 @@ async function main() {
|
|
|
224
231
|
await installDesktopCommand(commandArgs);
|
|
225
232
|
} else if (command === "connect") {
|
|
226
233
|
await connectCommand(commandArgs);
|
|
234
|
+
} else if (command === "dispatch") {
|
|
235
|
+
await dispatchCommand(commandArgs);
|
|
227
236
|
} else if (command === "onboard" || command === "setup") {
|
|
228
237
|
// makeArgs/authenticateTerminal/connectCommand are injected rather than
|
|
229
238
|
// imported there, so integrations.js stays free of a cycle back into the CLI.
|
package/bin/connect.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* never fails the connect.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
@@ -53,7 +53,11 @@ const AGENTS = [
|
|
|
53
53
|
id: "cursor",
|
|
54
54
|
label: "Cursor",
|
|
55
55
|
aliases: ["cursor"],
|
|
56
|
-
dispatch: {
|
|
56
|
+
dispatch: {
|
|
57
|
+
credential: "Cursor API key",
|
|
58
|
+
needsRoutine: false,
|
|
59
|
+
source: "cursor.com/dashboard → Integrations → API Keys",
|
|
60
|
+
},
|
|
57
61
|
snippetHint: "merge into ~/.cursor/mcp.json",
|
|
58
62
|
restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
|
|
59
63
|
},
|
|
@@ -61,7 +65,13 @@ const AGENTS = [
|
|
|
61
65
|
id: "claude_code",
|
|
62
66
|
label: "Claude Code",
|
|
63
67
|
aliases: ["claude", "claude-code", "claude_code", "claudecode"],
|
|
64
|
-
dispatch: {
|
|
68
|
+
dispatch: {
|
|
69
|
+
credential: "Claude Code routine token",
|
|
70
|
+
needsRoutine: true,
|
|
71
|
+
// Named explicitly because "Claude Code routine token" reads like an
|
|
72
|
+
// Anthropic API key, which is a different credential from a different page.
|
|
73
|
+
source: "claude.ai/code/routines → your routine → Add API trigger",
|
|
74
|
+
},
|
|
65
75
|
snippetHint: "run:",
|
|
66
76
|
restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
|
|
67
77
|
},
|
|
@@ -97,17 +107,36 @@ function detectAgents() {
|
|
|
97
107
|
};
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The agent whose session this command is running inside, if any.
|
|
112
|
+
*
|
|
113
|
+
* A better default than "first one installed": someone who types this into an
|
|
114
|
+
* agent's terminal almost always means that agent, and on a machine with all
|
|
115
|
+
* three installed the detected-order default is usually wrong.
|
|
116
|
+
*/
|
|
117
|
+
export function runningInside(env = process.env) {
|
|
118
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
|
|
119
|
+
if (env.CURSOR_TRACE_ID) return "cursor";
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function promptAgentChoice(detected, insideId = runningInside()) {
|
|
101
124
|
process.stdout.write("Which coding agent?\n");
|
|
102
125
|
AGENTS.forEach((agent, index) => {
|
|
103
|
-
|
|
126
|
+
let mark = "";
|
|
127
|
+
if (agent.id === insideId) mark = " (this session)";
|
|
128
|
+
else if (detected[agent.id]) mark = " (detected)";
|
|
104
129
|
process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
|
|
105
130
|
});
|
|
106
131
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
const inside = AGENTS.findIndex((a) => a.id === insideId);
|
|
133
|
+
const defaultIndex =
|
|
134
|
+
inside >= 0
|
|
135
|
+
? inside
|
|
136
|
+
: Math.max(
|
|
137
|
+
0,
|
|
138
|
+
AGENTS.findIndex((a) => detected[a.id])
|
|
139
|
+
);
|
|
111
140
|
const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
|
|
112
141
|
if (!answer) return AGENTS[defaultIndex];
|
|
113
142
|
|
|
@@ -381,6 +410,7 @@ export async function waitForConnection(
|
|
|
381
410
|
{
|
|
382
411
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
383
412
|
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
413
|
+
stopWhen = null,
|
|
384
414
|
} = {}
|
|
385
415
|
) {
|
|
386
416
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -396,6 +426,7 @@ export async function waitForConnection(
|
|
|
396
426
|
token: apiKey,
|
|
397
427
|
});
|
|
398
428
|
if (status.ok && status.connected) return true;
|
|
429
|
+
if (stopWhen && stopWhen()) return false;
|
|
399
430
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
400
431
|
}
|
|
401
432
|
} finally {
|
|
@@ -404,9 +435,94 @@ export async function waitForConnection(
|
|
|
404
435
|
return false;
|
|
405
436
|
}
|
|
406
437
|
|
|
438
|
+
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* How to ask each agent to make one PreMan call without opening its UI.
|
|
442
|
+
*
|
|
443
|
+
* The link is established by the agent's first call, so anything that reaches
|
|
444
|
+
* `preman_status` finishes the connect. Claude Code needs the server on its
|
|
445
|
+
* allow-list because print mode refuses un-allowed MCP tools rather than
|
|
446
|
+
* prompting.
|
|
447
|
+
*/
|
|
448
|
+
export function headlessCheckIn(agent, serverName) {
|
|
449
|
+
const prompt = `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
450
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
451
|
+
if (agent.id === "claude_code") {
|
|
452
|
+
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
453
|
+
}
|
|
454
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
460
|
+
*
|
|
461
|
+
* The config on disk is already correct at this point; all that is missing is
|
|
462
|
+
* one call from the agent, and telling someone to make it from another terminal
|
|
463
|
+
* is a dead end in the one terminal they are sitting in. So run the agent
|
|
464
|
+
* headlessly and poll for the check-in it produces.
|
|
465
|
+
*
|
|
466
|
+
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
467
|
+
* the caller falls back to the printed instructions.
|
|
468
|
+
*/
|
|
469
|
+
export async function autoCheckIn(
|
|
470
|
+
args,
|
|
471
|
+
agent,
|
|
472
|
+
apiKey,
|
|
473
|
+
{
|
|
474
|
+
// Never outlast the connect's own wait budget: this phase is part of it, not
|
|
475
|
+
// an extra one bolted on the front.
|
|
476
|
+
timeoutMs = Math.min(
|
|
477
|
+
Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
|
|
478
|
+
Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
|
|
479
|
+
),
|
|
480
|
+
serverName = "preman",
|
|
481
|
+
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
482
|
+
} = {}
|
|
483
|
+
) {
|
|
484
|
+
const spec = headlessCheckIn(agent, serverName);
|
|
485
|
+
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
486
|
+
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
487
|
+
|
|
488
|
+
let child;
|
|
489
|
+
try {
|
|
490
|
+
child = spawn(spec.bin, spec.args, { stdio: "ignore" });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return { ran: false, connected: false, reason: error.message };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let spawnError = null;
|
|
496
|
+
let exitedAt = 0;
|
|
497
|
+
child.on("error", (error) => {
|
|
498
|
+
spawnError = error;
|
|
499
|
+
exitedAt = exitedAt || Date.now();
|
|
500
|
+
});
|
|
501
|
+
child.on("exit", () => {
|
|
502
|
+
exitedAt = exitedAt || Date.now();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// The check-in can land moments after the agent's own process ends, so keep
|
|
506
|
+
// polling briefly past its exit rather than declaring failure at the edge.
|
|
507
|
+
const grace = intervalMs * 2;
|
|
508
|
+
try {
|
|
509
|
+
const connected = await waitForConnection(args, apiKey, {
|
|
510
|
+
intervalMs,
|
|
511
|
+
timeoutMs,
|
|
512
|
+
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
513
|
+
});
|
|
514
|
+
if (spawnError && !connected) {
|
|
515
|
+
return { ran: false, connected: false, reason: spawnError.message };
|
|
516
|
+
}
|
|
517
|
+
return { ran: true, connected, command: spec.bin };
|
|
518
|
+
} finally {
|
|
519
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
407
523
|
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
408
524
|
|
|
409
|
-
async function captureDispatchCredential(args, agent, apiKey) {
|
|
525
|
+
async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
|
|
410
526
|
if (!agent.dispatch) return;
|
|
411
527
|
if (args.has("--skip-dispatch-credential")) return;
|
|
412
528
|
|
|
@@ -414,19 +530,20 @@ async function captureDispatchCredential(args, agent, apiKey) {
|
|
|
414
530
|
let routineId = args.value("--routine-id", "");
|
|
415
531
|
|
|
416
532
|
if (!secret) {
|
|
417
|
-
if (!process.stdin.isTTY) return;
|
|
533
|
+
if (!prompt || !process.stdin.isTTY) return;
|
|
418
534
|
// Framed as the expected step rather than an optional aside. Without it
|
|
419
535
|
// PreMan can only suggest fixes; with it, it can run them. Presenting it as
|
|
420
536
|
// "optional, press Enter to skip" meant almost everyone skipped the thing
|
|
421
537
|
// that makes the product act rather than advise.
|
|
422
538
|
process.stdout.write(
|
|
423
|
-
`\
|
|
424
|
-
`run checks on a schedule instead of only telling you what to do.\n`
|
|
539
|
+
`\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
|
|
540
|
+
`fixes and run checks on a schedule instead of only telling you what to do.\n` +
|
|
541
|
+
`Get the token from ${agent.dispatch.source}.\n`
|
|
425
542
|
);
|
|
426
|
-
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to
|
|
543
|
+
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
|
|
427
544
|
if (!secret) {
|
|
428
545
|
process.stdout.write(
|
|
429
|
-
`Skipped. Run '${cliInvocation()}
|
|
546
|
+
`Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
|
|
430
547
|
);
|
|
431
548
|
return;
|
|
432
549
|
}
|
|
@@ -458,6 +575,64 @@ async function captureDispatchCredential(args, agent, apiKey) {
|
|
|
458
575
|
}
|
|
459
576
|
}
|
|
460
577
|
|
|
578
|
+
export const DISPATCH_HELP = `
|
|
579
|
+
Dispatch options:
|
|
580
|
+
--agent <name> cursor | claude-code (defaults to the connected agent)
|
|
581
|
+
--dispatch-credential <t> Cloud-dispatch token (non-interactive)
|
|
582
|
+
--routine-id <id> Claude Code routine id, with --dispatch-credential
|
|
583
|
+
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
584
|
+
--backend <url> PreMan backend URL
|
|
585
|
+
`;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* `preman dispatch` — store the cloud-dispatch credential on its own.
|
|
589
|
+
*
|
|
590
|
+
* Exists so connect never has to ask for a token mid-onboarding. Someone who
|
|
591
|
+
* skipped it (or did not have it yet) comes back here instead of re-running the
|
|
592
|
+
* whole connect.
|
|
593
|
+
*/
|
|
594
|
+
export async function dispatchCommand(commandArgs) {
|
|
595
|
+
const args = makeArgs(commandArgs);
|
|
596
|
+
const apiKey = resolveApiKey(args);
|
|
597
|
+
if (!apiKey) {
|
|
598
|
+
throw new ConnectError(
|
|
599
|
+
`No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
|
|
600
|
+
EXIT_USAGE
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let agent = findAgent(args.value("--agent", ""));
|
|
605
|
+
if (!agent && args.value("--agent", "")) {
|
|
606
|
+
throw new ConnectError(
|
|
607
|
+
`Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
|
|
608
|
+
EXIT_USAGE
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!agent) {
|
|
613
|
+
const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
|
|
614
|
+
agent = findAgent(current.ok ? current.agent : "");
|
|
615
|
+
if (!agent) {
|
|
616
|
+
throw new ConnectError(
|
|
617
|
+
"Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
|
|
618
|
+
EXIT_USAGE
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!agent.dispatch) {
|
|
624
|
+
throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
|
|
625
|
+
}
|
|
626
|
+
if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
|
|
627
|
+
throw new ConnectError(
|
|
628
|
+
"preman dispatch needs a terminal, or --dispatch-credential <token>.",
|
|
629
|
+
EXIT_USAGE
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
634
|
+
}
|
|
635
|
+
|
|
461
636
|
/** Accept either a bare trig_… id or the routine URL it appears in. */
|
|
462
637
|
export function extractRoutineId(value) {
|
|
463
638
|
const raw = String(value || "").trim();
|
|
@@ -602,6 +777,7 @@ Connect options:
|
|
|
602
777
|
--project Write project-local config instead of the user config
|
|
603
778
|
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
604
779
|
--email <email> Pre-fill the email prompt when logging in
|
|
780
|
+
--password [value] Also set a dashboard password (signup asks for none)
|
|
605
781
|
--backend <url> PreMan backend URL
|
|
606
782
|
--frontend <url> PreMan frontend URL
|
|
607
783
|
--name <name> MCP server name. Defaults to preman
|
|
@@ -610,6 +786,7 @@ Connect options:
|
|
|
610
786
|
--skip-dispatch-credential Do not ask for a cloud-dispatch credential
|
|
611
787
|
--skip-login Write config without interactive terminal auth
|
|
612
788
|
--no-pair Do not mint a pair code
|
|
789
|
+
--no-auto-checkin Do not run the agent to finish the link
|
|
613
790
|
--no-wait Do not wait for the agent to check in
|
|
614
791
|
--no-guide Skip the guided first run after connecting
|
|
615
792
|
--print Print the config instead of writing it
|
|
@@ -630,31 +807,23 @@ export async function connectCommand(commandArgs) {
|
|
|
630
807
|
);
|
|
631
808
|
}
|
|
632
809
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
"
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
agent = await promptAgentChoice(detectAgents());
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (!printOnly) {
|
|
652
|
-
// Verify the machine can run what we are about to write — before any
|
|
653
|
-
// config edits or logins, so failures leave nothing half-done.
|
|
654
|
-
await preflight(args);
|
|
810
|
+
// Nothing to prompt on, so leave behind everything a CI log needs to finish
|
|
811
|
+
// the setup by hand rather than just the reason it stopped.
|
|
812
|
+
if (!agent && !interactive) {
|
|
813
|
+
process.stdout.write(
|
|
814
|
+
`preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
|
|
815
|
+
'Then restart your agent and ask it: "run preman_status".\n' +
|
|
816
|
+
"Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
|
|
817
|
+
);
|
|
818
|
+
throw new ConnectError(
|
|
819
|
+
"preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
|
|
820
|
+
"and --api-key pm_live_… (or --print), or use one of the snippets above.",
|
|
821
|
+
EXIT_USAGE
|
|
822
|
+
);
|
|
655
823
|
}
|
|
656
824
|
|
|
657
825
|
if (printOnly) {
|
|
826
|
+
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
658
827
|
const serverConfig = buildServerConfig(args);
|
|
659
828
|
if (agent.id === "codex") {
|
|
660
829
|
process.stdout.write(renderCodexToml(serverName, serverConfig));
|
|
@@ -664,6 +833,12 @@ export async function connectCommand(commandArgs) {
|
|
|
664
833
|
return;
|
|
665
834
|
}
|
|
666
835
|
|
|
836
|
+
// Verify the machine can run what we are about to write — before any config
|
|
837
|
+
// edits or logins, so failures leave nothing half-done.
|
|
838
|
+
await preflight(args);
|
|
839
|
+
|
|
840
|
+
// Account first, so "First, let's connect your PreMan account" is not the
|
|
841
|
+
// second thing that happens.
|
|
667
842
|
if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
|
|
668
843
|
if (!interactive) {
|
|
669
844
|
throw new ConnectError(
|
|
@@ -676,6 +851,8 @@ export async function connectCommand(commandArgs) {
|
|
|
676
851
|
process.stdout.write("\n");
|
|
677
852
|
}
|
|
678
853
|
|
|
854
|
+
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
855
|
+
|
|
679
856
|
const apiKey = resolveApiKey(args);
|
|
680
857
|
|
|
681
858
|
let pairCode = "";
|
|
@@ -708,27 +885,19 @@ export async function connectCommand(commandArgs) {
|
|
|
708
885
|
);
|
|
709
886
|
}
|
|
710
887
|
|
|
711
|
-
//
|
|
712
|
-
// prompt inside only
|
|
713
|
-
|
|
714
|
-
|
|
888
|
+
// A non-interactive run can still be handed the credential up front, so this
|
|
889
|
+
// stays reachable; the prompt inside only fires when there is a TTY, and by
|
|
890
|
+
// then onboarding is done.
|
|
715
891
|
if (!pairCode || args.has("--no-wait") || !interactive) {
|
|
892
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
716
893
|
process.stdout.write(nextStepsBlock(agent));
|
|
717
894
|
return;
|
|
718
895
|
}
|
|
719
896
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (!(await waitForConnection(args, apiKey))) {
|
|
726
|
-
process.stdout.write(
|
|
727
|
-
"No check-in yet. Troubleshooting:\n" +
|
|
728
|
-
` - ${agent.restartHint}\n` +
|
|
729
|
-
` - Config written to: ${written.path}\n` +
|
|
730
|
-
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n`
|
|
731
|
-
);
|
|
897
|
+
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written }))) {
|
|
898
|
+
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
899
|
+
// on top of a connect that just told the user something went wrong.
|
|
900
|
+
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
732
901
|
return;
|
|
733
902
|
}
|
|
734
903
|
|
|
@@ -736,4 +905,42 @@ export async function connectCommand(commandArgs) {
|
|
|
736
905
|
if (!args.has("--no-guide")) {
|
|
737
906
|
await guidedFirstRun(args, agent);
|
|
738
907
|
}
|
|
908
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Get the agent to make its first PreMan call, by whatever means work here.
|
|
913
|
+
*
|
|
914
|
+
* Prefers running it for the user; falls back to asking them to restart it and
|
|
915
|
+
* waiting, which is all this ever did. Returns whether the check-in landed, and
|
|
916
|
+
* prints the troubleshooting block itself when it did not.
|
|
917
|
+
*/
|
|
918
|
+
async function establishCheckIn(args, agent, apiKey, { serverName, written }) {
|
|
919
|
+
if (!args.has("--no-auto-checkin")) {
|
|
920
|
+
const spec = headlessCheckIn(agent, serverName);
|
|
921
|
+
if (spec && onPath(spec.bin)) {
|
|
922
|
+
process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
|
|
923
|
+
}
|
|
924
|
+
const auto = await autoCheckIn(args, agent, apiKey, { serverName });
|
|
925
|
+
if (auto.connected) return true;
|
|
926
|
+
if (auto.ran) {
|
|
927
|
+
process.stdout.write(`${agent.label} ran but did not check in.\n`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
process.stdout.write(
|
|
932
|
+
`\nRestart ${agent.label} and ask it: "run preman_status"\n` +
|
|
933
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
if (await waitForConnection(args, apiKey)) return true;
|
|
937
|
+
|
|
938
|
+
process.stdout.write(
|
|
939
|
+
"No check-in yet. Troubleshooting:\n" +
|
|
940
|
+
` - ${agent.restartHint}\n` +
|
|
941
|
+
` - Config written to: ${written.path}\n` +
|
|
942
|
+
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|
|
943
|
+
` - Then: ${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}\n`
|
|
944
|
+
);
|
|
945
|
+
return false;
|
|
739
946
|
}
|
package/bin/integrations.js
CHANGED
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
* hand, and no third-party credential ever touches the terminal.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
|
|
19
17
|
import {
|
|
20
18
|
assertOk,
|
|
21
19
|
callBackendJson,
|
|
22
20
|
frontendUrl,
|
|
21
|
+
openUrl,
|
|
23
22
|
promptText,
|
|
24
23
|
resolveApiKey,
|
|
25
24
|
} from "./shared.js";
|
|
@@ -42,9 +41,9 @@ function paint(code, text) {
|
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
export const MARK = {
|
|
45
|
-
ok: () => paint("32", "
|
|
46
|
-
skip: () => paint("90", "
|
|
47
|
-
fail: () => paint("31", "
|
|
44
|
+
ok: () => paint("32", "?"),
|
|
45
|
+
skip: () => paint("90", "?"),
|
|
46
|
+
fail: () => paint("31", "?"),
|
|
48
47
|
};
|
|
49
48
|
|
|
50
49
|
/** A finished step, marked so it can be found at a glance. */
|
|
@@ -52,25 +51,6 @@ export function connected(message) {
|
|
|
52
51
|
process.stdout.write(`${MARK.ok()} ${message}\n`);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Open a URL in the customer's default browser, best effort. */
|
|
56
|
-
export function openUrl(url) {
|
|
57
|
-
const command =
|
|
58
|
-
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
59
|
-
try {
|
|
60
|
-
const child = spawn(command, [url], {
|
|
61
|
-
stdio: "ignore",
|
|
62
|
-
detached: true,
|
|
63
|
-
shell: process.platform === "win32",
|
|
64
|
-
});
|
|
65
|
-
child.unref();
|
|
66
|
-
return true;
|
|
67
|
-
} catch {
|
|
68
|
-
// Headless boxes and locked-down shells are normal. The URL is printed
|
|
69
|
-
// either way, so this is a convenience, never the only path.
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
54
|
function present(url, what) {
|
|
75
55
|
process.stdout.write(`\n ${url}\n\n`);
|
|
76
56
|
if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
|
|
@@ -349,7 +329,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
349
329
|
let i = 0;
|
|
350
330
|
while (i < steps.length) {
|
|
351
331
|
const step = steps[i];
|
|
352
|
-
process.stdout.write(`\n
|
|
332
|
+
process.stdout.write(`\n?? ${step.name} ??\n`);
|
|
353
333
|
|
|
354
334
|
const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
|
|
355
335
|
if (choice === "back") {
|
|
@@ -377,7 +357,7 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
377
357
|
i += 1;
|
|
378
358
|
}
|
|
379
359
|
|
|
380
|
-
process.stdout.write("\n
|
|
360
|
+
process.stdout.write("\n?? done ??\n");
|
|
381
361
|
// One line per step, marked, so the outcome is scannable rather than prose.
|
|
382
362
|
for (const step of steps) {
|
|
383
363
|
const result = outcome.get(step.name);
|
package/bin/shared.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* over a module-level argv, so a command can pass its own slice.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
@@ -101,6 +101,36 @@ export function frontendUrl(args) {
|
|
|
101
101
|
return args.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Open a URL in the user's default browser, best effort.
|
|
106
|
+
*
|
|
107
|
+
* Returns false without spawning anything when nobody is there to look at it:
|
|
108
|
+
* `PREMAN_NO_BROWSER` for an explicit no, and a non-TTY stdout for CI, piped
|
|
109
|
+
* output, and test runs. Every caller prints the URL as well, so this is only
|
|
110
|
+
* ever a convenience — a suite that exercises these flows should not be able to
|
|
111
|
+
* throw tabs at whoever is running it.
|
|
112
|
+
*/
|
|
113
|
+
export function openUrl(url) {
|
|
114
|
+
const optOut = (process.env.PREMAN_NO_BROWSER || "").trim().toLowerCase();
|
|
115
|
+
if (optOut && !["0", "false", "no"].includes(optOut)) return false;
|
|
116
|
+
if (!process.stdout.isTTY) return false;
|
|
117
|
+
|
|
118
|
+
const opener =
|
|
119
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
120
|
+
try {
|
|
121
|
+
const child = spawn(opener, [url], {
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
detached: true,
|
|
124
|
+
shell: process.platform === "win32",
|
|
125
|
+
});
|
|
126
|
+
child.unref();
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
// Headless boxes and locked-down shells are normal.
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
export async function promptText(question) {
|
|
105
135
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
106
136
|
try {
|
|
@@ -235,7 +265,50 @@ async function verifyUnconfirmedAccount(args, email) {
|
|
|
235
265
|
return verified.access_token;
|
|
236
266
|
}
|
|
237
267
|
|
|
268
|
+
/**
|
|
269
|
+
* A password only if the caller asked for one.
|
|
270
|
+
*
|
|
271
|
+
* The terminal's durable credential is the pm_live_ key, so signup does not
|
|
272
|
+
* need a password and no longer asks for one. `--password` is for anyone who
|
|
273
|
+
* wants to be able to sign in to the dashboard immediately; `--password <value>`
|
|
274
|
+
* skips the prompt for scripted runs.
|
|
275
|
+
*/
|
|
276
|
+
async function optionalPassword(args) {
|
|
277
|
+
if (!args.has("--password")) return "";
|
|
278
|
+
const supplied = args.value("--password", "");
|
|
279
|
+
// Bare `--password` followed by another flag means "prompt me", not "my
|
|
280
|
+
// password is --backend".
|
|
281
|
+
if (supplied && !supplied.startsWith("--")) {
|
|
282
|
+
if (supplied.length < 6) throw new Error("Password must be at least 6 characters.");
|
|
283
|
+
return supplied;
|
|
284
|
+
}
|
|
285
|
+
return promptPasswordTwice();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Turn a verification code into a session token.
|
|
290
|
+
*
|
|
291
|
+
* Without a password this is one call: verify-otp signs the user in and the
|
|
292
|
+
* backend keeps the account passwordless, so the dashboard knows to offer a
|
|
293
|
+
* code rather than a password field.
|
|
294
|
+
*/
|
|
295
|
+
async function completeWithCode(args, email, otp, password) {
|
|
296
|
+
if (password) {
|
|
297
|
+
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
298
|
+
json: { email, otp, new_password: password },
|
|
299
|
+
});
|
|
300
|
+
assertOk(setPassword, "set password");
|
|
301
|
+
return String(setPassword.access_token || "");
|
|
302
|
+
}
|
|
303
|
+
const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
|
|
304
|
+
json: { email, otp },
|
|
305
|
+
});
|
|
306
|
+
assertOk(verified, "verify OTP");
|
|
307
|
+
return String(verified.access_token || "");
|
|
308
|
+
}
|
|
309
|
+
|
|
238
310
|
async function createAccountFromTerminal(args, email) {
|
|
311
|
+
const password = await optionalPassword(args);
|
|
239
312
|
const signup = await callBackendJson(args, "POST", "/auth/start-signup", {
|
|
240
313
|
json: { email },
|
|
241
314
|
});
|
|
@@ -243,24 +316,21 @@ async function createAccountFromTerminal(args, email) {
|
|
|
243
316
|
if (signup.ok) {
|
|
244
317
|
process.stdout.write("Verification code sent. Check your email.\n");
|
|
245
318
|
const otp = await promptText("Verification code: ");
|
|
246
|
-
|
|
247
|
-
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
248
|
-
json: { email, otp, new_password: password },
|
|
249
|
-
});
|
|
250
|
-
assertOk(setPassword, "set password");
|
|
251
|
-
return String(setPassword.access_token || "");
|
|
319
|
+
return completeWithCode(args, email, otp, password);
|
|
252
320
|
}
|
|
253
321
|
|
|
254
322
|
if (signup.status_code !== 404) {
|
|
255
323
|
assertOk(signup, "start signup");
|
|
256
324
|
}
|
|
257
325
|
|
|
326
|
+
// Backends predating start-signup have no passwordless route in, so this one
|
|
327
|
+
// path still has to ask.
|
|
258
328
|
process.stdout.write(
|
|
259
329
|
"This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
|
|
260
330
|
);
|
|
261
|
-
const
|
|
331
|
+
const legacyPassword = password || (await promptPasswordTwice());
|
|
262
332
|
const legacySignup = await callBackendJson(args, "POST", "/auth/signup", {
|
|
263
|
-
json: { email, password },
|
|
333
|
+
json: { email, password: legacyPassword },
|
|
264
334
|
});
|
|
265
335
|
assertOk(legacySignup, "signup");
|
|
266
336
|
process.stdout.write("Verification code sent. Check your email.\n");
|
|
@@ -303,26 +373,31 @@ export async function authenticateTerminal(args) {
|
|
|
303
373
|
process.stdout.write("No PreMan account found. Creating one now.\n");
|
|
304
374
|
accessToken = await createAccountFromTerminal(args, email);
|
|
305
375
|
} else if (account.needs_password) {
|
|
306
|
-
|
|
376
|
+
// No password on this account, so a code is the only way in.
|
|
377
|
+
const password = await optionalPassword(args);
|
|
378
|
+
process.stdout.write("Sending a sign-in code to your email.\n");
|
|
307
379
|
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
308
380
|
assertOk(resend, "resend OTP");
|
|
309
381
|
const otp = await promptText("Verification code: ");
|
|
310
|
-
|
|
311
|
-
const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
|
|
312
|
-
json: { email, otp, new_password: password },
|
|
313
|
-
});
|
|
314
|
-
assertOk(setPassword, "set password");
|
|
315
|
-
accessToken = String(setPassword.access_token || "");
|
|
382
|
+
accessToken = await completeWithCode(args, email, otp, password);
|
|
316
383
|
} else {
|
|
317
|
-
const password = await promptSecret("Password: ");
|
|
318
|
-
|
|
319
|
-
json: { email
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
384
|
+
const password = await promptSecret("Password (Enter to get a code by email): ");
|
|
385
|
+
if (!password) {
|
|
386
|
+
const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
|
|
387
|
+
assertOk(resend, "resend OTP");
|
|
388
|
+
process.stdout.write("Sign-in code sent. Check your email.\n");
|
|
389
|
+
const otp = await promptText("Verification code: ");
|
|
390
|
+
accessToken = await completeWithCode(args, email, otp, "");
|
|
323
391
|
} else {
|
|
324
|
-
|
|
325
|
-
|
|
392
|
+
const login = await callBackendJson(args, "POST", "/auth/login", {
|
|
393
|
+
json: { email, password },
|
|
394
|
+
});
|
|
395
|
+
if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
|
|
396
|
+
accessToken = await verifyUnconfirmedAccount(args, email);
|
|
397
|
+
} else {
|
|
398
|
+
assertOk(login, "login");
|
|
399
|
+
accessToken = String(login.access_token || "");
|
|
400
|
+
}
|
|
326
401
|
}
|
|
327
402
|
}
|
|
328
403
|
|