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 CHANGED
@@ -19,11 +19,12 @@ Local development form:
19
19
  node bin/cli.js connect
20
20
  ```
21
21
 
22
- First-time users are prompted for email, OTP, and password directly in the terminal.
23
- PreMan creates or connects the account, generates an API key, saves it to
24
- `~/.preman/credentials.json`, then writes a `preman` MCP server into the config your
25
- agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
26
- `~/.codex/config.toml`):
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 waits for your agent to check in — that
47
- check-in is the only real proof the agent loaded the server.
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
- `connect` offers to store a coding-agent credential — a Cursor API key, or a Claude
66
- Code routine token and id. With one saved, PreMan can start an agent run for you when
67
- it finds a failing endpoint instead of handing back a prompt to paste. Press Enter to
68
- skip; everything else still works.
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 (!openBrowser(verificationUrl)) {
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 { CONNECT_HELP, connectCommand, writeCursorConfig } from "./connect.js";
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: { credential: "Cursor API key", needsRoutine: false },
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: { credential: "Claude Code routine token", needsRoutine: true },
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
- async function promptAgentChoice(detected) {
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
- const mark = detected[agent.id] ? " (detected)" : "";
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 defaultIndex = Math.max(
108
- 0,
109
- AGENTS.findIndex((a) => detected[a.id])
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
- `\nLet PreMan start ${agent.label} runs for you it can then apply fixes and\n` +
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 set up later): `);
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()} connect --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
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
- if (!agent) {
634
- if (!interactive) {
635
- // Nothing to prompt on, so leave behind everything a CI log needs to
636
- // finish the setup by hand rather than just the reason it stopped.
637
- process.stdout.write(
638
- `preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
639
- 'Then restart your agent and ask it: "run preman_status".\n' +
640
- "Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
641
- );
642
- throw new ConnectError(
643
- "preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
644
- "and --api-key pm_live_… (or --print), or use one of the snippets above.",
645
- EXIT_USAGE
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
- // Not gated on TTY: --dispatch-credential is the non-interactive path, and the
712
- // prompt inside only runs when there is a terminal to prompt on.
713
- await captureDispatchCredential(args, agent, apiKey);
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
- process.stdout.write(
721
- `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
722
- "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
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
  }
@@ -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── ${step.name} ──\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── done ──\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
- const password = await promptPasswordTwice();
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 password = await promptPasswordTwice();
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
- process.stdout.write("This account needs a password. Sending an OTP first.\n");
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
- const password = await promptPasswordTwice();
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
- const login = await callBackendJson(args, "POST", "/auth/login", {
319
- json: { email, password },
320
- });
321
- if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
322
- accessToken = await verifyUnconfirmedAccount(args, email);
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
- assertOk(login, "login");
325
- accessToken = String(login.access_token || "");
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {