premanmcp 0.8.0 → 0.10.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 +63 -19
- package/bin/account.js +2 -13
- package/bin/cli.js +15 -2
- package/bin/connect.js +891 -106
- package/bin/integrations.js +2 -22
- package/bin/runner.js +880 -0
- package/bin/shared.js +112 -25
- package/dist/server.js +29 -9
- package/package.json +1 -1
package/bin/connect.js
CHANGED
|
@@ -10,18 +10,24 @@
|
|
|
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";
|
|
17
17
|
|
|
18
18
|
import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
|
|
19
|
+
import { installDesktopCommand } from "./desktop.js";
|
|
20
|
+
import { installHook, hookStatus } from "./hook.js";
|
|
21
|
+
import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
|
|
22
|
+
import { readRunnerState, registerRunner, runnerIsAlive, startBackground } from "./runner.js";
|
|
19
23
|
import {
|
|
24
|
+
apiKeyIsExplicit,
|
|
20
25
|
assertOk,
|
|
21
26
|
authenticateTerminal,
|
|
22
27
|
backendUrl,
|
|
23
28
|
buildServerConfig,
|
|
24
29
|
callBackendJson,
|
|
30
|
+
clearStoredCredentials,
|
|
25
31
|
cliInvocation,
|
|
26
32
|
frontendUrl,
|
|
27
33
|
hasKeyAvailable,
|
|
@@ -53,7 +59,11 @@ const AGENTS = [
|
|
|
53
59
|
id: "cursor",
|
|
54
60
|
label: "Cursor",
|
|
55
61
|
aliases: ["cursor"],
|
|
56
|
-
dispatch: {
|
|
62
|
+
dispatch: {
|
|
63
|
+
credential: "Cursor API key",
|
|
64
|
+
needsRoutine: false,
|
|
65
|
+
source: "cursor.com/dashboard → Integrations → API Keys",
|
|
66
|
+
},
|
|
57
67
|
snippetHint: "merge into ~/.cursor/mcp.json",
|
|
58
68
|
restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
|
|
59
69
|
},
|
|
@@ -61,7 +71,13 @@ const AGENTS = [
|
|
|
61
71
|
id: "claude_code",
|
|
62
72
|
label: "Claude Code",
|
|
63
73
|
aliases: ["claude", "claude-code", "claude_code", "claudecode"],
|
|
64
|
-
dispatch: {
|
|
74
|
+
dispatch: {
|
|
75
|
+
credential: "Claude Code routine token",
|
|
76
|
+
needsRoutine: true,
|
|
77
|
+
// Named explicitly because "Claude Code routine token" reads like an
|
|
78
|
+
// Anthropic API key, which is a different credential from a different page.
|
|
79
|
+
source: "claude.ai/code/routines → your routine → Add API trigger",
|
|
80
|
+
},
|
|
65
81
|
snippetHint: "run:",
|
|
66
82
|
restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
|
|
67
83
|
},
|
|
@@ -97,17 +113,36 @@ function detectAgents() {
|
|
|
97
113
|
};
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The agent whose session this command is running inside, if any.
|
|
118
|
+
*
|
|
119
|
+
* A better default than "first one installed": someone who types this into an
|
|
120
|
+
* agent's terminal almost always means that agent, and on a machine with all
|
|
121
|
+
* three installed the detected-order default is usually wrong.
|
|
122
|
+
*/
|
|
123
|
+
export function runningInside(env = process.env) {
|
|
124
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
|
|
125
|
+
if (env.CURSOR_TRACE_ID) return "cursor";
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function promptAgentChoice(detected, insideId = runningInside()) {
|
|
101
130
|
process.stdout.write("Which coding agent?\n");
|
|
102
131
|
AGENTS.forEach((agent, index) => {
|
|
103
|
-
|
|
132
|
+
let mark = "";
|
|
133
|
+
if (agent.id === insideId) mark = " (this session)";
|
|
134
|
+
else if (detected[agent.id]) mark = " (detected)";
|
|
104
135
|
process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
|
|
105
136
|
});
|
|
106
137
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
138
|
+
const inside = AGENTS.findIndex((a) => a.id === insideId);
|
|
139
|
+
const defaultIndex =
|
|
140
|
+
inside >= 0
|
|
141
|
+
? inside
|
|
142
|
+
: Math.max(
|
|
143
|
+
0,
|
|
144
|
+
AGENTS.findIndex((a) => detected[a.id])
|
|
145
|
+
);
|
|
111
146
|
const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
|
|
112
147
|
if (!answer) return AGENTS[defaultIndex];
|
|
113
148
|
|
|
@@ -360,19 +395,81 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
|
360
395
|
|
|
361
396
|
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
362
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Mint the pair code, distinguishing "could not reach it" from "this key is dead".
|
|
400
|
+
*
|
|
401
|
+
* 401 is the backend saying the key is invalid or revoked, which is the one
|
|
402
|
+
* failure the caller must not shrug off: every other outcome still leaves a
|
|
403
|
+
* usable config, but writing a revoked key into an agent's config produces a
|
|
404
|
+
* setup that looks connected and fails on every call.
|
|
405
|
+
*/
|
|
363
406
|
async function startPairing(args, agent, apiKey) {
|
|
364
407
|
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
|
|
365
408
|
token: apiKey,
|
|
366
409
|
json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
|
|
367
410
|
});
|
|
411
|
+
if (result.status_code === 401) return { pairCode: "", stale: true };
|
|
368
412
|
if (!result.ok) {
|
|
369
413
|
process.stdout.write(
|
|
370
414
|
`Note: could not start pairing (${result.status_code}); the config still works, ` +
|
|
371
415
|
"your agent will link on its first PreMan call.\n"
|
|
372
416
|
);
|
|
373
|
-
return "";
|
|
417
|
+
return { pairCode: "", stale: false };
|
|
374
418
|
}
|
|
375
|
-
return String(result.pair_code || "");
|
|
419
|
+
return { pairCode: String(result.pair_code || ""), stale: false };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The --no-pair stand-in for the auth check pairing performs implicitly.
|
|
424
|
+
*
|
|
425
|
+
* Declining a pair code should not also decline noticing the key is dead,
|
|
426
|
+
* otherwise this flag writes exactly the broken config pairing would catch.
|
|
427
|
+
*/
|
|
428
|
+
async function verifyKey(args, apiKey) {
|
|
429
|
+
const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
|
|
430
|
+
return { pairCode: "", stale: result.status_code === 401 };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Whichever of the two ways to learn the backend still accepts this key. */
|
|
434
|
+
async function checkKeyAndPair(args, agent, apiKey) {
|
|
435
|
+
return args.has("--no-pair")
|
|
436
|
+
? verifyKey(args, apiKey)
|
|
437
|
+
: startPairing(args, agent, apiKey);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Replace credentials the backend has rejected, and return the new key.
|
|
442
|
+
*
|
|
443
|
+
* Only stored credentials can be replaced here: an explicit --api-key or
|
|
444
|
+
* PREMAN_API_KEY would just be re-read, so those say so and stop rather than
|
|
445
|
+
* looping. Refusing to continue is deliberate — the alternative is writing a
|
|
446
|
+
* key we know is dead into the agent's config.
|
|
447
|
+
*/
|
|
448
|
+
async function refreshStaleCredentials(args) {
|
|
449
|
+
if (apiKeyIsExplicit(args)) {
|
|
450
|
+
const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
|
|
451
|
+
throw new ConnectError(
|
|
452
|
+
`The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
|
|
453
|
+
`dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
|
|
454
|
+
EXIT_USAGE
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (args.has("--skip-login") || !process.stdin.isTTY) {
|
|
458
|
+
throw new ConnectError(
|
|
459
|
+
"Saved PreMan credentials are invalid or revoked. Run " +
|
|
460
|
+
`'${cliInvocation()} logout', then connect again to sign in.`,
|
|
461
|
+
EXIT_USAGE
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
process.stdout.write(
|
|
466
|
+
"\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
|
|
467
|
+
"account behind it was removed. Signing you in again.\n"
|
|
468
|
+
);
|
|
469
|
+
clearStoredCredentials();
|
|
470
|
+
await authenticateTerminal(args);
|
|
471
|
+
process.stdout.write("\n");
|
|
472
|
+
return resolveApiKey(args);
|
|
376
473
|
}
|
|
377
474
|
|
|
378
475
|
export async function waitForConnection(
|
|
@@ -381,6 +478,7 @@ export async function waitForConnection(
|
|
|
381
478
|
{
|
|
382
479
|
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
383
480
|
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
481
|
+
stopWhen = null,
|
|
384
482
|
} = {}
|
|
385
483
|
) {
|
|
386
484
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -396,6 +494,7 @@ export async function waitForConnection(
|
|
|
396
494
|
token: apiKey,
|
|
397
495
|
});
|
|
398
496
|
if (status.ok && status.connected) return true;
|
|
497
|
+
if (stopWhen && stopWhen()) return false;
|
|
399
498
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
400
499
|
}
|
|
401
500
|
} finally {
|
|
@@ -404,9 +503,247 @@ export async function waitForConnection(
|
|
|
404
503
|
return false;
|
|
405
504
|
}
|
|
406
505
|
|
|
506
|
+
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* How to ask each agent to make one PreMan call without opening its UI.
|
|
510
|
+
*
|
|
511
|
+
* The link is established by the agent's first call, so anything that reaches
|
|
512
|
+
* `preman_status` finishes the connect. Claude Code needs the server on its
|
|
513
|
+
* allow-list because print mode refuses un-allowed MCP tools rather than
|
|
514
|
+
* prompting.
|
|
515
|
+
*/
|
|
516
|
+
export function headlessCheckIn(agent, serverName) {
|
|
517
|
+
const prompt = `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
518
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
519
|
+
if (agent.id === "claude_code") {
|
|
520
|
+
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
521
|
+
}
|
|
522
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
528
|
+
*
|
|
529
|
+
* The config on disk is already correct at this point; all that is missing is
|
|
530
|
+
* one call from the agent, and telling someone to make it from another terminal
|
|
531
|
+
* is a dead end in the one terminal they are sitting in. So run the agent
|
|
532
|
+
* headlessly and poll for the check-in it produces.
|
|
533
|
+
*
|
|
534
|
+
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
535
|
+
* the caller falls back to the printed instructions.
|
|
536
|
+
*/
|
|
537
|
+
export async function autoCheckIn(
|
|
538
|
+
args,
|
|
539
|
+
agent,
|
|
540
|
+
apiKey,
|
|
541
|
+
{
|
|
542
|
+
// Never outlast the connect's own wait budget: this phase is part of it, not
|
|
543
|
+
// an extra one bolted on the front.
|
|
544
|
+
timeoutMs = Math.min(
|
|
545
|
+
Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
|
|
546
|
+
Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
|
|
547
|
+
),
|
|
548
|
+
serverName = "preman",
|
|
549
|
+
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
550
|
+
} = {}
|
|
551
|
+
) {
|
|
552
|
+
const spec = headlessCheckIn(agent, serverName);
|
|
553
|
+
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
554
|
+
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
555
|
+
|
|
556
|
+
let child;
|
|
557
|
+
try {
|
|
558
|
+
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
559
|
+
// report exactly that and nothing else, which is the least useful sentence
|
|
560
|
+
// available. Its own last words usually name the cause.
|
|
561
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
562
|
+
} catch (error) {
|
|
563
|
+
return { ran: false, connected: false, reason: error.message };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let spawnError = null;
|
|
567
|
+
let exitedAt = 0;
|
|
568
|
+
let output = "";
|
|
569
|
+
const absorb = (chunk) => {
|
|
570
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
571
|
+
};
|
|
572
|
+
child.stdout?.setEncoding("utf8");
|
|
573
|
+
child.stderr?.setEncoding("utf8");
|
|
574
|
+
child.stdout?.on("data", absorb);
|
|
575
|
+
child.stderr?.on("data", absorb);
|
|
576
|
+
child.on("error", (error) => {
|
|
577
|
+
spawnError = error;
|
|
578
|
+
exitedAt = exitedAt || Date.now();
|
|
579
|
+
});
|
|
580
|
+
child.on("exit", () => {
|
|
581
|
+
exitedAt = exitedAt || Date.now();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// The check-in can land moments after the agent's own process ends, so keep
|
|
585
|
+
// polling briefly past its exit rather than declaring failure at the edge.
|
|
586
|
+
const grace = intervalMs * 2;
|
|
587
|
+
try {
|
|
588
|
+
const connected = await waitForConnection(args, apiKey, {
|
|
589
|
+
intervalMs,
|
|
590
|
+
timeoutMs,
|
|
591
|
+
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
592
|
+
});
|
|
593
|
+
if (spawnError && !connected) {
|
|
594
|
+
return { ran: false, connected: false, reason: spawnError.message, output };
|
|
595
|
+
}
|
|
596
|
+
return { ran: true, connected, command: spec.bin, output };
|
|
597
|
+
} finally {
|
|
598
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The last non-empty line of an agent's output, for a one-line diagnosis. */
|
|
603
|
+
export function lastLine(output, cap = 200) {
|
|
604
|
+
const lines = String(output || "")
|
|
605
|
+
.split("\n")
|
|
606
|
+
.map((line) => line.trim())
|
|
607
|
+
.filter(Boolean);
|
|
608
|
+
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── MCP self-test ───────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* How long to give the self-test, and whether to run it at all.
|
|
615
|
+
*
|
|
616
|
+
* `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
|
|
617
|
+
* run wants: the launcher is `npm exec premanmcp@latest`, so every case that
|
|
618
|
+
* reaches this would otherwise go to the registry.
|
|
619
|
+
*/
|
|
620
|
+
export function selfTestBudgetMs() {
|
|
621
|
+
const raw = process.env.PREMAN_SELFTEST_MS;
|
|
622
|
+
if (raw === undefined || raw === "") return 60000;
|
|
623
|
+
const parsed = Number(raw);
|
|
624
|
+
return Number.isFinite(parsed) ? parsed : 60000;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Run the config we just wrote, exactly as the agent will, and call one tool.
|
|
629
|
+
*
|
|
630
|
+
* This is both the fastest way to finish the link and the only check that proves
|
|
631
|
+
* the whole chain — launcher, package, key, backend — rather than proving the
|
|
632
|
+
* file is on disk. It matters because the failure it catches is invisible
|
|
633
|
+
* otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
|
|
634
|
+
* redirect the server to a backend the key was never issued for, and every
|
|
635
|
+
* symptom of that points at the key.
|
|
636
|
+
*
|
|
637
|
+
* Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
|
|
638
|
+
*/
|
|
639
|
+
export async function mcpSelfTest(
|
|
640
|
+
serverConfig,
|
|
641
|
+
{ timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
|
|
642
|
+
) {
|
|
643
|
+
if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
|
|
644
|
+
let child;
|
|
645
|
+
try {
|
|
646
|
+
child = spawn(serverConfig.command, serverConfig.args, {
|
|
647
|
+
cwd,
|
|
648
|
+
env: { ...process.env, ...serverConfig.env },
|
|
649
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
650
|
+
});
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return { ok: false, reason: error.message };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
let stdout = "";
|
|
657
|
+
let stderr = "";
|
|
658
|
+
let settled = false;
|
|
659
|
+
|
|
660
|
+
const finish = (result) => {
|
|
661
|
+
if (settled) return;
|
|
662
|
+
settled = true;
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
665
|
+
resolve(result);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const timer = setTimeout(
|
|
669
|
+
() => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
|
|
670
|
+
timeoutMs
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const send = (message) => {
|
|
674
|
+
try {
|
|
675
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
finish({ ok: false, reason: error.message });
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
child.on("error", (error) => finish({ ok: false, reason: error.message }));
|
|
682
|
+
child.on("exit", (code) =>
|
|
683
|
+
finish({
|
|
684
|
+
ok: false,
|
|
685
|
+
reason: `the MCP server exited with code ${code}`,
|
|
686
|
+
stderr: lastLine(stderr),
|
|
687
|
+
})
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
child.stdout.setEncoding("utf8");
|
|
691
|
+
child.stderr.setEncoding("utf8");
|
|
692
|
+
child.stderr.on("data", (chunk) => {
|
|
693
|
+
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
694
|
+
});
|
|
695
|
+
child.stdout.on("data", (chunk) => {
|
|
696
|
+
stdout += chunk;
|
|
697
|
+
let newline = stdout.indexOf("\n");
|
|
698
|
+
while (newline !== -1) {
|
|
699
|
+
const line = stdout.slice(0, newline).trim();
|
|
700
|
+
stdout = stdout.slice(newline + 1);
|
|
701
|
+
newline = stdout.indexOf("\n");
|
|
702
|
+
if (!line.startsWith("{")) continue;
|
|
703
|
+
let message;
|
|
704
|
+
try {
|
|
705
|
+
message = JSON.parse(line);
|
|
706
|
+
} catch {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (message.id === 1) {
|
|
710
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
711
|
+
send({
|
|
712
|
+
jsonrpc: "2.0",
|
|
713
|
+
id: 2,
|
|
714
|
+
method: "tools/call",
|
|
715
|
+
params: { name: "preman_status", arguments: {} },
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (message.id !== 2) continue;
|
|
720
|
+
const text = message.result?.content?.[0]?.text;
|
|
721
|
+
let status = {};
|
|
722
|
+
try {
|
|
723
|
+
status = text ? JSON.parse(text) : {};
|
|
724
|
+
} catch {
|
|
725
|
+
status = { raw: text };
|
|
726
|
+
}
|
|
727
|
+
finish({ ok: !message.error, status, reason: message.error?.message || "" });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
send({
|
|
732
|
+
jsonrpc: "2.0",
|
|
733
|
+
id: 1,
|
|
734
|
+
method: "initialize",
|
|
735
|
+
params: {
|
|
736
|
+
protocolVersion: "2024-11-05",
|
|
737
|
+
capabilities: {},
|
|
738
|
+
clientInfo: { name: "preman-connect-selftest", version: "1" },
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
407
744
|
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
408
745
|
|
|
409
|
-
async function captureDispatchCredential(args, agent, apiKey) {
|
|
746
|
+
async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
|
|
410
747
|
if (!agent.dispatch) return;
|
|
411
748
|
if (args.has("--skip-dispatch-credential")) return;
|
|
412
749
|
|
|
@@ -414,19 +751,20 @@ async function captureDispatchCredential(args, agent, apiKey) {
|
|
|
414
751
|
let routineId = args.value("--routine-id", "");
|
|
415
752
|
|
|
416
753
|
if (!secret) {
|
|
417
|
-
if (!process.stdin.isTTY) return;
|
|
754
|
+
if (!prompt || !process.stdin.isTTY) return;
|
|
418
755
|
// Framed as the expected step rather than an optional aside. Without it
|
|
419
756
|
// PreMan can only suggest fixes; with it, it can run them. Presenting it as
|
|
420
757
|
// "optional, press Enter to skip" meant almost everyone skipped the thing
|
|
421
758
|
// that makes the product act rather than advise.
|
|
422
759
|
process.stdout.write(
|
|
423
|
-
`\
|
|
424
|
-
`run checks on a schedule instead of only telling you what to do.\n`
|
|
760
|
+
`\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
|
|
761
|
+
`fixes and run checks on a schedule instead of only telling you what to do.\n` +
|
|
762
|
+
`Get the token from ${agent.dispatch.source}.\n`
|
|
425
763
|
);
|
|
426
|
-
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to
|
|
764
|
+
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
|
|
427
765
|
if (!secret) {
|
|
428
766
|
process.stdout.write(
|
|
429
|
-
`Skipped. Run '${cliInvocation()}
|
|
767
|
+
`Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
|
|
430
768
|
);
|
|
431
769
|
return;
|
|
432
770
|
}
|
|
@@ -458,6 +796,64 @@ async function captureDispatchCredential(args, agent, apiKey) {
|
|
|
458
796
|
}
|
|
459
797
|
}
|
|
460
798
|
|
|
799
|
+
export const DISPATCH_HELP = `
|
|
800
|
+
Dispatch options:
|
|
801
|
+
--agent <name> cursor | claude-code (defaults to the connected agent)
|
|
802
|
+
--dispatch-credential <t> Cloud-dispatch token (non-interactive)
|
|
803
|
+
--routine-id <id> Claude Code routine id, with --dispatch-credential
|
|
804
|
+
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
805
|
+
--backend <url> PreMan backend URL
|
|
806
|
+
`;
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* `preman dispatch` — store the cloud-dispatch credential on its own.
|
|
810
|
+
*
|
|
811
|
+
* Exists so connect never has to ask for a token mid-onboarding. Someone who
|
|
812
|
+
* skipped it (or did not have it yet) comes back here instead of re-running the
|
|
813
|
+
* whole connect.
|
|
814
|
+
*/
|
|
815
|
+
export async function dispatchCommand(commandArgs) {
|
|
816
|
+
const args = makeArgs(commandArgs);
|
|
817
|
+
const apiKey = resolveApiKey(args);
|
|
818
|
+
if (!apiKey) {
|
|
819
|
+
throw new ConnectError(
|
|
820
|
+
`No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
|
|
821
|
+
EXIT_USAGE
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let agent = findAgent(args.value("--agent", ""));
|
|
826
|
+
if (!agent && args.value("--agent", "")) {
|
|
827
|
+
throw new ConnectError(
|
|
828
|
+
`Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
|
|
829
|
+
EXIT_USAGE
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (!agent) {
|
|
834
|
+
const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
|
|
835
|
+
agent = findAgent(current.ok ? current.agent : "");
|
|
836
|
+
if (!agent) {
|
|
837
|
+
throw new ConnectError(
|
|
838
|
+
"Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
|
|
839
|
+
EXIT_USAGE
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (!agent.dispatch) {
|
|
845
|
+
throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
|
|
846
|
+
}
|
|
847
|
+
if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
|
|
848
|
+
throw new ConnectError(
|
|
849
|
+
"preman dispatch needs a terminal, or --dispatch-credential <token>.",
|
|
850
|
+
EXIT_USAGE
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
855
|
+
}
|
|
856
|
+
|
|
461
857
|
/** Accept either a bare trig_… id or the routine URL it appears in. */
|
|
462
858
|
export function extractRoutineId(value) {
|
|
463
859
|
const raw = String(value || "").trim();
|
|
@@ -476,81 +872,381 @@ function nextStepsBlock(agent) {
|
|
|
476
872
|
return (
|
|
477
873
|
"\nNext steps:\n" +
|
|
478
874
|
` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
875
|
+
` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
|
|
876
|
+
` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
|
|
877
|
+
` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
|
|
482
878
|
);
|
|
483
879
|
}
|
|
484
880
|
|
|
485
|
-
async function confirm(question) {
|
|
486
|
-
|
|
487
|
-
|
|
881
|
+
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
882
|
+
if (assumeYes) return true;
|
|
883
|
+
if (!process.stdin.isTTY) return false;
|
|
884
|
+
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
885
|
+
if (answer === "") return defaultYes;
|
|
886
|
+
return answer.startsWith("y");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function step(title) {
|
|
890
|
+
process.stdout.write(`\n${title}\n`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** What the account can see right now: registry rows, and the runnable subset. */
|
|
894
|
+
async function endpointCounts(args) {
|
|
895
|
+
const inventory = await callPremanTool(args, "get_endpoints", {
|
|
896
|
+
include_workbench: true,
|
|
897
|
+
limit: 200,
|
|
898
|
+
});
|
|
899
|
+
const runnable = inventory.workbench_requests || [];
|
|
900
|
+
return {
|
|
901
|
+
registered: (inventory.endpoints || []).length,
|
|
902
|
+
runnable: runnable.length,
|
|
903
|
+
first: runnable[0] || null,
|
|
904
|
+
};
|
|
488
905
|
}
|
|
489
906
|
|
|
490
907
|
/**
|
|
491
|
-
*
|
|
908
|
+
* The one-shot command that has an agent execute the discovery brief.
|
|
492
909
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
910
|
+
* Same shape as `headlessCheckIn`, with the read tools the walk needs added to
|
|
911
|
+
* Claude Code's allow-list: print mode refuses anything un-allowed instead of
|
|
912
|
+
* asking, so an agent given only the MCP server can call PreMan and read nothing.
|
|
913
|
+
*/
|
|
914
|
+
export function headlessDiscovery(agent, serverName, instructions = []) {
|
|
915
|
+
const prompt = [
|
|
916
|
+
`Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
|
|
917
|
+
"Work autonomously and do not ask for confirmation.",
|
|
918
|
+
"",
|
|
919
|
+
...instructions,
|
|
920
|
+
].join("\n");
|
|
921
|
+
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
922
|
+
if (agent.id === "claude_code") {
|
|
923
|
+
return {
|
|
924
|
+
bin: "claude",
|
|
925
|
+
args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Get this project's endpoints into PreMan without handing anyone homework.
|
|
496
934
|
*
|
|
497
|
-
*
|
|
935
|
+
* Discovery is the agent's job — only it can read the repo — but "here is a brief,
|
|
936
|
+
* go paste it somewhere" is the step everybody dropped out on, and the commands
|
|
937
|
+
* printed after it referenced a file the user never had. So run the agent on the
|
|
938
|
+
* brief and report the two numbers that matter.
|
|
939
|
+
*
|
|
940
|
+
* Never throws: a failed discovery must not fail the connect.
|
|
498
941
|
*/
|
|
499
|
-
async function
|
|
942
|
+
async function discoverEndpoints(args, agent, serverName) {
|
|
500
943
|
try {
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (runnable) {
|
|
509
|
-
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
510
|
-
if (!(await confirm(`\nRun a first test against ${label}?`))) {
|
|
511
|
-
process.stdout.write(`Whenever you are ready: preman test ${runnable.id}\n`);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
process.stdout.write("Generating scenarios…\n");
|
|
515
|
-
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
516
|
-
target: runnable.id,
|
|
517
|
-
run: true,
|
|
518
|
-
allow_writes: false,
|
|
519
|
-
max_cases: 10,
|
|
520
|
-
});
|
|
521
|
-
printTestSummary(result);
|
|
522
|
-
process.stdout.write(`\nAdd your own: preman test ${runnable.id} --scenario "..."\n`);
|
|
523
|
-
return;
|
|
944
|
+
const before = await endpointCounts(args);
|
|
945
|
+
if (before.registered) {
|
|
946
|
+
process.stdout.write(
|
|
947
|
+
`${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
|
|
948
|
+
);
|
|
949
|
+
return before;
|
|
524
950
|
}
|
|
525
951
|
|
|
526
|
-
|
|
952
|
+
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
953
|
+
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
954
|
+
if (!spec || !onPath(spec.bin)) {
|
|
955
|
+
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
956
|
+
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
957
|
+
return before;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
|
|
961
|
+
const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
|
|
962
|
+
const outcome = await runAgentOnce(spec, timeoutMs);
|
|
963
|
+
const after = await endpointCounts(args);
|
|
964
|
+
|
|
965
|
+
if (!after.registered) {
|
|
966
|
+
const why = outcome.reason || lastLine(outcome.output) || "it registered nothing";
|
|
527
967
|
process.stdout.write(
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
" preman test <request-id> # generate + run your first scenarios\n"
|
|
968
|
+
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
969
|
+
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
531
970
|
);
|
|
532
|
-
return;
|
|
971
|
+
return after;
|
|
533
972
|
}
|
|
973
|
+
process.stdout.write(
|
|
974
|
+
`${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
|
|
975
|
+
);
|
|
976
|
+
return after;
|
|
977
|
+
} catch (error) {
|
|
978
|
+
process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
|
|
979
|
+
return { registered: 0, runnable: 0, first: null };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
534
982
|
|
|
535
|
-
|
|
536
|
-
|
|
983
|
+
/** Run one bounded, non-interactive agent invocation and keep its tail. */
|
|
984
|
+
async function runAgentOnce(spec, timeoutMs) {
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
let child;
|
|
987
|
+
try {
|
|
988
|
+
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
989
|
+
} catch (error) {
|
|
990
|
+
resolve({ ok: false, reason: error.message, output: "" });
|
|
537
991
|
return;
|
|
538
992
|
}
|
|
993
|
+
let output = "";
|
|
994
|
+
const absorb = (chunk) => {
|
|
995
|
+
output = `${output}${chunk}`.slice(-8000);
|
|
996
|
+
};
|
|
997
|
+
child.stdout?.setEncoding("utf8");
|
|
998
|
+
child.stderr?.setEncoding("utf8");
|
|
999
|
+
child.stdout?.on("data", absorb);
|
|
1000
|
+
child.stderr?.on("data", absorb);
|
|
1001
|
+
|
|
1002
|
+
const timer = setTimeout(() => {
|
|
1003
|
+
child.kill("SIGTERM");
|
|
1004
|
+
resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
|
|
1005
|
+
}, timeoutMs);
|
|
1006
|
+
child.on("error", (error) => {
|
|
1007
|
+
clearTimeout(timer);
|
|
1008
|
+
resolve({ ok: false, reason: error.message, output });
|
|
1009
|
+
});
|
|
1010
|
+
child.on("close", (code) => {
|
|
1011
|
+
clearTimeout(timer);
|
|
1012
|
+
resolve({
|
|
1013
|
+
ok: code === 0,
|
|
1014
|
+
reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
|
|
1015
|
+
output,
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
539
1020
|
|
|
540
|
-
|
|
541
|
-
|
|
1021
|
+
/** Generate and run scenarios against the first runnable request, if they want it. */
|
|
1022
|
+
async function runFirstTest(args, runnable, { assumeYes }) {
|
|
1023
|
+
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
1024
|
+
if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
|
|
1025
|
+
process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
process.stdout.write("Generating scenarios…\n");
|
|
1030
|
+
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
1031
|
+
target: runnable.id,
|
|
1032
|
+
run: true,
|
|
1033
|
+
allow_writes: false,
|
|
1034
|
+
max_cases: 10,
|
|
1035
|
+
});
|
|
1036
|
+
printTestSummary(result);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Pair this machine as a runner and leave it running.
|
|
1044
|
+
*
|
|
1045
|
+
* Without this, everything PreMan finds later is advice: it can describe a fix
|
|
1046
|
+
* and cannot apply one. With it, the queue has somewhere to land.
|
|
1047
|
+
*/
|
|
1048
|
+
async function setUpRunner(args, agent, { assumeYes }) {
|
|
1049
|
+
if (args.has("--no-runner")) return { state: "skipped" };
|
|
1050
|
+
const existing = readRunnerState();
|
|
1051
|
+
if (existing && runnerIsAlive()) {
|
|
1052
|
+
process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
|
|
1053
|
+
return { state: "running" };
|
|
1054
|
+
}
|
|
1055
|
+
if (
|
|
1056
|
+
!(await confirm(
|
|
1057
|
+
`Let PreMan run ${agent.label} here when it finds something to fix?`,
|
|
1058
|
+
{ assumeYes }
|
|
1059
|
+
))
|
|
1060
|
+
) {
|
|
542
1061
|
process.stdout.write(
|
|
543
|
-
|
|
544
|
-
" preman endpoints setup --file endpoints.json\n" +
|
|
545
|
-
" preman test <request-id>\n"
|
|
1062
|
+
`${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
|
|
546
1063
|
);
|
|
1064
|
+
return { state: "skipped" };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
if (!existing || existing.agent !== agent.id || existing.project_path !== path.resolve(process.cwd())) {
|
|
1069
|
+
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1070
|
+
}
|
|
1071
|
+
const started = startBackground([]);
|
|
1072
|
+
process.stdout.write(
|
|
1073
|
+
`${MARK.ok()} Runner running (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1074
|
+
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1075
|
+
);
|
|
1076
|
+
return { state: "running", pid: started.pid };
|
|
547
1077
|
} catch (error) {
|
|
548
1078
|
process.stdout.write(
|
|
549
|
-
|
|
1079
|
+
`${MARK.fail()} Could not start the runner: ${error.message}\n` +
|
|
1080
|
+
` Retry later: ${cliInvocation()} runner start --background\n`
|
|
550
1081
|
);
|
|
1082
|
+
return { state: "failed", detail: error.message };
|
|
551
1083
|
}
|
|
552
1084
|
}
|
|
553
1085
|
|
|
1086
|
+
/** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
|
|
1087
|
+
async function offerDesktop(args, { assumeYes }) {
|
|
1088
|
+
if (args.has("--no-desktop")) return { state: "skipped" };
|
|
1089
|
+
if (process.platform !== "darwin") return { state: "unsupported" };
|
|
1090
|
+
if (existsSync("/Applications/PreMan.app")) {
|
|
1091
|
+
process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
|
|
1092
|
+
return { state: "installed" };
|
|
1093
|
+
}
|
|
1094
|
+
// Default no, unlike every other step here: this one downloads a hundred-odd
|
|
1095
|
+
// megabytes and writes to /Applications, which nobody should get by pressing
|
|
1096
|
+
// Enter to move past a prompt.
|
|
1097
|
+
if (
|
|
1098
|
+
!(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
|
|
1099
|
+
assumeYes,
|
|
1100
|
+
defaultYes: false,
|
|
1101
|
+
}))
|
|
1102
|
+
) {
|
|
1103
|
+
process.stdout.write(
|
|
1104
|
+
`${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
|
|
1105
|
+
);
|
|
1106
|
+
return { state: "skipped" };
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
return await installDesktopCommand([]);
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
|
|
1112
|
+
return { state: "failed", detail: error.message };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Which of GitHub, AWS and Slack this account already has.
|
|
1118
|
+
*
|
|
1119
|
+
* One probe per provider because each owns its own list route; a provider whose
|
|
1120
|
+
* route errors is reported as unknown rather than missing, so a backend hiccup
|
|
1121
|
+
* does not send someone through an install they already did.
|
|
1122
|
+
*/
|
|
1123
|
+
export async function integrationStatus(args, apiKey) {
|
|
1124
|
+
const probe = async (method, route, pick) => {
|
|
1125
|
+
try {
|
|
1126
|
+
const result = await callBackendJson(args, method, route, { token: apiKey });
|
|
1127
|
+
if (!result.ok) return null;
|
|
1128
|
+
return (pick(result) || []).length;
|
|
1129
|
+
} catch {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
const [github, aws, slack] = await Promise.all([
|
|
1134
|
+
probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
|
|
1135
|
+
probe("GET", "/aws-links", (r) => r.links || r.list),
|
|
1136
|
+
probe("GET", "/slack/connections", (r) => r.connections || r.list),
|
|
1137
|
+
]);
|
|
1138
|
+
return { github, aws, slack };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Show what is connected, and offer to finish what is not.
|
|
1143
|
+
*
|
|
1144
|
+
* Each install lives in the customer's browser session, so every one of these
|
|
1145
|
+
* opens a URL and polls our own API until the other side reports the connection
|
|
1146
|
+
* exists — the terminal picks the result up on its own, whether they finished in
|
|
1147
|
+
* the browser or in the desktop app.
|
|
1148
|
+
*/
|
|
1149
|
+
async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
1150
|
+
if (args.has("--no-integrations")) return;
|
|
1151
|
+
const status = await integrationStatus(args, apiKey);
|
|
1152
|
+
const providers = [
|
|
1153
|
+
{ key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
1154
|
+
{ key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
1155
|
+
{ key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
|
|
1156
|
+
];
|
|
1157
|
+
|
|
1158
|
+
for (const provider of providers) {
|
|
1159
|
+
const count = status[provider.key];
|
|
1160
|
+
if (count === null) {
|
|
1161
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
|
|
1162
|
+
} else if (count > 0) {
|
|
1163
|
+
process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
|
|
1164
|
+
} else {
|
|
1165
|
+
process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
for (const provider of providers) {
|
|
1170
|
+
if (status[provider.key] !== 0) continue;
|
|
1171
|
+
if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
|
|
1172
|
+
try {
|
|
1173
|
+
await provider.run();
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
// One provider's failure must not cost the customer the ones that worked.
|
|
1176
|
+
process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Install the pre-push hook, so a push is what triggers the tests. */
|
|
1182
|
+
async function setUpPushTesting(args, { assumeYes }) {
|
|
1183
|
+
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1184
|
+
const current = (() => {
|
|
1185
|
+
try {
|
|
1186
|
+
return hookStatus();
|
|
1187
|
+
} catch {
|
|
1188
|
+
return null; // not a git repository
|
|
1189
|
+
}
|
|
1190
|
+
})();
|
|
1191
|
+
if (!current) {
|
|
1192
|
+
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1193
|
+
return { state: "unavailable" };
|
|
1194
|
+
}
|
|
1195
|
+
if (current.state === "installed") {
|
|
1196
|
+
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1197
|
+
return { state: "installed" };
|
|
1198
|
+
}
|
|
1199
|
+
if (!(await confirm("Test the endpoints you touched on every git push?", { assumeYes }))) {
|
|
1200
|
+
process.stdout.write(`${MARK.skip()} Skipped. Turn it on: ${cliInvocation()} hook install\n`);
|
|
1201
|
+
return { state: "skipped" };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const result = installHook(args);
|
|
1205
|
+
if (result.action === "conflict") {
|
|
1206
|
+
process.stdout.write(
|
|
1207
|
+
`${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
|
|
1208
|
+
);
|
|
1209
|
+
return result;
|
|
1210
|
+
}
|
|
1211
|
+
process.stdout.write(
|
|
1212
|
+
`${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
|
|
1213
|
+
" It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
|
|
1214
|
+
);
|
|
1215
|
+
return result;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Everything after the link, in one pass, with nothing left for the user to run.
|
|
1220
|
+
*
|
|
1221
|
+
* Order follows what a new account needs to see: what PreMan found, then where to
|
|
1222
|
+
* watch it, then who to tell, then when to run it.
|
|
1223
|
+
*/
|
|
1224
|
+
async function guidedFirstRun(args, agent, apiKey, serverName) {
|
|
1225
|
+
const assumeYes = args.has("--yes");
|
|
1226
|
+
|
|
1227
|
+
step("Endpoints");
|
|
1228
|
+
const counts = await discoverEndpoints(args, agent, serverName);
|
|
1229
|
+
|
|
1230
|
+
if (counts.first) {
|
|
1231
|
+
step("First test");
|
|
1232
|
+
await runFirstTest(args, counts.first, { assumeYes });
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
step("Runner");
|
|
1236
|
+
await setUpRunner(args, agent, { assumeYes });
|
|
1237
|
+
|
|
1238
|
+
step("Desktop app");
|
|
1239
|
+
await offerDesktop(args, { assumeYes });
|
|
1240
|
+
|
|
1241
|
+
step("Integrations");
|
|
1242
|
+
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1243
|
+
|
|
1244
|
+
step("Testing on push");
|
|
1245
|
+
await setUpPushTesting(args, { assumeYes });
|
|
1246
|
+
|
|
1247
|
+
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
554
1250
|
// ── Preflight ───────────────────────────────────────────────────────────
|
|
555
1251
|
|
|
556
1252
|
const MIN_NODE_MAJOR = 18;
|
|
@@ -602,6 +1298,7 @@ Connect options:
|
|
|
602
1298
|
--project Write project-local config instead of the user config
|
|
603
1299
|
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
604
1300
|
--email <email> Pre-fill the email prompt when logging in
|
|
1301
|
+
--password [value] Also set a dashboard password (signup asks for none)
|
|
605
1302
|
--backend <url> PreMan backend URL
|
|
606
1303
|
--frontend <url> PreMan frontend URL
|
|
607
1304
|
--name <name> MCP server name. Defaults to preman
|
|
@@ -610,8 +1307,15 @@ Connect options:
|
|
|
610
1307
|
--skip-dispatch-credential Do not ask for a cloud-dispatch credential
|
|
611
1308
|
--skip-login Write config without interactive terminal auth
|
|
612
1309
|
--no-pair Do not mint a pair code
|
|
1310
|
+
--no-self-test Do not start the MCP server to finish the link
|
|
1311
|
+
--no-auto-checkin Do not run the agent to finish the link
|
|
613
1312
|
--no-wait Do not wait for the agent to check in
|
|
614
1313
|
--no-guide Skip the guided first run after connecting
|
|
1314
|
+
--no-runner Do not pair this machine as a job runner
|
|
1315
|
+
--no-desktop Do not offer the desktop app
|
|
1316
|
+
--no-integrations Do not check or offer GitHub / AWS / Slack
|
|
1317
|
+
--no-hook Do not install the git pre-push hook
|
|
1318
|
+
--yes Accept every optional step without prompting
|
|
615
1319
|
--print Print the config instead of writing it
|
|
616
1320
|
`;
|
|
617
1321
|
|
|
@@ -630,31 +1334,23 @@ export async function connectCommand(commandArgs) {
|
|
|
630
1334
|
);
|
|
631
1335
|
}
|
|
632
1336
|
|
|
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);
|
|
1337
|
+
// Nothing to prompt on, so leave behind everything a CI log needs to finish
|
|
1338
|
+
// the setup by hand rather than just the reason it stopped.
|
|
1339
|
+
if (!agent && !interactive) {
|
|
1340
|
+
process.stdout.write(
|
|
1341
|
+
`preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
|
|
1342
|
+
'Then restart your agent and ask it: "run preman_status".\n' +
|
|
1343
|
+
"Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
|
|
1344
|
+
);
|
|
1345
|
+
throw new ConnectError(
|
|
1346
|
+
"preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
|
|
1347
|
+
"and --api-key pm_live_… (or --print), or use one of the snippets above.",
|
|
1348
|
+
EXIT_USAGE
|
|
1349
|
+
);
|
|
655
1350
|
}
|
|
656
1351
|
|
|
657
1352
|
if (printOnly) {
|
|
1353
|
+
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
658
1354
|
const serverConfig = buildServerConfig(args);
|
|
659
1355
|
if (agent.id === "codex") {
|
|
660
1356
|
process.stdout.write(renderCodexToml(serverName, serverConfig));
|
|
@@ -664,6 +1360,12 @@ export async function connectCommand(commandArgs) {
|
|
|
664
1360
|
return;
|
|
665
1361
|
}
|
|
666
1362
|
|
|
1363
|
+
// Verify the machine can run what we are about to write — before any config
|
|
1364
|
+
// edits or logins, so failures leave nothing half-done.
|
|
1365
|
+
await preflight(args);
|
|
1366
|
+
|
|
1367
|
+
// Account first, so "First, let's connect your PreMan account" is not the
|
|
1368
|
+
// second thing that happens.
|
|
667
1369
|
if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
|
|
668
1370
|
if (!interactive) {
|
|
669
1371
|
throw new ConnectError(
|
|
@@ -676,11 +1378,21 @@ export async function connectCommand(commandArgs) {
|
|
|
676
1378
|
process.stdout.write("\n");
|
|
677
1379
|
}
|
|
678
1380
|
|
|
679
|
-
|
|
1381
|
+
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
1382
|
+
|
|
1383
|
+
let apiKey = resolveApiKey(args);
|
|
680
1384
|
|
|
681
1385
|
let pairCode = "";
|
|
682
|
-
if (apiKey
|
|
683
|
-
|
|
1386
|
+
if (apiKey) {
|
|
1387
|
+
let pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1388
|
+
if (pairing.stale) {
|
|
1389
|
+
apiKey = await refreshStaleCredentials(args);
|
|
1390
|
+
pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1391
|
+
if (pairing.stale) {
|
|
1392
|
+
throw new ConnectError("PreMan rejected a key it just issued. Try again shortly.");
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
pairCode = pairing.pairCode;
|
|
684
1396
|
}
|
|
685
1397
|
|
|
686
1398
|
const serverConfig = buildServerConfig(args, { pairCode });
|
|
@@ -708,32 +1420,105 @@ export async function connectCommand(commandArgs) {
|
|
|
708
1420
|
);
|
|
709
1421
|
}
|
|
710
1422
|
|
|
711
|
-
//
|
|
712
|
-
// prompt inside only
|
|
713
|
-
|
|
714
|
-
|
|
1423
|
+
// A non-interactive run can still be handed the credential up front, so this
|
|
1424
|
+
// stays reachable; the prompt inside only fires when there is a TTY, and by
|
|
1425
|
+
// then onboarding is done.
|
|
715
1426
|
if (!pairCode || args.has("--no-wait") || !interactive) {
|
|
1427
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
716
1428
|
process.stdout.write(nextStepsBlock(agent));
|
|
717
1429
|
return;
|
|
718
1430
|
}
|
|
719
1431
|
|
|
1432
|
+
if (!(await establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }))) {
|
|
1433
|
+
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
1434
|
+
// on top of a connect that just told the user something went wrong.
|
|
1435
|
+
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
|
|
1440
|
+
if (!args.has("--no-guide")) {
|
|
1441
|
+
await guidedFirstRun(args, agent, apiKey, serverName);
|
|
1442
|
+
}
|
|
1443
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Finish the link here, by whatever means work, in cheapest-first order.
|
|
1448
|
+
*
|
|
1449
|
+
* 1. Run the MCP server ourselves and call one tool. No agent, no tokens, a few
|
|
1450
|
+
* seconds, and it proves the launcher/key/backend chain the agent will use.
|
|
1451
|
+
* 2. Failing that, run the agent headlessly — which also proves the agent can
|
|
1452
|
+
* load the config we wrote.
|
|
1453
|
+
* 3. Failing that, ask them to restart it and wait, which is all this ever did.
|
|
1454
|
+
*
|
|
1455
|
+
* Returns whether the check-in landed, and prints the troubleshooting block
|
|
1456
|
+
* itself when it did not.
|
|
1457
|
+
*/
|
|
1458
|
+
async function establishCheckIn(args, agent, apiKey, { serverName, written, serverConfig }) {
|
|
1459
|
+
const notes = [];
|
|
1460
|
+
|
|
1461
|
+
if (!args.has("--no-self-test") && serverConfig && selfTestBudgetMs() > 0) {
|
|
1462
|
+
process.stdout.write("\nChecking the connection…\n");
|
|
1463
|
+
const test = await mcpSelfTest(serverConfig);
|
|
1464
|
+
const status = test.status || {};
|
|
1465
|
+
const repo = status.config?.repo_config;
|
|
1466
|
+
if (repo?.override && repo.applied?.length) {
|
|
1467
|
+
// The one failure that reads as a bad key: a working server talking to a
|
|
1468
|
+
// backend nobody chose. Name the file before it costs anyone an hour.
|
|
1469
|
+
notes.push(
|
|
1470
|
+
`${repo.path} overrides ${repo.applied.join(", ")} for anything started in this directory, ` +
|
|
1471
|
+
`so your agent will use ${status.backend_url}.`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
if (test.ok && status.authenticated && (await waitForConnection(args, apiKey, { timeoutMs: 15000 }))) {
|
|
1475
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
notes.push(
|
|
1479
|
+
test.ok
|
|
1480
|
+
? `the MCP server answered from ${status.backend_url || "an unknown backend"} but was not authenticated`
|
|
1481
|
+
: `the MCP server could not be started (${test.reason || "unknown"}${test.stderr ? `: ${test.stderr}` : ""})`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (!args.has("--no-auto-checkin")) {
|
|
1486
|
+
const spec = headlessCheckIn(agent, serverName);
|
|
1487
|
+
if (spec && onPath(spec.bin)) {
|
|
1488
|
+
process.stdout.write(`\nStarting ${agent.label} to finish the link…\n`);
|
|
1489
|
+
}
|
|
1490
|
+
const auto = await autoCheckIn(args, agent, apiKey, { serverName });
|
|
1491
|
+
if (auto.connected) {
|
|
1492
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
if (auto.ran) {
|
|
1496
|
+
const why = lastLine(auto.output);
|
|
1497
|
+
process.stdout.write(
|
|
1498
|
+
`${agent.label} ran but did not check in${why ? `: ${why}` : "."}\n`
|
|
1499
|
+
);
|
|
1500
|
+
} else if (auto.reason) {
|
|
1501
|
+
process.stdout.write(`Could not run ${agent.label}: ${auto.reason}\n`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
720
1505
|
process.stdout.write(
|
|
721
1506
|
`\nRestart ${agent.label} and ask it: "run preman_status"\n` +
|
|
722
1507
|
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
723
1508
|
);
|
|
724
1509
|
|
|
725
|
-
if (
|
|
726
|
-
process.stdout.write(
|
|
727
|
-
|
|
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
|
-
);
|
|
732
|
-
return;
|
|
1510
|
+
if (await waitForConnection(args, apiKey)) {
|
|
1511
|
+
for (const note of notes) process.stdout.write(`Note: ${note}\n`);
|
|
1512
|
+
return true;
|
|
733
1513
|
}
|
|
734
1514
|
|
|
735
|
-
process.stdout.write(
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
1515
|
+
process.stdout.write(
|
|
1516
|
+
"No check-in yet. Troubleshooting:\n" +
|
|
1517
|
+
notes.map((note) => ` - ${note}\n`).join("") +
|
|
1518
|
+
` - ${agent.restartHint}\n` +
|
|
1519
|
+
` - Config written to: ${written.path}\n` +
|
|
1520
|
+
` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n` +
|
|
1521
|
+
` - Then: ${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}\n`
|
|
1522
|
+
);
|
|
1523
|
+
return false;
|
|
739
1524
|
}
|