premanmcp 0.10.6 → 0.11.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
@@ -48,11 +48,13 @@ to go restart anything, in cheapest-first order:
48
48
  1. **Self-test.** It starts the MCP server exactly as your agent will and calls
49
49
  `preman_status` over stdio. That both completes the link and proves the whole chain —
50
50
  launcher, package, key, backend. `--no-self-test` turns it off.
51
- 2. **Agent run**, which also proves your agent can load what was written. It opens your
52
- agent interactively in a new terminal window the session you go on to use — and falls
53
- back to a headless run (`claude -p`, `cursor-agent -p`, `codex exec`) where no window can
54
- be opened, such as CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1`
55
- keeps it headless.
51
+ 2. **Agent run**, which also proves your agent can load what was written. For Cursor it
52
+ runs `cursor-agent mcp enable` after writing `mcp.json` (new servers stay off the CLI
53
+ approved list otherwise) and launches the agent with `--approve-mcps` so the check-in
54
+ can actually call `preman_status`. It opens your agent interactively in a new terminal
55
+ window the session you go on to use — and falls back to a headless run (`claude -p`,
56
+ `cursor-agent --approve-mcps -p`, `codex exec`) where no window can be opened, such as
57
+ CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1` keeps it headless.
56
58
  3. **Wait**, if neither is possible: restart your agent and it links on its first call.
57
59
 
58
60
  A self-test that answers from an unexpected backend is reported with the file that
@@ -71,6 +73,10 @@ up a link that already worked:
71
73
 
72
74
  ```bash
73
75
  preman endpoints discover # map this repo's endpoints
76
+ preman tests generate # heuristic suites on saved Collections requests
77
+ preman tests review # list flagged suites; --approve <id> enables schedule
78
+ preman tests setup # harvest path-param fixture IDs into a .env snippet
79
+ preman tests enrich # optional LLM cases on remaining heuristic suites
74
80
  preman runner start --background # let PreMan apply fixes on this machine
75
81
  preman github # or connect it in the dashboard
76
82
  preman status # which of those are done
package/bin/account.js CHANGED
@@ -29,7 +29,7 @@ export const ACCOUNT_HELP = `
29
29
  Account options:
30
30
  login --browser Approve sign-in in the browser instead of typing a password
31
31
  logout Delete stored CLI credentials
32
- doctor Check backend, credentials, target, and integrations
32
+ doctor [--port n] Check backend, credentials, target, and integrations
33
33
  watch <run-id> Follow a push simulation as it runs
34
34
  `;
35
35
 
@@ -140,7 +140,10 @@ export async function doctorCommand(commandArgs = []) {
140
140
  if (!status.ok) failures += 1;
141
141
  }
142
142
 
143
- const candidates = detectCandidates(process.cwd());
143
+ const explicitPort = args.value("--port", "");
144
+ const candidates = detectCandidates(process.cwd(), {
145
+ extraPorts: explicitPort ? [explicitPort] : [],
146
+ });
144
147
  results.push(
145
148
  line(
146
149
  "local target",
@@ -150,6 +153,7 @@ export async function doctorCommand(commandArgs = []) {
150
153
  : "no signals in this directory"
151
154
  )
152
155
  );
156
+ if (!candidates.length) failures += 1;
153
157
 
154
158
  // An installed hook whose command no longer answers is the one failure here
155
159
  // that reports itself as success everywhere else: `git push` prints a single
@@ -200,7 +204,18 @@ export async function doctorCommand(commandArgs = []) {
200
204
  return { failures };
201
205
  }
202
206
 
203
- const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "error", "cancelled"]);
207
+ const TERMINAL_STATUSES = new Set([
208
+ "complete",
209
+ "completed",
210
+ "succeeded",
211
+ "failed",
212
+ "error",
213
+ "cancelled",
214
+ ]);
215
+
216
+ export function isTerminalSimulationStatus(value) {
217
+ return TERMINAL_STATUSES.has(String(value || "").toLowerCase());
218
+ }
204
219
 
205
220
  export async function watchCommand(commandArgs = []) {
206
221
  const args = makeArgs(commandArgs);
@@ -235,7 +250,7 @@ export async function watchCommand(commandArgs = []) {
235
250
  process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
236
251
  }
237
252
 
238
- if (TERMINAL_STATUSES.has(String(result.status || "").toLowerCase())) {
253
+ if (isTerminalSimulationStatus(result.status)) {
239
254
  const verdict = result.summary?.verdict || result.status;
240
255
  process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
241
256
  return result;
package/bin/api_tools.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { readFileSync } from "node:fs";
10
10
 
11
11
  import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
12
+ import { printPlayground } from "./desktop.js";
12
13
 
13
14
  export const ENDPOINTS_HELP = `
14
15
  Endpoints:
@@ -144,6 +145,13 @@ export async function endpointsCommand(commandArgs) {
144
145
  if (requests.length) {
145
146
  process.stdout.write(`runnable as: ${requests.join(", ")}\n`);
146
147
  }
148
+ const url = result.ui?.url || result.url;
149
+ printPlayground(url);
150
+ if (result.campaign?.id) {
151
+ process.stdout.write(
152
+ `test campaign ${result.campaign.id}: ${result.campaign.queued || 0} queued, ${result.campaign.skipped || 0} skipped\n`,
153
+ );
154
+ }
147
155
  return undefined;
148
156
  }
149
157
 
package/bin/cli.js CHANGED
@@ -17,6 +17,7 @@ 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 { TESTS_HELP, testsCommand } from "./tests.js";
20
21
  import {
21
22
  CONNECT_HELP,
22
23
  DISPATCH_HELP,
@@ -56,7 +57,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
56
57
  const ROOT = path.join(__dirname, "..");
57
58
 
58
59
  const args = process.argv.slice(2);
59
- const command = args[0] === "--help" || args[0] === "-h"
60
+ const command = args.length === 0 && process.stdin.isTTY
61
+ ? "connect"
62
+ : args[0] === "--help" || args[0] === "-h"
60
63
  ? "help"
61
64
  : args[0] && !args[0].startsWith("-")
62
65
  ? args[0]
@@ -98,6 +101,7 @@ function printHelp() {
98
101
  ["link|tools|run ...", "Drive a published hosted MCP"],
99
102
  ["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
100
103
  ["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
104
+ ["tests generate|review|setup|enrich", "Collections: generate, review, fixtures, enrich"],
101
105
  ];
102
106
  const width = Math.max(...usage.map(([command]) => `${cli} ${command}`.trimEnd().length));
103
107
  const usageLines = usage
@@ -108,7 +112,7 @@ function printHelp() {
108
112
 
109
113
  Usage:
110
114
  ${usageLines}
111
- ${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${RUNNER_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
115
+ ${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${RUNNER_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}${TESTS_HELP}
112
116
  Login options:
113
117
  --email <email> Pre-fill the email prompt
114
118
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
@@ -277,6 +281,8 @@ async function main() {
277
281
  await endpointsCommand(commandArgs);
278
282
  } else if (command === "test") {
279
283
  await testCommand(commandArgs);
284
+ } else if (command === "tests") {
285
+ await testsCommand(commandArgs);
280
286
  } else if (command === "help" || command === "--help" || command === "-h") {
281
287
  printHelp();
282
288
  } else if (command === "start") {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Which coding agent this machine should use, and how it is asked.
3
+ *
4
+ * The registry is the shared vocabulary: `id` matches the backend's
5
+ * normalize_agent(), so a link record and this CLI cannot disagree about who is
6
+ * connected. Resolution is ordered by how much each signal actually knows --
7
+ * the session we are inside, the agent PreMan is already written into, the only
8
+ * one installed -- because asking is the step people resent most.
9
+ */
10
+
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { onPath, promptText, readJsonFile } from "../shared.js";
16
+ import { ConnectError, EXIT_USAGE } from "./errors.js";
17
+
18
+ /**
19
+ * Supported agents. `id` matches the backend's normalize_agent() vocabulary so
20
+ * the link record and this CLI cannot disagree about who is connected.
21
+ */
22
+ export const AGENTS = [
23
+ {
24
+ id: "cursor",
25
+ label: "Cursor",
26
+ aliases: ["cursor"],
27
+ dispatch: {
28
+ credential: "Cursor API key",
29
+ needsRoutine: false,
30
+ source: "cursor.com/dashboard → Integrations → API Keys",
31
+ },
32
+ snippetHint: "merge into ~/.cursor/mcp.json, then run: cursor-agent mcp enable preman",
33
+ restartHint:
34
+ 'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on. For the CLI, run `cursor-agent mcp enable preman`.',
35
+ },
36
+ {
37
+ id: "claude_code",
38
+ label: "Claude Code",
39
+ aliases: ["claude", "claude-code", "claude_code", "claudecode"],
40
+ dispatch: {
41
+ credential: "Claude Code routine token",
42
+ needsRoutine: true,
43
+ // Named explicitly because "Claude Code routine token" reads like an
44
+ // Anthropic API key, which is a different credential from a different page.
45
+ source: "claude.ai/code/routines → your routine → Add API trigger",
46
+ },
47
+ snippetHint: "run:",
48
+ restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
49
+ },
50
+ {
51
+ id: "codex",
52
+ label: "Codex",
53
+ aliases: ["codex", "openai-codex", "openai_codex"],
54
+ dispatch: null, // No public fire API; stays on the copy-paste path.
55
+ snippetHint: "append to ~/.codex/config.toml",
56
+ restartHint: "Restart Codex so it re-reads its config.toml.",
57
+ },
58
+ ];
59
+
60
+ export function findAgent(value) {
61
+ const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
62
+ return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
63
+ }
64
+
65
+
66
+ /**
67
+ * Why this agent's CLI cannot run unattended right now, or "".
68
+ *
69
+ * Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
70
+ * on every `-p` run until someone signs in, which surfaced as "Cursor did not
71
+ * register any endpoints" and sent people looking at PreMan for an hour. Only
72
+ * Cursor is probed because only its CLI has a cheap non-interactive status
73
+ * subcommand; the others are diagnosed from their own output when they fail.
74
+ */
75
+ export function agentBlocker(agentId) {
76
+ if (agentId !== "cursor") return "";
77
+ const probe = spawnSync("cursor-agent", ["status"], {
78
+ encoding: "utf8",
79
+ timeout: 15000,
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ });
82
+ const text = `${probe.stdout || ""}${probe.stderr || ""}`;
83
+ if (/not logged in|not authenticated|no active session/i.test(text)) {
84
+ return "cursor-agent is installed but not signed in — run `cursor-agent login`";
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /** Best guess at which agent this machine actually uses, for the default pick. */
90
+ export function detectAgents() {
91
+ const home = os.homedir();
92
+ return {
93
+ cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
94
+ claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
95
+ codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The agent whose session this command is running inside, if any.
101
+ *
102
+ * A better default than "first one installed": someone who types this into an
103
+ * agent's terminal almost always means that agent, and on a machine with all
104
+ * three installed the detected-order default is usually wrong.
105
+ */
106
+ export function runningInside(env = process.env) {
107
+ if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
108
+ if (env.CURSOR_TRACE_ID) return "cursor";
109
+ return "";
110
+ }
111
+
112
+ /** Is PreMan's MCP server already written into this agent's config? */
113
+ function agentIsLinked(agentId, serverName = "preman") {
114
+ try {
115
+ const home = os.homedir();
116
+ if (agentId === "cursor") {
117
+ return Boolean(readJsonFile(path.join(home, ".cursor", "mcp.json")).mcpServers?.[serverName]);
118
+ }
119
+ if (agentId === "claude_code") {
120
+ return Boolean(readJsonFile(path.join(home, ".claude.json")).mcpServers?.[serverName]);
121
+ }
122
+ if (agentId === "codex") {
123
+ const file = path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
124
+ return existsSync(file) && readFileSync(file, "utf8").includes(`[mcp_servers.${serverName}]`);
125
+ }
126
+ } catch {
127
+ // An unreadable config is not an answer; fall through to the other signals.
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /**
133
+ * Which agent this machine should pair as, or "" when only a person can say.
134
+ *
135
+ * Pairing needs an agent id, and demanding `--agent` for it turned "start the
136
+ * runner" into a command someone had to be told twice. The signals are ordered
137
+ * by how much they actually know: the session we are inside, then the agent
138
+ * PreMan is already configured in, then the only one installed. Asking is the
139
+ * last resort rather than the first, and a machine with no terminal to ask in
140
+ * gets "" so the caller can fail with a sentence instead of hanging on a prompt.
141
+ */
142
+ export async function resolveAgentForPairing({ interactive = Boolean(process.stdin.isTTY) } = {}) {
143
+ const inside = runningInside();
144
+ if (inside) return inside;
145
+
146
+ const linked = AGENTS.filter((agent) => agentIsLinked(agent.id));
147
+ if (linked.length === 1) return linked[0].id;
148
+
149
+ const detected = detectAgents();
150
+ const present = AGENTS.filter((agent) => detected[agent.id]);
151
+ if (present.length === 1) return present[0].id;
152
+
153
+ if (!interactive) return "";
154
+ const picked = await promptAgentChoice(detected);
155
+ return picked?.id || "";
156
+ }
157
+
158
+ /**
159
+ * The agent a directly-invoked command should drive, as an object, or null.
160
+ *
161
+ * Same signals as pairing, resolved to the record `discoverEndpoints` needs,
162
+ * with an explicit `--agent` taking precedence so the command stays scriptable
163
+ * on a machine that has all three installed. Commands the user typed on purpose
164
+ * get to run the agent; the caller falls back to printing the brief on null,
165
+ * which is also what a non-interactive run with no clear answer gets.
166
+ */
167
+ export async function resolveAgentToDrive({ agent = "", ...options } = {}) {
168
+ return findAgent(agent) || findAgent(await resolveAgentForPairing(options));
169
+ }
170
+
171
+ export async function promptAgentChoice(detected, insideId = runningInside()) {
172
+ process.stdout.write("Which coding agent?\n");
173
+ AGENTS.forEach((agent, index) => {
174
+ let mark = "";
175
+ if (agent.id === insideId) mark = " (this session)";
176
+ else if (detected[agent.id]) mark = " (detected)";
177
+ process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
178
+ });
179
+
180
+ const inside = AGENTS.findIndex((a) => a.id === insideId);
181
+ const defaultIndex =
182
+ inside >= 0
183
+ ? inside
184
+ : Math.max(
185
+ 0,
186
+ AGENTS.findIndex((a) => detected[a.id])
187
+ );
188
+ const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
189
+ if (!answer) return AGENTS[defaultIndex];
190
+
191
+ const byNumber = Number.parseInt(answer, 10);
192
+ if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
193
+ return AGENTS[byNumber - 1];
194
+ }
195
+ const byName = findAgent(answer);
196
+ if (byName) return byName;
197
+ throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
198
+ }