premanmcp 0.10.6 → 0.10.7

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/desktop.js CHANGED
@@ -136,7 +136,7 @@ function mountedVolume(hdiutilOutput) {
136
136
  export async function installDesktopCommand(commandArgs = []) {
137
137
  const args = makeArgs(commandArgs);
138
138
 
139
- if (process.platform !== "darwin") {
139
+ if (process.platform !== "darwin" && !args.has("--print-url")) {
140
140
  process.stdout.write(
141
141
  `The PreMan desktop app is macOS-only right now.\n` +
142
142
  `Downloads for other platforms: ${RELEASES_BASE}/latest\n`
package/bin/detect.js CHANGED
@@ -246,6 +246,36 @@ function normalisePathTemplate(value) {
246
246
  .toLowerCase() || "/";
247
247
  }
248
248
 
249
+ function normaliseBaseUrl(value) {
250
+ try {
251
+ const parsed = new URL(String(value || "").trim());
252
+ if (!isLoopback(parsed.href)) {
253
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/, "");
254
+ }
255
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
256
+ const pathname = parsed.pathname.replace(/\/+$/, "");
257
+ return `${parsed.protocol}//loopback:${port}${pathname}`;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Prefer endpoints explicitly registered to this candidate. If none carry that
265
+ * base URL, preserve the existing repo-local workflow where a production
266
+ * inventory is retargeted to a local dev server and confirmed by route overlap.
267
+ */
268
+ function inventoryForCandidate(candidate, inventory) {
269
+ const all = inventory || [];
270
+ const candidateBase = normaliseBaseUrl(candidate.url);
271
+ const matching = candidateBase
272
+ ? all.filter((ep) => normaliseBaseUrl(ep?.base_url) === candidateBase)
273
+ : [];
274
+ const selected = matching.length ? matching : all;
275
+ const scope = matching.length ? "matching_base_url" : "all_routes";
276
+ return { inventory: selected, scope };
277
+ }
278
+
249
279
  /** Route keys the live app advertises through its generated spec, if it has one. */
250
280
  async function liveSpecRoutes(baseUrl, timeoutMs) {
251
281
  for (const specPath of SPEC_PATHS) {
@@ -305,7 +335,8 @@ export async function confirmCandidate(candidate, inventory, { timeoutMs = 2000
305
335
  let matched = 0;
306
336
  for (const key of expected) if (live.keys.has(key)) matched += 1;
307
337
  const overlap = matched / expected.size;
308
- if (matched >= CONFIRM_MIN_ROUTES && overlap >= CONFIRM_MIN_OVERLAP) {
338
+ const requiredRoutes = Math.min(CONFIRM_MIN_ROUTES, expected.size);
339
+ if (matched >= requiredRoutes && overlap >= CONFIRM_MIN_OVERLAP) {
309
340
  return {
310
341
  ...candidate,
311
342
  confirmed: true,
@@ -366,9 +397,17 @@ export async function resolveLocalTarget(
366
397
  }
367
398
  const attempts = [];
368
399
  for (const candidate of candidates) {
369
- const result = await confirmCandidate(candidate, inventory, { timeoutMs });
400
+ const scoped = inventoryForCandidate(candidate, inventory);
401
+ const result = await confirmCandidate(candidate, scoped.inventory, { timeoutMs });
370
402
  attempts.push(result);
371
- if (result.confirmed) return { target: result, candidates: attempts };
403
+ if (result.confirmed) {
404
+ return {
405
+ target: result,
406
+ candidates: attempts,
407
+ inventory: scoped.inventory,
408
+ inventoryScope: scoped.scope,
409
+ };
410
+ }
372
411
  }
373
412
  return {
374
413
  target: null,
package/bin/hook.js CHANGED
@@ -135,6 +135,10 @@ function embeddedInvocation(text) {
135
135
  return match ? match[1].trim() : "";
136
136
  }
137
137
 
138
+ function isGeneratedInvocation(invocation) {
139
+ return invocation === "preman" || /^npm exec -y premanmcp@\S+ --$/.test(invocation);
140
+ }
141
+
138
142
  function hookBody(invocation) {
139
143
  // `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
140
144
  // non-zero and still exit 0 itself.
@@ -257,10 +261,15 @@ export function hookStatus({ probe = false } = {}) {
257
261
  if (!isOurHook(existing)) return { ...missing, state: "foreign" };
258
262
 
259
263
  const invocation = embeddedInvocation(existing);
264
+ const declared = declaredInvocation();
260
265
  // Falling back to what the file already carries keeps a machine that can no
261
266
  // longer prove any invocation -- offline, say -- from reporting a hook as out
262
267
  // of date and sending its owner to a reinstall that would refuse to write.
263
- const wanted = provenInvocation() || invocation;
268
+ // A custom wrapper or direct Node command is itself the user's pin. Ambient
269
+ // PATH changes must not make it stale after PREMAN_HOOK_INVOCATION disappears.
270
+ const wanted =
271
+ declared ||
272
+ (isGeneratedInvocation(invocation) ? provenInvocation() || invocation : invocation);
264
273
  return {
265
274
  path: target,
266
275
  state: "installed",
package/bin/hosted.js CHANGED
@@ -37,7 +37,7 @@ export class CliError extends Error {
37
37
 
38
38
  // --- arg parsing (mirrors bin/cli.js; no --flag=value form) -----------------
39
39
 
40
- function makeArgs(commandArgs) {
40
+ function makeHostedArgs(commandArgs) {
41
41
  return {
42
42
  positional: commandArgs.filter((a, i) => {
43
43
  if (a.startsWith("-")) return false;
@@ -297,7 +297,7 @@ async function promptToken() {
297
297
  // --- commands --------------------------------------------------------------
298
298
 
299
299
  export async function linkCommand(commandArgs) {
300
- const args = makeArgs(commandArgs);
300
+ const args = makeHostedArgs(commandArgs);
301
301
  const target = args.positional[0];
302
302
  const envName = args.value("--env", "");
303
303
  if (!target) throw new CliError("Usage: preman link <mcp-url-or-id> --env <name> [--token pm_hmcp_…]");
@@ -343,7 +343,7 @@ export async function linkCommand(commandArgs) {
343
343
  }
344
344
 
345
345
  export async function toolsCommand(commandArgs) {
346
- const args = makeArgs(commandArgs);
346
+ const args = makeHostedArgs(commandArgs);
347
347
  const config = readCliConfig();
348
348
  const profile = resolveProfile(config, args.value("--env", ""));
349
349
  const timeoutMs = Number(args.value("--timeout", "")) * 1000 || DEFAULT_TIMEOUT_MS;
@@ -368,7 +368,7 @@ export async function toolsCommand(commandArgs) {
368
368
  }
369
369
 
370
370
  export async function runCommand(commandArgs) {
371
- const args = makeArgs(commandArgs);
371
+ const args = makeHostedArgs(commandArgs);
372
372
  const selection = args.positional[0];
373
373
  if (!selection) {
374
374
  throw new CliError(
package/bin/progress.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * CI, and cursor-movement escapes in a log file are worse than no animation.
11
11
  */
12
12
 
13
+ import { truncate } from "./shared.js";
14
+
13
15
  const FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
14
16
  const FRAME_MS = 80;
15
17
 
@@ -23,11 +25,6 @@ const YELLOW = "\u001b[33m";
23
25
  const DIM = "\u001b[2m";
24
26
  const RESET = "\u001b[0m";
25
27
 
26
- function truncate(text, width) {
27
- const value = String(text ?? "");
28
- return value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}\u2026`;
29
- }
30
-
31
28
  export function createReporter({ stream = process.stdout, live } = {}) {
32
29
  const isLive = live ?? Boolean(stream.isTTY);
33
30
  const slots = new Map();
package/bin/runner.js CHANGED
@@ -38,14 +38,7 @@ import os from "node:os";
38
38
  import path from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
40
 
41
- import {
42
- CREDENTIALS_DIR,
43
- backendUrl,
44
- callBackendJson,
45
- cliInvocation,
46
- makeArgs,
47
- resolveApiKey,
48
- } from "./shared.js";
41
+ import { CREDENTIALS_DIR, backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, onPath, packageVersion, resolveApiKey } from "./shared.js";
49
42
 
50
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
44
 
@@ -137,15 +130,6 @@ export function deviceId(projectPath) {
137
130
  return `cli-${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
138
131
  }
139
132
 
140
- export function packageVersion() {
141
- try {
142
- const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
143
- return String(pkg.version || "0.0.0");
144
- } catch {
145
- return "0.0.0";
146
- }
147
- }
148
-
149
133
  function readPid() {
150
134
  try {
151
135
  const pid = Number(readFileSync(RUNNER_PID_FILE, "utf8").trim());
@@ -208,7 +192,7 @@ export async function registerRunner(args, { agent, projectPath, label = "", api
208
192
  project_path: project,
209
193
  project_name: path.basename(project),
210
194
  platform: `${process.platform}-${process.arch}`,
211
- app_version: packageVersion(),
195
+ app_version: packageVersion() || undefined,
212
196
  capabilities: RUNNER_CAPABILITIES,
213
197
  },
214
198
  }
@@ -250,7 +234,7 @@ export function agentCommand(agent, prompt, { fullAccess = false } = {}) {
250
234
  return { bin: "claude", args, stream: "claude-json" };
251
235
  }
252
236
  if (agent === "cursor") {
253
- return { bin: "cursor-agent", args: ["-p", prompt], stream: "text" };
237
+ return { bin: "cursor-agent", args: ["--approve-mcps", "-p", prompt], stream: "text" };
254
238
  }
255
239
  if (agent === "codex") {
256
240
  const args = ["exec"];
@@ -261,13 +245,6 @@ export function agentCommand(agent, prompt, { fullAccess = false } = {}) {
261
245
  return null;
262
246
  }
263
247
 
264
- export function onPath(binary) {
265
- const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
266
- stdio: "ignore",
267
- });
268
- return probe.status === 0;
269
- }
270
-
271
248
  function sanitizeId(value, cap) {
272
249
  return String(value || "")
273
250
  .replace(/[^A-Za-z0-9_.:-]/g, "_")
@@ -406,10 +383,6 @@ function childEnv() {
406
383
 
407
384
  // ── Job execution ───────────────────────────────────────────────────────
408
385
 
409
- function nowMs() {
410
- return Number(process.hrtime.bigint() / 1000000n);
411
- }
412
-
413
386
  /** POST a runner callback. Never throws — a lost callback is retried or fenced. */
414
387
  async function callback(args, state, route, body, log) {
415
388
  try {
package/bin/shared.js CHANGED
@@ -119,6 +119,30 @@ export function cliInvocation() {
119
119
  pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
120
120
  }
121
121
 
122
+ /**
123
+ * Is this binary on PATH?
124
+ *
125
+ * Lived in three files at once, which is two more places for "installed" to mean
126
+ * something slightly different than it does everywhere else.
127
+ */
128
+ export function onPath(binary) {
129
+ const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
130
+ stdio: "ignore",
131
+ });
132
+ return probe.status === 0;
133
+ }
134
+
135
+ /** Monotonic milliseconds, for durations that must not follow the wall clock. */
136
+ export function nowMs() {
137
+ return Number(process.hrtime.bigint() / 1000000n);
138
+ }
139
+
140
+ /** Clip to `width`, ellipsis included in the count. */
141
+ export function truncate(value, width) {
142
+ const text = String(value ?? "");
143
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
144
+ }
145
+
122
146
  /** The version of the package this process is running from, or "". */
123
147
  export function packageVersion() {
124
148
  try {
@@ -560,7 +584,7 @@ export function resolveApiKey(args) {
560
584
  export const LAUNCHER_COMMAND = "npm";
561
585
  export const LAUNCHER_ARGS = ["exec", "-y", "premanmcp@latest", "--"];
562
586
 
563
- export function buildServerConfig(args, { pairCode = "" } = {}) {
587
+ export function buildServerConfig(args, { pairCode = "", codingAgent = "" } = {}) {
564
588
  const env = {
565
589
  PREMAN_BACKEND: backendUrl(args),
566
590
  PREMAN_FRONTEND: frontendUrl(args),
@@ -568,6 +592,7 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
568
592
  const apiKey = resolveApiKey(args);
569
593
  if (apiKey) env.PREMAN_API_KEY = apiKey;
570
594
  if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
595
+ if (codingAgent) env.PREMAN_CODING_AGENT = codingAgent;
571
596
 
572
597
  return {
573
598
  command: LAUNCHER_COMMAND,
package/bin/status.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * section as possibly empty.
7
7
  */
8
8
 
9
- import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
9
+ import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey, truncate } from "./shared.js";
10
10
 
11
11
  export const STATUS_HELP = `
12
12
  Status options:
@@ -47,11 +47,6 @@ function relativeTime(iso) {
47
47
  return `${Math.round(seconds / 86400)}d ago`;
48
48
  }
49
49
 
50
- function truncate(value, width) {
51
- const text = String(value ?? "");
52
- return text.length <= width ? text : `${text.slice(0, width - 1)}…`;
53
- }
54
-
55
50
  const MAX_TARGET_WIDTH = 46;
56
51
 
57
52
  /** Column widths are measured across every row we will print, so the failing and
package/bin/tests.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * `preman tests generate|review|setup|enrich` — Collections workbench test generation.
3
+ *
4
+ * Thin HTTP wrappers over the same `/workbench/generate-tests` routes the
5
+ * dashboard uses. Distinct from `preman test <id>`, which generates + runs
6
+ * scenarios for one endpoint via `POST /mcp/call-tool`.
7
+ */
8
+
9
+ import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
10
+
11
+ export const TESTS_HELP = `
12
+ Collections tests:
13
+ preman tests generate [--workspace id] [--integration-id id] [--json-out]
14
+ Scan the connected GitHub repo and write heuristic suites on saved requests.
15
+ No LLM. Writes and ambiguous routes are flagged for review.
16
+ preman tests review [--workspace id] [--json-out]
17
+ List suites still flagged (review_status=required) with the reason.
18
+ preman tests review --approve <request_id> [--workspace id] [--json-out]
19
+ Approve one flagged suite so it can be scheduled. Does not raise
20
+ unattended_policy. DELETE stays skipped unless allow_destructive.
21
+ preman tests review --all-required [--workspace id] [--json-out]
22
+ Approve every currently flagged suite. Same gates as --approve.
23
+ preman tests setup [--list] [--workspace id] [--json-out]
24
+ Harvest path-param fixture IDs from list GETs / schema examples.
25
+ Stores them on the workspace and prints a .env snippet.
26
+ Does not invent UUIDs, create secrets, or approve suites.
27
+ --list shows missing fixtures without calling the API.
28
+ preman tests enrich [--list] [--workspace id] [--json-out]
29
+ LLM-enrich remaining review-cleared heuristic suites (cap 40).
30
+ --list shows the remaining queue without calling the model.
31
+ Skips destructive/billing unless standing policy already allows that risk.
32
+ Does not enable writes and does not publish a Guard baseline.
33
+ `;
34
+
35
+ class CliError extends Error {
36
+ constructor(message, exitCode = 1) {
37
+ super(message);
38
+ this.exitCode = exitCode;
39
+ }
40
+ }
41
+
42
+ function requireKey(args) {
43
+ const token = resolveApiKey(args);
44
+ if (!token) {
45
+ throw new CliError(
46
+ `No PreMan API key. Run \`${cliInvocation()} login\` or pass --api-key pm_live_...`,
47
+ 2,
48
+ );
49
+ }
50
+ return token;
51
+ }
52
+
53
+ function workspaceHeaders(args) {
54
+ return { "x-workspace-id": args.value("--workspace", "") };
55
+ }
56
+
57
+ function wantsJson(args) {
58
+ return args.has("--json-out") || args.has("--json");
59
+ }
60
+
61
+ function printJson(value) {
62
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
63
+ }
64
+
65
+ function errorDetail(result) {
66
+ const detail = result.detail;
67
+ if (typeof detail === "string") return detail;
68
+ if (Array.isArray(detail)) {
69
+ return detail.map((item) => item.msg || JSON.stringify(item)).join("; ");
70
+ }
71
+ if (detail && typeof detail === "object") {
72
+ return detail.message || detail.error || JSON.stringify(detail);
73
+ }
74
+ return result.message || result.raw || "backend error";
75
+ }
76
+
77
+ async function workbench(args, method, routePath, { json } = {}) {
78
+ const token = requireKey(args);
79
+ const result = await callBackendJson(args, method, routePath, {
80
+ token,
81
+ json,
82
+ headers: workspaceHeaders(args),
83
+ });
84
+ if (!result.ok) {
85
+ throw new CliError(
86
+ `${method} ${routePath} failed: ${result.status_code} ${errorDetail(result)}`,
87
+ );
88
+ }
89
+ const { status_code, ...payload } = result;
90
+ return payload;
91
+ }
92
+
93
+ function flagLine(item) {
94
+ const method = item.method || "";
95
+ const url = item.url || item.name || "";
96
+ const reason = item.reason_label || item.reason || "Needs review";
97
+ const id = item.request_id || "";
98
+ return ` ${id} ${method} ${url} ${reason}`;
99
+ }
100
+
101
+ export function printGenerateSummary(payload) {
102
+ const generated = payload.suites_generated || 0;
103
+ const repo = payload.repo_url || "the connected repository";
104
+ process.stdout.write(`Generated ${generated} suite(s) from ${repo}\n`);
105
+ process.stdout.write(
106
+ ` scanned ${payload.scanned || 0} matched ${payload.matched || 0} unmatched ${payload.unmatched || 0}\n`,
107
+ );
108
+ process.stdout.write(
109
+ ` auto-approved ${payload.auto_approved || 0} flagged ${payload.flagged_for_review || 0}` +
110
+ ` waiting for base URL ${payload.waiting_for_base_url || 0}\n`,
111
+ );
112
+ if (payload.bodies_filled_from_schema) {
113
+ process.stdout.write(` bodies filled from schema ${payload.bodies_filled_from_schema}\n`);
114
+ }
115
+ const flagged = payload.flagged || [];
116
+ if (flagged.length) {
117
+ process.stdout.write(`\n${flagged.length} flagged for review:\n`);
118
+ for (const item of flagged) process.stdout.write(`${flagLine(item)}\n`);
119
+ process.stdout.write(
120
+ `\nNext: \`${cliInvocation()} tests review --approve <request_id>\` enables a read-only schedule only.\n`,
121
+ );
122
+ } else if ((payload.flagged_for_review || 0) === 0 && generated) {
123
+ process.stdout.write(
124
+ `\nNo suites flagged. Optional: \`${cliInvocation()} tests enrich\` to add LLM cases to remaining heuristic suites.\n`,
125
+ );
126
+ }
127
+ const samples = payload.unmatched_samples || [];
128
+ if (samples.length) {
129
+ process.stdout.write(`\nUnmatched repo routes (not imported):\n`);
130
+ for (const sample of samples.slice(0, 10)) process.stdout.write(` ${sample}\n`);
131
+ }
132
+ }
133
+
134
+ export function printReviewList(payload) {
135
+ const items = payload.items || [];
136
+ const total = payload.total ?? items.length;
137
+ process.stdout.write(`${total} suite(s) flagged for review\n`);
138
+ for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
139
+ if (total) {
140
+ process.stdout.write(
141
+ `\nApprove one: \`${cliInvocation()} tests review --approve <request_id>\`\n` +
142
+ `Approve enables a read-only schedule only. DELETE stays skipped unless allow_destructive.\n`,
143
+ );
144
+ }
145
+ }
146
+
147
+ export function printApproveResult(payload) {
148
+ const id = payload.request_id || "";
149
+ const status = payload.review_status || "approved";
150
+ const enabled = payload.enabled ? "enabled" : "disabled";
151
+ process.stdout.write(`Approved ${id} ${status} ${enabled}\n`);
152
+ process.stdout.write(
153
+ "Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
154
+ );
155
+ }
156
+
157
+ export function printApproveAll(results) {
158
+ process.stdout.write(`Approved ${results.length} flagged suite(s)\n`);
159
+ for (const payload of results) {
160
+ const enabled = payload.enabled ? "enabled" : "disabled";
161
+ process.stdout.write(` ${payload.request_id || ""} ${payload.review_status || "approved"} ${enabled}\n`);
162
+ }
163
+ if (results.length) {
164
+ process.stdout.write(
165
+ "Schedule is enabled. unattended_policy is unchanged; DELETE still skips unless allow_destructive.\n",
166
+ );
167
+ }
168
+ }
169
+
170
+ export function printEnrichList(payload) {
171
+ const items = payload.items || [];
172
+ const total = payload.total ?? items.length;
173
+ process.stdout.write(`${total} suite(s) remaining for agent enrichment\n`);
174
+ for (const item of items) process.stdout.write(`${flagLine(item)}\n`);
175
+ if (total) {
176
+ process.stdout.write(
177
+ `\nRun \`${cliInvocation()} tests enrich\` to append LLM cases (cap 40). Does not enable writes.\n`,
178
+ );
179
+ }
180
+ }
181
+
182
+ export function printSetupList(payload) {
183
+ const items = (payload.items || []).filter((item) => !item.value_set);
184
+ const total = payload.total ?? items.length;
185
+ process.stdout.write(`${total} path-param fixture(s) still missing\n`);
186
+ for (const item of payload.items || []) {
187
+ const state = item.value_set ? "set" : "missing";
188
+ process.stdout.write(
189
+ ` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""} ${state}\n`,
190
+ );
191
+ }
192
+ if (payload.dotenv) {
193
+ process.stdout.write(`\nCurrent .env:\n${payload.dotenv}`);
194
+ }
195
+ if (total) {
196
+ process.stdout.write(
197
+ `\nRun \`${cliInvocation()} tests setup\` to harvest ids from list GETs.\n`,
198
+ );
199
+ }
200
+ }
201
+
202
+ export function printSetupSummary(payload) {
203
+ process.stdout.write(
204
+ `Stored ${payload.stored || 0} fixture(s). Missing ${payload.missing || 0}.\n`,
205
+ );
206
+ process.stdout.write(
207
+ ` harvested ${payload.harvested || 0}` +
208
+ ` list GETs ${payload.list_gets_called || 0}` +
209
+ ` from lists ${payload.from_list_get || 0}` +
210
+ ` from schema ${payload.from_schema || 0}\n`,
211
+ );
212
+ if (payload.dotenv) {
213
+ process.stdout.write(`\n.env\n${payload.dotenv}`);
214
+ }
215
+ const missing = payload.missing_items || [];
216
+ if (missing.length) {
217
+ process.stdout.write(`\nStill missing:\n`);
218
+ for (const item of missing) {
219
+ process.stdout.write(` ${item.name || item.param || ""} ${item.method || ""} ${item.url || ""}\n`);
220
+ }
221
+ }
222
+ }
223
+
224
+ export function printEnrichSummary(payload) {
225
+ process.stdout.write(
226
+ `Enriched ${payload.enriched || 0} suite(s). Remaining ${payload.remaining || 0}.\n`,
227
+ );
228
+ process.stdout.write(
229
+ ` skipped sensitive ${payload.skipped_sensitive || 0}` +
230
+ ` pending review ${payload.skipped_pending_review || 0}` +
231
+ ` already enriched ${payload.skipped_already_enriched || 0}` +
232
+ ` no suite ${payload.skipped_no_suite || 0}` +
233
+ ` failed ${payload.failed || 0}\n`,
234
+ );
235
+ const remaining = payload.remaining_items || [];
236
+ if (remaining.length) {
237
+ process.stdout.write(`\nStill remaining:\n`);
238
+ for (const item of remaining) process.stdout.write(`${flagLine(item)}\n`);
239
+ }
240
+ }
241
+
242
+ function requestIdFlag(args, flag) {
243
+ const value = args.value(flag, "");
244
+ if (!value || value.startsWith("-")) {
245
+ throw new CliError(`pass a request id after ${flag}${TESTS_HELP}`, 2);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ async function generateCommand(args) {
251
+ const integrationId = args.value("--integration-id", "");
252
+ const json = {};
253
+ if (integrationId && !integrationId.startsWith("-")) json.integration_id = integrationId;
254
+ const payload = await workbench(args, "POST", "/workbench/generate-tests", { json });
255
+ if (wantsJson(args)) return printJson(payload);
256
+ printGenerateSummary(payload);
257
+ return payload;
258
+ }
259
+
260
+ async function reviewCommand(args) {
261
+ if (args.has("--approve") && args.has("--all-required")) {
262
+ throw new CliError("use either --approve <request_id> or --all-required, not both", 2);
263
+ }
264
+
265
+ if (args.has("--approve")) {
266
+ const requestId = encodeURIComponent(requestIdFlag(args, "--approve"));
267
+ const payload = await workbench(
268
+ args,
269
+ "POST",
270
+ `/workbench/generate-tests/review/${requestId}/approve`,
271
+ );
272
+ if (wantsJson(args)) return printJson(payload);
273
+ printApproveResult(payload);
274
+ return payload;
275
+ }
276
+
277
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/review");
278
+ if (args.has("--all-required")) {
279
+ const items = listed.items || [];
280
+ const results = [];
281
+ for (const item of items) {
282
+ const requestId = encodeURIComponent(item.request_id || "");
283
+ if (!requestId) continue;
284
+ results.push(
285
+ await workbench(args, "POST", `/workbench/generate-tests/review/${requestId}/approve`),
286
+ );
287
+ }
288
+ if (wantsJson(args)) return printJson({ approved: results, listed });
289
+ if (!results.length) {
290
+ process.stdout.write("0 suite(s) flagged for review\n");
291
+ return { approved: results, listed };
292
+ }
293
+ printApproveAll(results);
294
+ return { approved: results, listed };
295
+ }
296
+
297
+ if (wantsJson(args)) return printJson(listed);
298
+ printReviewList(listed);
299
+ return listed;
300
+ }
301
+
302
+ async function setupCommand(args) {
303
+ if (args.has("--list")) {
304
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/fixtures");
305
+ if (wantsJson(args)) return printJson(listed);
306
+ printSetupList(listed);
307
+ return listed;
308
+ }
309
+ const payload = await workbench(args, "POST", "/workbench/generate-tests/fixtures");
310
+ if (wantsJson(args)) return printJson(payload);
311
+ printSetupSummary(payload);
312
+ return payload;
313
+ }
314
+
315
+ async function enrichCommand(args) {
316
+ if (args.has("--list")) {
317
+ const listed = await workbench(args, "GET", "/workbench/generate-tests/enrich");
318
+ if (wantsJson(args)) return printJson(listed);
319
+ printEnrichList(listed);
320
+ return listed;
321
+ }
322
+ const payload = await workbench(args, "POST", "/workbench/generate-tests/enrich");
323
+ if (wantsJson(args)) return printJson(payload);
324
+ printEnrichSummary(payload);
325
+ return payload;
326
+ }
327
+
328
+ export async function testsCommand(commandArgs = []) {
329
+ const sub = commandArgs[0] && !commandArgs[0].startsWith("-") ? commandArgs[0] : "";
330
+ const rest = sub ? commandArgs.slice(1) : commandArgs;
331
+ const args = makeArgs(rest);
332
+
333
+ if (!sub || sub === "help") {
334
+ throw new CliError(`pass generate, review, setup, or enrich${TESTS_HELP}`, 2);
335
+ }
336
+ if (sub === "generate") return generateCommand(args);
337
+ if (sub === "review") return reviewCommand(args);
338
+ if (sub === "setup") return setupCommand(args);
339
+ if (sub === "enrich") return enrichCommand(args);
340
+ throw new CliError(`unknown tests subcommand: ${sub}${TESTS_HELP}`, 2);
341
+ }