premanmcp 0.7.1 → 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/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";
@@ -25,6 +25,8 @@ import {
25
25
  cliInvocation,
26
26
  frontendUrl,
27
27
  hasKeyAvailable,
28
+ LAUNCHER_ARGS,
29
+ LAUNCHER_COMMAND,
28
30
  makeArgs,
29
31
  promptSecret,
30
32
  promptText,
@@ -51,7 +53,11 @@ const AGENTS = [
51
53
  id: "cursor",
52
54
  label: "Cursor",
53
55
  aliases: ["cursor"],
54
- 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
+ },
55
61
  snippetHint: "merge into ~/.cursor/mcp.json",
56
62
  restartHint: 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on.',
57
63
  },
@@ -59,7 +65,13 @@ const AGENTS = [
59
65
  id: "claude_code",
60
66
  label: "Claude Code",
61
67
  aliases: ["claude", "claude-code", "claude_code", "claudecode"],
62
- 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
+ },
63
75
  snippetHint: "run:",
64
76
  restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
65
77
  },
@@ -95,17 +107,36 @@ function detectAgents() {
95
107
  };
96
108
  }
97
109
 
98
- 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()) {
99
124
  process.stdout.write("Which coding agent?\n");
100
125
  AGENTS.forEach((agent, index) => {
101
- 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)";
102
129
  process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
103
130
  });
104
131
 
105
- const defaultIndex = Math.max(
106
- 0,
107
- AGENTS.findIndex((a) => detected[a.id])
108
- );
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
+ );
109
140
  const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
110
141
  if (!answer) return AGENTS[defaultIndex];
111
142
 
@@ -272,8 +303,13 @@ export function renderAllAgentSnippets(args, serverName, { projectInstall = fals
272
303
  PREMAN_BACKEND: backendUrl(args),
273
304
  PREMAN_FRONTEND: frontendUrl(args),
274
305
  };
275
- const serverConfig = { command: "npx", args: ["-y", "premanmcp@latest"], env };
276
-
306
+ const serverConfig = {
307
+ command: LAUNCHER_COMMAND,
308
+ args: [...LAUNCHER_ARGS],
309
+ env,
310
+ };
311
+
312
+
277
313
  return AGENTS.map((agent) => {
278
314
  // Adjust hint based on projectInstall, matching verifyWrittenConfig logic
279
315
  let hint = agent.snippetHint;
@@ -319,8 +355,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
319
355
  if (!block) {
320
356
  return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
321
357
  }
322
- if (!block.some((line) => line.trim() === 'command = "npx"')) {
323
- return { status: "mismatch", detail: `${written.path} does not launch npx` };
358
+ if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
359
+ return {
360
+ status: "mismatch",
361
+ detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
362
+ };
324
363
  }
325
364
  return { status: "verified" };
326
365
  }
@@ -336,8 +375,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
336
375
 
337
376
  const entry = readJsonFile(written.path).mcpServers?.[serverName];
338
377
  if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
339
- if (entry.command !== "npx") {
340
- return { status: "mismatch", detail: `${written.path} does not launch npx` };
378
+ if (entry.command !== LAUNCHER_COMMAND) {
379
+ return {
380
+ status: "mismatch",
381
+ detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
382
+ };
341
383
  }
342
384
  return { status: "verified" };
343
385
  } catch (error) {
@@ -368,6 +410,7 @@ export async function waitForConnection(
368
410
  {
369
411
  intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
370
412
  timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
413
+ stopWhen = null,
371
414
  } = {}
372
415
  ) {
373
416
  const deadline = Date.now() + timeoutMs;
@@ -383,6 +426,7 @@ export async function waitForConnection(
383
426
  token: apiKey,
384
427
  });
385
428
  if (status.ok && status.connected) return true;
429
+ if (stopWhen && stopWhen()) return false;
386
430
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
387
431
  }
388
432
  } finally {
@@ -391,9 +435,94 @@ export async function waitForConnection(
391
435
  return false;
392
436
  }
393
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
+
394
523
  // ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
395
524
 
396
- async function captureDispatchCredential(args, agent, apiKey) {
525
+ async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
397
526
  if (!agent.dispatch) return;
398
527
  if (args.has("--skip-dispatch-credential")) return;
399
528
 
@@ -401,19 +530,20 @@ async function captureDispatchCredential(args, agent, apiKey) {
401
530
  let routineId = args.value("--routine-id", "");
402
531
 
403
532
  if (!secret) {
404
- if (!process.stdin.isTTY) return;
533
+ if (!prompt || !process.stdin.isTTY) return;
405
534
  // Framed as the expected step rather than an optional aside. Without it
406
535
  // PreMan can only suggest fixes; with it, it can run them. Presenting it as
407
536
  // "optional, press Enter to skip" meant almost everyone skipped the thing
408
537
  // that makes the product act rather than advise.
409
538
  process.stdout.write(
410
- `\nLet PreMan start ${agent.label} runs for you — it can then apply fixes and\n` +
411
- `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`
412
542
  );
413
- 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): `);
414
544
  if (!secret) {
415
545
  process.stdout.write(
416
- `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`
417
547
  );
418
548
  return;
419
549
  }
@@ -445,6 +575,64 @@ async function captureDispatchCredential(args, agent, apiKey) {
445
575
  }
446
576
  }
447
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
+
448
636
  /** Accept either a bare trig_… id or the routine URL it appears in. */
449
637
  export function extractRoutineId(value) {
450
638
  const raw = String(value || "").trim();
@@ -589,6 +777,7 @@ Connect options:
589
777
  --project Write project-local config instead of the user config
590
778
  --api-key <key> PreMan API key. If omitted, stored credentials are used
591
779
  --email <email> Pre-fill the email prompt when logging in
780
+ --password [value] Also set a dashboard password (signup asks for none)
592
781
  --backend <url> PreMan backend URL
593
782
  --frontend <url> PreMan frontend URL
594
783
  --name <name> MCP server name. Defaults to preman
@@ -597,6 +786,7 @@ Connect options:
597
786
  --skip-dispatch-credential Do not ask for a cloud-dispatch credential
598
787
  --skip-login Write config without interactive terminal auth
599
788
  --no-pair Do not mint a pair code
789
+ --no-auto-checkin Do not run the agent to finish the link
600
790
  --no-wait Do not wait for the agent to check in
601
791
  --no-guide Skip the guided first run after connecting
602
792
  --print Print the config instead of writing it
@@ -617,31 +807,23 @@ export async function connectCommand(commandArgs) {
617
807
  );
618
808
  }
619
809
 
620
- if (!agent) {
621
- if (!interactive) {
622
- // Nothing to prompt on, so leave behind everything a CI log needs to
623
- // finish the setup by hand rather than just the reason it stopped.
624
- process.stdout.write(
625
- `preman connect needs a terminal to pick an agent. Copy-paste setup instead:\n\n${renderAllAgentSnippets(args, serverName, { projectInstall })}\n` +
626
- 'Then restart your agent and ask it: "run preman_status".\n' +
627
- "Or rerun: preman connect --agent <cursor|claude-code|codex> --api-key pm_live_…\n"
628
- );
629
- throw new ConnectError(
630
- "preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
631
- "and --api-key pm_live_… (or --print), or use one of the snippets above.",
632
- EXIT_USAGE
633
- );
634
- }
635
- agent = await promptAgentChoice(detectAgents());
636
- }
637
-
638
- if (!printOnly) {
639
- // Verify the machine can run what we are about to write — before any
640
- // config edits or logins, so failures leave nothing half-done.
641
- 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
+ );
642
823
  }
643
824
 
644
825
  if (printOnly) {
826
+ if (!agent) agent = await promptAgentChoice(detectAgents());
645
827
  const serverConfig = buildServerConfig(args);
646
828
  if (agent.id === "codex") {
647
829
  process.stdout.write(renderCodexToml(serverName, serverConfig));
@@ -651,6 +833,12 @@ export async function connectCommand(commandArgs) {
651
833
  return;
652
834
  }
653
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.
654
842
  if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
655
843
  if (!interactive) {
656
844
  throw new ConnectError(
@@ -663,6 +851,8 @@ export async function connectCommand(commandArgs) {
663
851
  process.stdout.write("\n");
664
852
  }
665
853
 
854
+ if (!agent) agent = await promptAgentChoice(detectAgents());
855
+
666
856
  const apiKey = resolveApiKey(args);
667
857
 
668
858
  let pairCode = "";
@@ -695,27 +885,19 @@ export async function connectCommand(commandArgs) {
695
885
  );
696
886
  }
697
887
 
698
- // Not gated on TTY: --dispatch-credential is the non-interactive path, and the
699
- // prompt inside only runs when there is a terminal to prompt on.
700
- await captureDispatchCredential(args, agent, apiKey);
701
-
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.
702
891
  if (!pairCode || args.has("--no-wait") || !interactive) {
892
+ await captureDispatchCredential(args, agent, apiKey);
703
893
  process.stdout.write(nextStepsBlock(agent));
704
894
  return;
705
895
  }
706
896
 
707
- process.stdout.write(
708
- `\nRestart ${agent.label} and ask it: "run preman_status"\n` +
709
- "Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
710
- );
711
-
712
- if (!(await waitForConnection(args, apiKey))) {
713
- process.stdout.write(
714
- "No check-in yet. Troubleshooting:\n" +
715
- ` - ${agent.restartHint}\n` +
716
- ` - Config written to: ${written.path}\n` +
717
- ` - Then ask ${agent.label} to "run preman_status" — it links on its first PreMan call.\n`
718
- );
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 });
719
901
  return;
720
902
  }
721
903
 
@@ -723,4 +905,42 @@ export async function connectCommand(commandArgs) {
723
905
  if (!args.has("--no-guide")) {
724
906
  await guidedFirstRun(args, agent);
725
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;
726
946
  }