codex-plugin-doctor 1.76.0 → 1.78.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.
Files changed (38) hide show
  1. package/README.md +21 -2
  2. package/dist/core/init-ci.d.ts +1 -0
  3. package/dist/core/init-ci.js +16 -2
  4. package/dist/core/mcp-prompt-catalog.d.ts +36 -0
  5. package/dist/core/mcp-prompt-catalog.js +53 -0
  6. package/dist/core/mcp-prompt-definition.d.ts +24 -0
  7. package/dist/core/mcp-prompt-definition.js +107 -0
  8. package/dist/core/mcp-prompt-diff.d.ts +43 -0
  9. package/dist/core/mcp-prompt-diff.js +132 -0
  10. package/dist/core/mcp-prompt-file.d.ts +31 -0
  11. package/dist/core/mcp-prompt-file.js +118 -0
  12. package/dist/core/mcp-prompt-impact.d.ts +21 -0
  13. package/dist/core/mcp-prompt-impact.js +93 -0
  14. package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
  15. package/dist/core/mcp-stdio-tool-catalog.js +74 -55
  16. package/dist/core/mcp-tool-catalog.d.ts +44 -5
  17. package/dist/core/mcp-tool-catalog.js +76 -31
  18. package/dist/core/mcp-tool-diff.d.ts +1 -0
  19. package/dist/core/mcp-tool-diff.js +1 -1
  20. package/dist/core/mcp-tool-file.d.ts +20 -0
  21. package/dist/core/mcp-tool-file.js +1 -1
  22. package/dist/core/mcp-tool-response-file.d.ts +6 -4
  23. package/dist/core/mcp-tool-response-file.js +8 -8
  24. package/dist/core/output-contract.js +177 -0
  25. package/dist/reporting/quote-server-text.d.ts +1 -0
  26. package/dist/reporting/quote-server-text.js +13 -0
  27. package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
  28. package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
  29. package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
  30. package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
  31. package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
  32. package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
  33. package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
  34. package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
  35. package/dist/reporting/render-mcp-tool-diff-markdown.js +3 -16
  36. package/dist/run-cli.d.ts +7 -0
  37. package/dist/run-cli.js +223 -150
  38. package/package.json +1 -1
package/dist/run-cli.js CHANGED
@@ -25,13 +25,20 @@ import { isValidBearerToken } from "./core/bounded-http-client.js";
25
25
  import { discoverMcpServer } from "./core/mcp-discovery.js";
26
26
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
27
27
  import { captureStdioMcpToolCatalog, resolveMcpStdioServer } from "./core/mcp-stdio-tool-catalog.js";
28
+ import { captureMcpPromptCatalog, captureStdioMcpPromptCatalog } from "./core/mcp-prompt-catalog.js";
29
+ import { compareMcpPromptFiles } from "./core/mcp-prompt-diff.js";
30
+ import { inspectMcpPromptFile } from "./core/mcp-prompt-file.js";
28
31
  import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
29
- import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
32
+ import { saveMcpListResponse } from "./core/mcp-tool-response-file.js";
30
33
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
31
34
  import { renderMcpToolDiffMarkdown } from "./reporting/render-mcp-tool-diff-markdown.js";
32
35
  import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
33
36
  import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
34
37
  import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
38
+ import { renderMcpPromptCatalogReport } from "./reporting/render-mcp-prompt-catalog-report.js";
39
+ import { renderMcpPromptDiffMarkdown } from "./reporting/render-mcp-prompt-diff-markdown.js";
40
+ import { renderMcpPromptDiffReport } from "./reporting/render-mcp-prompt-diff-report.js";
41
+ import { renderMcpPromptFileReport } from "./reporting/render-mcp-prompt-file-report.js";
35
42
  import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
36
43
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
37
44
  import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
@@ -113,15 +120,20 @@ class CliUsageError extends Error {
113
120
  }
114
121
  const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking] [--show-names]";
115
122
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
123
+ const promptsDiffUsage = "Usage: codex-plugin-doctor doctor prompts-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking] [--show-names]";
124
+ const promptsFileUsage = "Usage: codex-plugin-doctor doctor prompts-file <path> [--json]";
116
125
  const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker [--env <NAME>]...] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
126
+ const promptsUsage = "Usage: codex-plugin-doctor doctor prompts <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor prompts <path> --server <name> --runtime [--sandbox docker [--env <NAME>]...] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
127
+ const TOOL_CATALOG_COMMAND = { name: "tools", noun: "Tool", method: "tools/list", usage: toolsUsage };
128
+ const PROMPT_CATALOG_COMMAND = { name: "prompts", noun: "Prompt", method: "prompts/list", usage: promptsUsage };
117
129
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
118
130
  const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
119
131
  const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
120
- /** Reads init-ci --tools-diff-server, --baseline, and --sandbox; options is null when none is given. */
132
+ /** Reads init-ci --tools-diff-server, --baseline, --prompts-baseline, and --sandbox; options is null when none is given. */
121
133
  function parseToolsDiffWorkflowOptions(flags) {
122
134
  const values = new Map();
123
135
  for (let index = 0; index < flags.length; index += 1) {
124
- if (!["--tools-diff-server", "--baseline", "--sandbox"].includes(flags[index]))
136
+ if (!["--tools-diff-server", "--baseline", "--prompts-baseline", "--sandbox"].includes(flags[index]))
125
137
  continue;
126
138
  const value = flags[index + 1];
127
139
  const present = value !== undefined && !value.startsWith("--");
@@ -132,7 +144,7 @@ function parseToolsDiffWorkflowOptions(flags) {
132
144
  if (values.size === 0)
133
145
  return { options: null };
134
146
  if (!values.has("--tools-diff-server"))
135
- return { error: "--baseline and --sandbox require --tools-diff-server." };
147
+ return { error: "--baseline, --prompts-baseline, and --sandbox require --tools-diff-server." };
136
148
  // Control characters would break the generated workflow lines, GitHub evaluates "${{" in any
137
149
  // workflow value, and the Action rejects values that start with "-".
138
150
  const usable = (value) => value !== undefined && value.trim() !== "" && !value.startsWith("-") && !value.includes("${{")
@@ -140,16 +152,23 @@ function parseToolsDiffWorkflowOptions(flags) {
140
152
  const server = values.get("--tools-diff-server");
141
153
  if (!usable(server))
142
154
  return { error: "--tools-diff-server requires a server name." };
143
- // The workflow runs on Linux from the repository root, so the baseline is a relative POSIX path.
155
+ // The workflow runs on Linux from the repository root, so baselines are relative POSIX paths.
156
+ const relative = (value) => usable(value) && !path.posix.isAbsolute(value)
157
+ && !/^[A-Za-z]:/u.test(value) && !path.posix.normalize(value).split("/").includes("..");
144
158
  const baseline = (values.has("--baseline") ? values.get("--baseline") : "mcp/tools-baseline.json")?.replaceAll("\\", "/");
145
- if (!usable(baseline) || path.posix.isAbsolute(baseline) || /^[A-Za-z]:/u.test(baseline)
146
- || path.posix.normalize(baseline).split("/").includes("..")) {
159
+ if (!relative(baseline))
147
160
  return { error: "--baseline requires a relative path inside the repository." };
161
+ const promptsBaseline = values.has("--prompts-baseline") ? values.get("--prompts-baseline")?.replaceAll("\\", "/") : null;
162
+ if (promptsBaseline !== null && !relative(promptsBaseline)) {
163
+ return { error: "--prompts-baseline requires a relative path inside the repository." };
164
+ }
165
+ if (promptsBaseline !== null && path.posix.normalize(promptsBaseline) === path.posix.normalize(baseline)) {
166
+ return { error: "--prompts-baseline must differ from --baseline." };
148
167
  }
149
168
  const sandbox = values.get("--sandbox");
150
169
  if (values.has("--sandbox") && sandbox !== "docker")
151
170
  return { error: "--sandbox must be docker." };
152
- return { options: { server, baseline, sandbox: sandbox === "docker" ? "docker" : null } };
171
+ return { options: { server, baseline, sandbox: sandbox === "docker" ? "docker" : null, promptsBaseline } };
153
172
  }
154
173
  const MAX_FORWARDED_ENVIRONMENT_VARIABLES = 32;
155
174
  const forwardedEnvironmentHelp = "--env forwards the named host environment variable into the Docker sandbox by name; Doctor never prints its value, writes it to a report, or passes it as a command argument. Native servers already inherit Doctor's environment.";
@@ -165,33 +184,90 @@ function resolveBearerToken(name, env) {
165
184
  return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
166
185
  return { token };
167
186
  }
168
- async function finishToolCatalogCommand(io, snapshot, json, savePath) {
169
- const report = snapshot.report;
170
- io.writeStdout(json
171
- ? JSON.stringify(report, null, 2)
172
- : renderMcpToolCatalogReport(report));
173
- const exitCode = report.status === "pass" ? 0
174
- : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
187
+ // Writes the catalog report and, when asked, saves a complete enumeration as one list response.
188
+ async function finishCatalogCommand(io, kind, status, output, items, cache, savePath) {
189
+ io.writeStdout(output);
190
+ const exitCode = status === "pass" ? 0
191
+ : status === "incomplete" || status === "blocked" ? 2 : 1;
175
192
  if (savePath === undefined)
176
193
  return exitCode;
177
- if (snapshot.tools === null || snapshot.cache === null) {
178
- io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
194
+ if (items === null || cache === null) {
195
+ io.writeStderr(`${kind.noun} catalog response was not saved because enumeration did not complete.`);
179
196
  return 2;
180
197
  }
181
- const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
198
+ const saved = await saveMcpListResponse(savePath, kind.name, items, cache);
182
199
  if (saved.kind !== "saved") {
183
200
  io.writeStderr({
184
- "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
185
- "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
186
- "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
187
- "write-failed": "Tool catalog response could not be written."
201
+ "path-blocked": `${kind.noun} catalog response was not saved because the path is not a local file path.`,
202
+ "not-regular": `${kind.noun} catalog response was not saved because the existing target is not a regular file.`,
203
+ "too-large": `${kind.noun} catalog response was not saved because it exceeds the one MiB ${kind.name}-file limit.`,
204
+ "write-failed": `${kind.noun} catalog response could not be written.`
188
205
  }[saved.kind]);
189
206
  return 2;
190
207
  }
191
- io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
208
+ io.writeStderr(`Saved a complete ${kind.method} response with ${saved.count} ${kind.name} (${saved.bytes} bytes).`);
192
209
  return exitCode;
193
210
  }
194
- async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
211
+ function finishToolCatalogCommand(io, snapshot, json, savePath) {
212
+ const report = snapshot.report;
213
+ return finishCatalogCommand(io, TOOL_CATALOG_COMMAND, report.status, json ? JSON.stringify(report, null, 2) : renderMcpToolCatalogReport(report), snapshot.tools, snapshot.cache, savePath);
214
+ }
215
+ function finishPromptCatalogCommand(io, snapshot, json, savePath) {
216
+ const report = snapshot.report;
217
+ return finishCatalogCommand(io, PROMPT_CATALOG_COMMAND, report.status, json ? JSON.stringify(report, null, 2) : renderMcpPromptCatalogReport(report), snapshot.prompts, snapshot.cache, savePath);
218
+ }
219
+ async function runHttpCatalogCommand(url, flags, io, env, kind, run) {
220
+ const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
221
+ const switches = [];
222
+ let savePath;
223
+ let tokenEnv;
224
+ let invalid = false;
225
+ for (let index = 0; index < flags.length; index += 1) {
226
+ const flag = flags[index];
227
+ if ((flag === "--save-response" && savePath === undefined) || (flag === "--bearer-token-env" && tokenEnv === undefined)) {
228
+ const value = flags[index + 1];
229
+ if (!value || value.startsWith("-")) {
230
+ invalid = true;
231
+ break;
232
+ }
233
+ if (flag === "--save-response")
234
+ savePath = value;
235
+ else
236
+ tokenEnv = value;
237
+ index += 1;
238
+ }
239
+ else if (allowedFlags.has(flag) && !switches.includes(flag)) {
240
+ switches.push(flag);
241
+ }
242
+ else {
243
+ invalid = true;
244
+ break;
245
+ }
246
+ }
247
+ if (invalid) {
248
+ io.writeStderr(`Invalid or duplicate ${kind.noun.toLowerCase()} catalog arguments. ${kind.usage}`);
249
+ return 2;
250
+ }
251
+ if (!switches.includes("--allow-network")) {
252
+ io.writeStderr(`doctor ${kind.name} requires explicit --allow-network consent.`);
253
+ return 2;
254
+ }
255
+ if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
256
+ io.writeStderr(`doctor ${kind.name} --save-response requires a local file path.`);
257
+ return 2;
258
+ }
259
+ const bearer = resolveBearerToken(tokenEnv, env);
260
+ if ("error" in bearer) {
261
+ io.writeStderr(bearer.error);
262
+ return 2;
263
+ }
264
+ return run(url, {
265
+ allowNetwork: true,
266
+ allowLocalNetwork: switches.includes("--allow-local-network"),
267
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
268
+ }, switches.includes("--json"), savePath);
269
+ }
270
+ async function runStdioCatalogCommand(targetPath, flags, io, env, kind, run) {
195
271
  const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
196
272
  const switches = [];
197
273
  const values = new Map();
@@ -231,11 +307,11 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
231
307
  const approvalDigest = values.get("--runtime-approval-digest");
232
308
  const requireApproval = switches.includes("--require-runtime-approval");
233
309
  if (invalid || serverName === undefined || (sandbox !== undefined && sandbox !== "docker")) {
234
- io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
310
+ io.writeStderr(`Invalid or duplicate ${kind.noun.toLowerCase()} catalog arguments. ${kind.usage}`);
235
311
  return 2;
236
312
  }
237
313
  if (!switches.includes("--runtime")) {
238
- io.writeStderr("doctor tools --server starts a local MCP server and requires explicit --runtime consent.");
314
+ io.writeStderr(`doctor ${kind.name} --server starts a local MCP server and requires explicit --runtime consent.`);
239
315
  return 2;
240
316
  }
241
317
  if (approvalDigest !== undefined && !requireApproval) {
@@ -262,14 +338,14 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
262
338
  }
263
339
  }
264
340
  if (isBlockedOfflinePath(targetPath)) {
265
- io.writeStderr("doctor tools --server requires a local package path.");
341
+ io.writeStderr(`doctor ${kind.name} --server requires a local package path.`);
266
342
  return 2;
267
343
  }
268
344
  if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
269
- io.writeStderr("doctor tools --save-response requires a local file path.");
345
+ io.writeStderr(`doctor ${kind.name} --save-response requires a local file path.`);
270
346
  return 2;
271
347
  }
272
- const resolved = await resolveMcpStdioServer(targetPath, serverName);
348
+ const resolved = await resolveMcpStdioServer(targetPath, serverName, kind.name);
273
349
  if (!resolved.ok) {
274
350
  io.writeStderr(resolved.message);
275
351
  return 2;
@@ -290,8 +366,74 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
290
366
  return 1;
291
367
  }
292
368
  }
293
- const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox !== "docker" ? {} : environment.length > 0 ? { sandbox: "docker", environment } : { sandbox: "docker" });
294
- return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
369
+ return run(resolved.server, sandbox !== "docker" ? {} : environment.length > 0 ? { sandbox: "docker", environment } : { sandbox: "docker" }, switches.includes("--json"), savePath);
370
+ }
371
+ // Runs tools-diff or prompts-diff, which share their options and exit codes.
372
+ async function runOfflineDiffCommand(args, io, usage, compare, render) {
373
+ let beforePath;
374
+ let afterPath;
375
+ let format;
376
+ let failOn;
377
+ let showNames = false;
378
+ let invalid = false;
379
+ for (let index = 0; index < args.length; index += 1) {
380
+ const flag = args[index];
381
+ if (flag === "--show-names" && !showNames) {
382
+ showNames = true;
383
+ }
384
+ else if ((flag === "--json" || flag === "--markdown") && format === undefined) {
385
+ format = flag === "--json" ? "json" : "markdown";
386
+ }
387
+ else if (flag === "--fail-on" && failOn === undefined) {
388
+ const value = args[index + 1];
389
+ if (value !== "any" && value !== "breaking") {
390
+ invalid = true;
391
+ break;
392
+ }
393
+ failOn = value;
394
+ index += 1;
395
+ }
396
+ else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
397
+ const value = args[index + 1];
398
+ if (!value || value.startsWith("-")) {
399
+ invalid = true;
400
+ break;
401
+ }
402
+ if (flag === "--before")
403
+ beforePath = value;
404
+ else
405
+ afterPath = value;
406
+ index += 1;
407
+ }
408
+ else {
409
+ invalid = true;
410
+ break;
411
+ }
412
+ }
413
+ if (invalid || !beforePath || !afterPath) {
414
+ io.writeStderr(`Invalid comparison arguments. ${usage}`);
415
+ return 2;
416
+ }
417
+ const report = await compare(beforePath, afterPath, showNames);
418
+ io.writeStdout(format === "json" ? JSON.stringify(report, null, 2) : render(report, format === "markdown"));
419
+ if (report.status !== "pass" && report.status !== "warn")
420
+ return 2;
421
+ if (failOn === "breaking") {
422
+ return (report.comparison.breaking ?? 0) + (report.comparison.unclassified ?? 0) > 0 ? 1 : 0;
423
+ }
424
+ return report.status === "pass" ? 0 : 1;
425
+ }
426
+ // Runs tools-file or prompts-file, which share their options and exit codes.
427
+ async function runOfflineFileCommand(args, io, usage, inspect, render) {
428
+ const [filePath, ...flags] = args;
429
+ if (!filePath || filePath.startsWith("-") || flags.some((flag) => flag !== "--json") || flags.length > 1) {
430
+ io.writeStderr(`Invalid file inspection arguments. ${usage}`);
431
+ return 2;
432
+ }
433
+ const report = await inspect(filePath);
434
+ io.writeStdout(flags.includes("--json") ? JSON.stringify(report, null, 2) : render(report));
435
+ return report.status === "pass" ? 0
436
+ : report.status === "warn" || report.status === "fail" ? 1 : 2;
295
437
  }
296
438
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
297
439
  const index = flags.indexOf("--sandbox");
@@ -374,11 +516,14 @@ function parseRemoteNetworkFlags(flags, runtime) {
374
516
  return { allowNetwork, allowLocalNetwork, allowSessionLifecycle, requireRemoteReliability };
375
517
  }
376
518
  function printUsage(io) {
377
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path] [--tools-diff-server <name> [--baseline <path>] [--sandbox docker]]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
519
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path] [--tools-diff-server <name> [--baseline <path>] [--prompts-baseline <path>] [--sandbox docker]]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
378
520
  io.writeStderr(discoveryUsage);
379
521
  io.writeStderr(toolsUsage);
522
+ io.writeStderr(promptsUsage);
380
523
  io.writeStderr(toolsFileUsage);
381
524
  io.writeStderr(toolsDiffUsage);
525
+ io.writeStderr(promptsFileUsage);
526
+ io.writeStderr(promptsDiffUsage);
382
527
  io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
383
528
  + " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
384
529
  + "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
@@ -1342,10 +1487,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
1342
1487
  (command === "doctor" &&
1343
1488
  (["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
1344
1489
  (maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
1345
- (maybePath === "tools" && remainingArgs.includes("--server")))) ||
1490
+ ((maybePath === "tools" || maybePath === "prompts") && remainingArgs.includes("--server")))) ||
1346
1491
  (command === "init-ci" && args.includes("--tools-diff-server"));
1347
1492
  if (args.includes("--sandbox") && !runtimeSandboxSupported) {
1348
- io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, doctor tools --server, and init-ci --tools-diff-server.");
1493
+ io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, doctor tools --server, doctor prompts --server, and init-ci --tools-diff-server.");
1349
1494
  return 2;
1350
1495
  }
1351
1496
  if (command === "--version" || command === "-v" || command === "version") {
@@ -1397,78 +1542,35 @@ export async function runCli(args, io = defaultIo, options = {}) {
1397
1542
  --show-names adds tool names and the JSON Pointer of each reason; reports omit both by default, so keep it out of public output such as pull request comments.`);
1398
1543
  return 0;
1399
1544
  }
1400
- let beforePath;
1401
- let afterPath;
1402
- let format;
1403
- let failOn;
1404
- let showNames = false;
1405
- let invalid = false;
1406
- for (let index = 0; index < remainingArgs.length; index += 1) {
1407
- const flag = remainingArgs[index];
1408
- if (flag === "--show-names" && !showNames) {
1409
- showNames = true;
1410
- }
1411
- else if ((flag === "--json" || flag === "--markdown") && format === undefined) {
1412
- format = flag === "--json" ? "json" : "markdown";
1413
- }
1414
- else if (flag === "--fail-on" && failOn === undefined) {
1415
- const value = remainingArgs[index + 1];
1416
- if (value !== "any" && value !== "breaking") {
1417
- invalid = true;
1418
- break;
1419
- }
1420
- failOn = value;
1421
- index += 1;
1422
- }
1423
- else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
1424
- const value = remainingArgs[index + 1];
1425
- if (!value || value.startsWith("-")) {
1426
- invalid = true;
1427
- break;
1428
- }
1429
- if (flag === "--before")
1430
- beforePath = value;
1431
- else
1432
- afterPath = value;
1433
- index += 1;
1434
- }
1435
- else {
1436
- invalid = true;
1437
- break;
1438
- }
1439
- }
1440
- if (invalid || !beforePath || !afterPath) {
1441
- io.writeStderr(`Invalid comparison arguments. ${toolsDiffUsage}`);
1442
- return 2;
1443
- }
1444
- const compare = options.compareMcpToolFilesImpl ?? compareMcpToolFiles;
1445
- const report = await (showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath));
1446
- io.writeStdout(format === "json" ? JSON.stringify(report, null, 2)
1447
- : format === "markdown" ? renderMcpToolDiffMarkdown(report)
1448
- : renderMcpToolDiffReport(report));
1449
- if (report.status !== "pass" && report.status !== "warn")
1450
- return 2;
1451
- if (failOn === "breaking") {
1452
- return (report.comparison.breaking ?? 0) + (report.comparison.unclassified ?? 0) > 0 ? 1 : 0;
1545
+ return runOfflineDiffCommand(remainingArgs, io, toolsDiffUsage, (beforePath, afterPath, showNames) => {
1546
+ const compare = options.compareMcpToolFilesImpl ?? compareMcpToolFiles;
1547
+ return showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath);
1548
+ }, (report, markdown) => markdown ? renderMcpToolDiffMarkdown(report) : renderMcpToolDiffReport(report));
1549
+ }
1550
+ if (maybePath === "prompts-diff") {
1551
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1552
+ io.writeStdout(`${promptsDiffUsage}\nCompares two saved MCP prompts/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; prompts are not retrieved and compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning).
1553
+ --show-names adds prompt names and the argument behind each reason; reports omit both by default, so keep it out of public output such as pull request comments.`);
1554
+ return 0;
1453
1555
  }
1454
- return report.status === "pass" ? 0 : 1;
1556
+ return runOfflineDiffCommand(remainingArgs, io, promptsDiffUsage, (beforePath, afterPath, showNames) => {
1557
+ const compare = options.compareMcpPromptFilesImpl ?? compareMcpPromptFiles;
1558
+ return showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath);
1559
+ }, (report, markdown) => markdown ? renderMcpPromptDiffMarkdown(report) : renderMcpPromptDiffReport(report));
1455
1560
  }
1456
1561
  if (maybePath === "tools-file") {
1457
1562
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1458
1563
  io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
1459
1564
  return 0;
1460
1565
  }
1461
- const [filePath, ...flags] = remainingArgs;
1462
- if (!filePath || filePath.startsWith("-") || flags.some((flag) => flag !== "--json") || flags.length > 1) {
1463
- io.writeStderr(`Invalid file inspection arguments. ${toolsFileUsage}`);
1464
- return 2;
1566
+ return runOfflineFileCommand(remainingArgs, io, toolsFileUsage, options.inspectMcpToolFileImpl ?? inspectMcpToolFile, renderMcpToolFileReport);
1567
+ }
1568
+ if (maybePath === "prompts-file") {
1569
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1570
+ io.writeStdout(`${promptsFileUsage}\nInspects one saved UTF-8 MCP prompts/list response. No server connection or prompt retrieval.`);
1571
+ return 0;
1465
1572
  }
1466
- const report = await (options.inspectMcpToolFileImpl ?? inspectMcpToolFile)(filePath);
1467
- io.writeStdout(flags.includes("--json")
1468
- ? JSON.stringify(report, null, 2)
1469
- : renderMcpToolFileReport(report));
1470
- return report.status === "pass" ? 0
1471
- : report.status === "warn" || report.status === "fail" ? 1 : 2;
1573
+ return runOfflineFileCommand(remainingArgs, io, promptsFileUsage, options.inspectMcpPromptFileImpl ?? inspectMcpPromptFile, renderMcpPromptFileReport);
1472
1574
  }
1473
1575
  if (maybePath === "tools") {
1474
1576
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
@@ -1481,61 +1583,29 @@ export async function runCli(args, io = defaultIo, options = {}) {
1481
1583
  return 2;
1482
1584
  }
1483
1585
  if (flags.includes("--server")) {
1484
- return runStdioToolCatalogCommand(url, flags, io, options, terminalContext.env);
1586
+ return runStdioCatalogCommand(url, flags, io, terminalContext.env, TOOL_CATALOG_COMMAND, async (server, captureOptions, json, savePath) => finishToolCatalogCommand(io, await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(server, captureOptions), json, savePath));
1485
1587
  }
1486
- const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1487
- const switches = [];
1488
- let savePath;
1489
- let tokenEnv;
1490
- let invalid = false;
1491
- for (let index = 0; index < flags.length; index += 1) {
1492
- const flag = flags[index];
1493
- if ((flag === "--save-response" && savePath === undefined) || (flag === "--bearer-token-env" && tokenEnv === undefined)) {
1494
- const value = flags[index + 1];
1495
- if (!value || value.startsWith("-")) {
1496
- invalid = true;
1497
- break;
1498
- }
1499
- if (flag === "--save-response")
1500
- savePath = value;
1501
- else
1502
- tokenEnv = value;
1503
- index += 1;
1504
- }
1505
- else if (allowedFlags.has(flag) && !switches.includes(flag)) {
1506
- switches.push(flag);
1507
- }
1508
- else {
1509
- invalid = true;
1510
- break;
1511
- }
1512
- }
1513
- if (invalid) {
1514
- io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
1515
- return 2;
1516
- }
1517
- if (!switches.includes("--allow-network")) {
1518
- io.writeStderr("doctor tools requires explicit --allow-network consent.");
1519
- return 2;
1588
+ return runHttpCatalogCommand(url, flags, io, terminalContext.env, TOOL_CATALOG_COMMAND, async (target, catalogOptions, json, savePath) => {
1589
+ const snapshot = savePath === undefined
1590
+ ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(target, catalogOptions), tools: null, cache: null }
1591
+ : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(target, catalogOptions);
1592
+ return finishToolCatalogCommand(io, snapshot, json, savePath);
1593
+ });
1594
+ }
1595
+ if (maybePath === "prompts") {
1596
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1597
+ io.writeStdout(`${promptsUsage}\nPrompt catalog structure only; prompts are not retrieved.\n--server starts the named stdio server from the package MCP configuration after explicit --runtime consent and stops it after reading the prompt list.\n${forwardedEnvironmentHelp}\n--save-response writes a complete enumeration as one prompts/list response for doctor prompts-file and prompts-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
1598
+ return 0;
1520
1599
  }
1521
- if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
1522
- io.writeStderr("doctor tools --save-response requires a local file path.");
1600
+ const [target, ...flags] = remainingArgs;
1601
+ if (!target || target.startsWith("-")) {
1602
+ io.writeStderr(promptsUsage);
1523
1603
  return 2;
1524
1604
  }
1525
- const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1526
- if ("error" in bearer) {
1527
- io.writeStderr(bearer.error);
1528
- return 2;
1605
+ if (flags.includes("--server")) {
1606
+ return runStdioCatalogCommand(target, flags, io, terminalContext.env, PROMPT_CATALOG_COMMAND, async (server, captureOptions, json, savePath) => finishPromptCatalogCommand(io, await (options.captureStdioMcpPromptCatalogImpl ?? captureStdioMcpPromptCatalog)(server, captureOptions), json, savePath));
1529
1607
  }
1530
- const catalogOptions = {
1531
- allowNetwork: true,
1532
- allowLocalNetwork: switches.includes("--allow-local-network"),
1533
- ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
1534
- };
1535
- const snapshot = savePath === undefined
1536
- ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
1537
- : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
1538
- return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
1608
+ return runHttpCatalogCommand(target, flags, io, terminalContext.env, PROMPT_CATALOG_COMMAND, async (url, catalogOptions, json, savePath) => finishPromptCatalogCommand(io, await (options.captureMcpPromptCatalogImpl ?? captureMcpPromptCatalog)(url, catalogOptions), json, savePath));
1539
1609
  }
1540
1610
  if (maybePath === "discover") {
1541
1611
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
@@ -2819,13 +2889,16 @@ export async function runCli(args, io = defaultIo, options = {}) {
2819
2889
  return 2;
2820
2890
  }
2821
2891
  if (toolsDiff.options !== null) {
2822
- const { server, baseline, sandbox } = toolsDiff.options;
2892
+ const { server, baseline, sandbox, promptsBaseline } = toolsDiff.options;
2823
2893
  const toolsResult = await initToolsDiffWorkflow(targetPath, toolsDiff.options);
2824
2894
  io.writeStdout([
2825
2895
  "Initialized Codex Plugin Doctor tool diff workflow",
2826
2896
  `Root: ${toolsResult.rootPath}`,
2827
2897
  `Workflow: ${toolsResult.workflowPath}`,
2828
- `Capture and commit a baseline at ${baseline}, for example with: codex-plugin-doctor doctor tools ${JSON.stringify(targetPath)} --server ${JSON.stringify(server)} --runtime${sandbox === "docker" ? " --sandbox docker" : ""} --save-response ${JSON.stringify(path.join(targetPath, baseline))}`
2898
+ `Capture and commit a baseline at ${baseline}, for example with: codex-plugin-doctor doctor tools ${JSON.stringify(targetPath)} --server ${JSON.stringify(server)} --runtime${sandbox === "docker" ? " --sandbox docker" : ""} --save-response ${JSON.stringify(path.join(targetPath, baseline))}`,
2899
+ ...(promptsBaseline === null ? [] : [
2900
+ `Capture and commit a prompt baseline at ${promptsBaseline}, for example with: codex-plugin-doctor doctor prompts ${JSON.stringify(targetPath)} --server ${JSON.stringify(server)} --runtime${sandbox === "docker" ? " --sandbox docker" : ""} --save-response ${JSON.stringify(path.join(targetPath, promptsBaseline))}`
2901
+ ])
2829
2902
  ].join("\n"));
2830
2903
  return 0;
2831
2904
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.76.0",
3
+ "version": "1.78.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",