codex-plugin-doctor 1.76.0 → 1.77.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 (36) hide show
  1. package/README.md +18 -2
  2. package/dist/core/mcp-prompt-catalog.d.ts +36 -0
  3. package/dist/core/mcp-prompt-catalog.js +53 -0
  4. package/dist/core/mcp-prompt-definition.d.ts +24 -0
  5. package/dist/core/mcp-prompt-definition.js +107 -0
  6. package/dist/core/mcp-prompt-diff.d.ts +43 -0
  7. package/dist/core/mcp-prompt-diff.js +132 -0
  8. package/dist/core/mcp-prompt-file.d.ts +31 -0
  9. package/dist/core/mcp-prompt-file.js +118 -0
  10. package/dist/core/mcp-prompt-impact.d.ts +21 -0
  11. package/dist/core/mcp-prompt-impact.js +93 -0
  12. package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
  13. package/dist/core/mcp-stdio-tool-catalog.js +74 -55
  14. package/dist/core/mcp-tool-catalog.d.ts +44 -5
  15. package/dist/core/mcp-tool-catalog.js +76 -31
  16. package/dist/core/mcp-tool-diff.d.ts +1 -0
  17. package/dist/core/mcp-tool-diff.js +1 -1
  18. package/dist/core/mcp-tool-file.d.ts +20 -0
  19. package/dist/core/mcp-tool-file.js +1 -1
  20. package/dist/core/mcp-tool-response-file.d.ts +6 -4
  21. package/dist/core/mcp-tool-response-file.js +8 -8
  22. package/dist/core/output-contract.js +177 -0
  23. package/dist/reporting/quote-server-text.d.ts +1 -0
  24. package/dist/reporting/quote-server-text.js +13 -0
  25. package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
  26. package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
  27. package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
  28. package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
  29. package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
  30. package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
  31. package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
  32. package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
  33. package/dist/reporting/render-mcp-tool-diff-markdown.js +3 -16
  34. package/dist/run-cli.d.ts +7 -0
  35. package/dist/run-cli.js +203 -140
  36. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ import type { McpPromptDiffReport } from "../core/mcp-prompt-diff.js";
2
+ export declare function renderMcpPromptDiffReport(report: McpPromptDiffReport): string;
@@ -0,0 +1,48 @@
1
+ import { describePromptLocation } from "./render-mcp-prompt-file-report.js";
2
+ import { quoteServerText } from "./quote-server-text.js";
3
+ export function renderMcpPromptDiffReport(report) {
4
+ const comparison = report.comparison;
5
+ return [
6
+ "Offline MCP Prompt Diff",
7
+ "=======================",
8
+ `Status: ${report.status}`,
9
+ `Scope: ${report.scope}`,
10
+ `Comparison complete: ${comparison.complete}`,
11
+ ...(comparison.reason === null ? [] : [`Reason: ${comparison.reason}`]),
12
+ `Added: ${comparison.added ?? "not compared"}`,
13
+ `Removed: ${comparison.removed ?? "not compared"}`,
14
+ `Changed: ${comparison.changed ?? "not compared"}`,
15
+ `Unchanged: ${comparison.unchanged ?? "not compared"}`,
16
+ `Breaking: ${comparison.breaking ?? "not compared"}`,
17
+ `Unclassified: ${comparison.unclassified ?? "not compared"}`,
18
+ `Comparison coverage: ${report.coverage.comparison}`,
19
+ `Definition coverage: ${report.coverage.definitions}`,
20
+ `Compatibility: ${report.coverage.compatibility}`,
21
+ `Server catalog: ${report.coverage.serverCatalog}`,
22
+ `Prompt retrieval: ${report.coverage.promptRetrieval}`,
23
+ `Impact classification: ${report.coverage.impactClassification}`,
24
+ "Only the supplied responses are compared. Impact labels are argument heuristics: a compatible label or an unchanged result does not establish compatibility.",
25
+ ...["before", "after"].flatMap((side) => {
26
+ const source = report[side];
27
+ const label = side === "before" ? "Before" : "After";
28
+ return [
29
+ `${label} input: ${source.status}; prompts checked: ${source.inspection.promptsChecked}; bytes read: ${source.source.bytesRead}`,
30
+ ...(source.inspection.reason === null ? [] : [`${label} reason: ${source.inspection.reason}`]),
31
+ ...source.findings.map((finding) => {
32
+ const locator = finding.location ? ` [${side} ${describePromptLocation(finding.location)}]` : "";
33
+ return `${label} ${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
34
+ })
35
+ ];
36
+ }),
37
+ ...report.changes.map((change) => {
38
+ const locations = [
39
+ ...(change.beforePromptIndex === null ? [] : [`before prompt ${change.beforePromptIndex}`]),
40
+ ...(change.afterPromptIndex === null ? [] : [`after prompt ${change.afterPromptIndex}`])
41
+ ];
42
+ const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
43
+ const name = change.name === undefined ? "" : ` ${quoteServerText(change.name)}`;
44
+ const at = (change.locations ?? []).map((location) => `\n At ${location.argument === null ? "(prompt)" : `argument ${quoteServerText(location.argument)}`}: ${location.reason}`).join("");
45
+ return `${change.kind.toUpperCase()}${name} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}${at}`;
46
+ })
47
+ ].join("\n");
48
+ }
@@ -0,0 +1,5 @@
1
+ import type { PromptCatalogLocation } from "../core/mcp-prompt-definition.js";
2
+ import type { McpPromptFileReport } from "../core/mcp-prompt-file.js";
3
+ /** Describes a finding location by one-based positions only, such as "prompt 2, argument 1, related argument 3". */
4
+ export declare function describePromptLocation(location: PromptCatalogLocation): string;
5
+ export declare function renderMcpPromptFileReport(report: McpPromptFileReport): string;
@@ -0,0 +1,33 @@
1
+ /** Describes a finding location by one-based positions only, such as "prompt 2, argument 1, related argument 3". */
2
+ export function describePromptLocation(location) {
3
+ return [
4
+ `prompt ${location.promptIndex}`,
5
+ ...(location.argumentIndex === undefined ? [] : [`argument ${location.argumentIndex}`]),
6
+ ...(location.relatedPromptIndex === undefined ? [] : [`related prompt ${location.relatedPromptIndex}`]),
7
+ ...(location.relatedArgumentIndex === undefined ? [] : [`related argument ${location.relatedArgumentIndex}`])
8
+ ].join(", ");
9
+ }
10
+ export function renderMcpPromptFileReport(report) {
11
+ return [
12
+ "Offline MCP Prompt Definitions",
13
+ "==============================",
14
+ `Status: ${report.status}`,
15
+ `Scope: ${report.scope}`,
16
+ `Source: ${report.source.kind} (${report.source.format})`,
17
+ `Bytes read: ${report.source.bytesRead}`,
18
+ `Inspection complete: ${report.inspection.complete}`,
19
+ `Prompts checked: ${report.inspection.promptsChecked}`,
20
+ `More pages advertised: ${report.inspection.hasNextCursor ?? "unknown"}`,
21
+ ...(report.inspection.reason === null ? [] : [`Reason: ${report.inspection.reason}`]),
22
+ `Definition coverage: ${report.coverage.definitions}`,
23
+ `Discovery: ${report.coverage.discovery}`,
24
+ `Server catalog: ${report.coverage.serverCatalog}`,
25
+ `Prompt retrieval: ${report.coverage.promptRetrieval}`,
26
+ `Full conformance: ${report.coverage.fullConformance}`,
27
+ "Only the supplied file was inspected. Its provenance and server completeness are not established.",
28
+ ...report.findings.map((finding) => {
29
+ const locator = finding.location ? ` [${describePromptLocation(finding.location)}]` : "";
30
+ return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
31
+ })
32
+ ].join("\n");
33
+ }
@@ -1,23 +1,10 @@
1
- import { quoteServerText } from "./quote-server-text.js";
1
+ import { markdownCodeSpan } from "./quote-server-text.js";
2
2
  function count(value) {
3
3
  return value === null || value === undefined ? "not compared" : String(value);
4
4
  }
5
5
  function toolIndex(value) {
6
6
  return value === null ? "-" : String(value);
7
7
  }
8
- // Names and pointers come from the server, so they are quoted and put in a code span, where Markdown,
9
- // HTML, mentions, and links stay inert; the fence outgrows any backtick run, and pipes are escaped for the table.
10
- function code(text) {
11
- const quoted = quoteServerText(text);
12
- let run = 0;
13
- let longest = 0;
14
- for (const character of quoted) {
15
- run = character === "`" ? run + 1 : 0;
16
- longest = Math.max(longest, run);
17
- }
18
- const fence = "`".repeat(longest + 1);
19
- return `${fence}${quoted.replace(/\|/gu, "\\|")}${fence}`;
20
- }
21
8
  export function renderMcpToolDiffMarkdown(report) {
22
9
  const comparison = report.comparison;
23
10
  const named = report.changes.some((change) => change.name !== undefined);
@@ -31,9 +18,9 @@ export function renderMcpToolDiffMarkdown(report) {
31
18
  `| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
32
19
  ];
33
20
  if (report.changes.length > 0) {
34
- lines.push("", "### Changes", "", named ? "| Change | Tool | Before tool | After tool | Fields | Impact | Reasons |" : "| Change | Before tool | After tool | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : code(change.name)}` : ""} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
21
+ lines.push("", "### Changes", "", named ? "| Change | Tool | Before tool | After tool | Fields | Impact | Reasons |" : "| Change | Before tool | After tool | Fields | Impact | Reasons |", named ? "| --- | --- | --- | --- | --- | --- | --- |" : "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind}${named ? ` | ${change.name === undefined ? "-" : markdownCodeSpan(change.name)}` : ""} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
35
22
  }
36
- const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : code(change.name)} | \`${location.reason}\` | ${location.pointer === "" ? "(tool)" : code(location.pointer)} |`));
23
+ const located = report.changes.flatMap((change) => (change.locations ?? []).map((location) => `| ${change.name === undefined ? "-" : markdownCodeSpan(change.name)} | \`${location.reason}\` | ${location.pointer === "" ? "(tool)" : markdownCodeSpan(location.pointer)} |`));
37
24
  if (located.length > 0) {
38
25
  lines.push("", "### Locations", "", "| Tool | Reason | Location |", "| --- | --- | --- |", ...located);
39
26
  }
package/dist/run-cli.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { discoverMcpServer } from "./core/mcp-discovery.js";
2
2
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
3
3
  import { captureStdioMcpToolCatalog } from "./core/mcp-stdio-tool-catalog.js";
4
+ import { captureMcpPromptCatalog, captureStdioMcpPromptCatalog } from "./core/mcp-prompt-catalog.js";
5
+ import { compareMcpPromptFiles } from "./core/mcp-prompt-diff.js";
6
+ import { inspectMcpPromptFile } from "./core/mcp-prompt-file.js";
4
7
  import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
5
8
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
6
9
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
@@ -23,6 +26,10 @@ export interface RunCliOptions {
23
26
  inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
24
27
  captureMcpToolCatalogImpl?: typeof captureMcpToolCatalog;
25
28
  captureStdioMcpToolCatalogImpl?: typeof captureStdioMcpToolCatalog;
29
+ compareMcpPromptFilesImpl?: typeof compareMcpPromptFiles;
30
+ inspectMcpPromptFileImpl?: typeof inspectMcpPromptFile;
31
+ captureMcpPromptCatalogImpl?: typeof captureMcpPromptCatalog;
32
+ captureStdioMcpPromptCatalogImpl?: typeof captureStdioMcpPromptCatalog;
26
33
  discoverMcpServerImpl?: typeof discoverMcpServer;
27
34
  terminalContext?: CliTerminalContext;
28
35
  runCheckImpl?: typeof runCheck;
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,7 +120,12 @@ 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;
@@ -165,33 +177,90 @@ function resolveBearerToken(name, env) {
165
177
  return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
166
178
  return { token };
167
179
  }
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;
180
+ // Writes the catalog report and, when asked, saves a complete enumeration as one list response.
181
+ async function finishCatalogCommand(io, kind, status, output, items, cache, savePath) {
182
+ io.writeStdout(output);
183
+ const exitCode = status === "pass" ? 0
184
+ : status === "incomplete" || status === "blocked" ? 2 : 1;
175
185
  if (savePath === undefined)
176
186
  return exitCode;
177
- if (snapshot.tools === null || snapshot.cache === null) {
178
- io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
187
+ if (items === null || cache === null) {
188
+ io.writeStderr(`${kind.noun} catalog response was not saved because enumeration did not complete.`);
179
189
  return 2;
180
190
  }
181
- const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
191
+ const saved = await saveMcpListResponse(savePath, kind.name, items, cache);
182
192
  if (saved.kind !== "saved") {
183
193
  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."
194
+ "path-blocked": `${kind.noun} catalog response was not saved because the path is not a local file path.`,
195
+ "not-regular": `${kind.noun} catalog response was not saved because the existing target is not a regular file.`,
196
+ "too-large": `${kind.noun} catalog response was not saved because it exceeds the one MiB ${kind.name}-file limit.`,
197
+ "write-failed": `${kind.noun} catalog response could not be written.`
188
198
  }[saved.kind]);
189
199
  return 2;
190
200
  }
191
- io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
201
+ io.writeStderr(`Saved a complete ${kind.method} response with ${saved.count} ${kind.name} (${saved.bytes} bytes).`);
192
202
  return exitCode;
193
203
  }
194
- async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
204
+ function finishToolCatalogCommand(io, snapshot, json, savePath) {
205
+ const report = snapshot.report;
206
+ return finishCatalogCommand(io, TOOL_CATALOG_COMMAND, report.status, json ? JSON.stringify(report, null, 2) : renderMcpToolCatalogReport(report), snapshot.tools, snapshot.cache, savePath);
207
+ }
208
+ function finishPromptCatalogCommand(io, snapshot, json, savePath) {
209
+ const report = snapshot.report;
210
+ return finishCatalogCommand(io, PROMPT_CATALOG_COMMAND, report.status, json ? JSON.stringify(report, null, 2) : renderMcpPromptCatalogReport(report), snapshot.prompts, snapshot.cache, savePath);
211
+ }
212
+ async function runHttpCatalogCommand(url, flags, io, env, kind, run) {
213
+ const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
214
+ const switches = [];
215
+ let savePath;
216
+ let tokenEnv;
217
+ let invalid = false;
218
+ for (let index = 0; index < flags.length; index += 1) {
219
+ const flag = flags[index];
220
+ if ((flag === "--save-response" && savePath === undefined) || (flag === "--bearer-token-env" && tokenEnv === undefined)) {
221
+ const value = flags[index + 1];
222
+ if (!value || value.startsWith("-")) {
223
+ invalid = true;
224
+ break;
225
+ }
226
+ if (flag === "--save-response")
227
+ savePath = value;
228
+ else
229
+ tokenEnv = value;
230
+ index += 1;
231
+ }
232
+ else if (allowedFlags.has(flag) && !switches.includes(flag)) {
233
+ switches.push(flag);
234
+ }
235
+ else {
236
+ invalid = true;
237
+ break;
238
+ }
239
+ }
240
+ if (invalid) {
241
+ io.writeStderr(`Invalid or duplicate ${kind.noun.toLowerCase()} catalog arguments. ${kind.usage}`);
242
+ return 2;
243
+ }
244
+ if (!switches.includes("--allow-network")) {
245
+ io.writeStderr(`doctor ${kind.name} requires explicit --allow-network consent.`);
246
+ return 2;
247
+ }
248
+ if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
249
+ io.writeStderr(`doctor ${kind.name} --save-response requires a local file path.`);
250
+ return 2;
251
+ }
252
+ const bearer = resolveBearerToken(tokenEnv, env);
253
+ if ("error" in bearer) {
254
+ io.writeStderr(bearer.error);
255
+ return 2;
256
+ }
257
+ return run(url, {
258
+ allowNetwork: true,
259
+ allowLocalNetwork: switches.includes("--allow-local-network"),
260
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
261
+ }, switches.includes("--json"), savePath);
262
+ }
263
+ async function runStdioCatalogCommand(targetPath, flags, io, env, kind, run) {
195
264
  const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
196
265
  const switches = [];
197
266
  const values = new Map();
@@ -231,11 +300,11 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
231
300
  const approvalDigest = values.get("--runtime-approval-digest");
232
301
  const requireApproval = switches.includes("--require-runtime-approval");
233
302
  if (invalid || serverName === undefined || (sandbox !== undefined && sandbox !== "docker")) {
234
- io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
303
+ io.writeStderr(`Invalid or duplicate ${kind.noun.toLowerCase()} catalog arguments. ${kind.usage}`);
235
304
  return 2;
236
305
  }
237
306
  if (!switches.includes("--runtime")) {
238
- io.writeStderr("doctor tools --server starts a local MCP server and requires explicit --runtime consent.");
307
+ io.writeStderr(`doctor ${kind.name} --server starts a local MCP server and requires explicit --runtime consent.`);
239
308
  return 2;
240
309
  }
241
310
  if (approvalDigest !== undefined && !requireApproval) {
@@ -262,14 +331,14 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
262
331
  }
263
332
  }
264
333
  if (isBlockedOfflinePath(targetPath)) {
265
- io.writeStderr("doctor tools --server requires a local package path.");
334
+ io.writeStderr(`doctor ${kind.name} --server requires a local package path.`);
266
335
  return 2;
267
336
  }
268
337
  if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
269
- io.writeStderr("doctor tools --save-response requires a local file path.");
338
+ io.writeStderr(`doctor ${kind.name} --save-response requires a local file path.`);
270
339
  return 2;
271
340
  }
272
- const resolved = await resolveMcpStdioServer(targetPath, serverName);
341
+ const resolved = await resolveMcpStdioServer(targetPath, serverName, kind.name);
273
342
  if (!resolved.ok) {
274
343
  io.writeStderr(resolved.message);
275
344
  return 2;
@@ -290,8 +359,74 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
290
359
  return 1;
291
360
  }
292
361
  }
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);
362
+ return run(resolved.server, sandbox !== "docker" ? {} : environment.length > 0 ? { sandbox: "docker", environment } : { sandbox: "docker" }, switches.includes("--json"), savePath);
363
+ }
364
+ // Runs tools-diff or prompts-diff, which share their options and exit codes.
365
+ async function runOfflineDiffCommand(args, io, usage, compare, render) {
366
+ let beforePath;
367
+ let afterPath;
368
+ let format;
369
+ let failOn;
370
+ let showNames = false;
371
+ let invalid = false;
372
+ for (let index = 0; index < args.length; index += 1) {
373
+ const flag = args[index];
374
+ if (flag === "--show-names" && !showNames) {
375
+ showNames = true;
376
+ }
377
+ else if ((flag === "--json" || flag === "--markdown") && format === undefined) {
378
+ format = flag === "--json" ? "json" : "markdown";
379
+ }
380
+ else if (flag === "--fail-on" && failOn === undefined) {
381
+ const value = args[index + 1];
382
+ if (value !== "any" && value !== "breaking") {
383
+ invalid = true;
384
+ break;
385
+ }
386
+ failOn = value;
387
+ index += 1;
388
+ }
389
+ else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
390
+ const value = args[index + 1];
391
+ if (!value || value.startsWith("-")) {
392
+ invalid = true;
393
+ break;
394
+ }
395
+ if (flag === "--before")
396
+ beforePath = value;
397
+ else
398
+ afterPath = value;
399
+ index += 1;
400
+ }
401
+ else {
402
+ invalid = true;
403
+ break;
404
+ }
405
+ }
406
+ if (invalid || !beforePath || !afterPath) {
407
+ io.writeStderr(`Invalid comparison arguments. ${usage}`);
408
+ return 2;
409
+ }
410
+ const report = await compare(beforePath, afterPath, showNames);
411
+ io.writeStdout(format === "json" ? JSON.stringify(report, null, 2) : render(report, format === "markdown"));
412
+ if (report.status !== "pass" && report.status !== "warn")
413
+ return 2;
414
+ if (failOn === "breaking") {
415
+ return (report.comparison.breaking ?? 0) + (report.comparison.unclassified ?? 0) > 0 ? 1 : 0;
416
+ }
417
+ return report.status === "pass" ? 0 : 1;
418
+ }
419
+ // Runs tools-file or prompts-file, which share their options and exit codes.
420
+ async function runOfflineFileCommand(args, io, usage, inspect, render) {
421
+ const [filePath, ...flags] = args;
422
+ if (!filePath || filePath.startsWith("-") || flags.some((flag) => flag !== "--json") || flags.length > 1) {
423
+ io.writeStderr(`Invalid file inspection arguments. ${usage}`);
424
+ return 2;
425
+ }
426
+ const report = await inspect(filePath);
427
+ io.writeStdout(flags.includes("--json") ? JSON.stringify(report, null, 2) : render(report));
428
+ return report.status === "pass" ? 0
429
+ : report.status === "warn" || report.status === "fail" ? 1 : 2;
295
430
  }
296
431
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
297
432
  const index = flags.indexOf("--sandbox");
@@ -377,8 +512,11 @@ function printUsage(io) {
377
512
  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");
378
513
  io.writeStderr(discoveryUsage);
379
514
  io.writeStderr(toolsUsage);
515
+ io.writeStderr(promptsUsage);
380
516
  io.writeStderr(toolsFileUsage);
381
517
  io.writeStderr(toolsDiffUsage);
518
+ io.writeStderr(promptsFileUsage);
519
+ io.writeStderr(promptsDiffUsage);
382
520
  io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
383
521
  + " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
384
522
  + "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
@@ -1342,10 +1480,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
1342
1480
  (command === "doctor" &&
1343
1481
  (["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
1344
1482
  (maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
1345
- (maybePath === "tools" && remainingArgs.includes("--server")))) ||
1483
+ ((maybePath === "tools" || maybePath === "prompts") && remainingArgs.includes("--server")))) ||
1346
1484
  (command === "init-ci" && args.includes("--tools-diff-server"));
1347
1485
  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.");
1486
+ 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
1487
  return 2;
1350
1488
  }
1351
1489
  if (command === "--version" || command === "-v" || command === "version") {
@@ -1397,78 +1535,35 @@ export async function runCli(args, io = defaultIo, options = {}) {
1397
1535
  --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
1536
  return 0;
1399
1537
  }
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;
1538
+ return runOfflineDiffCommand(remainingArgs, io, toolsDiffUsage, (beforePath, afterPath, showNames) => {
1539
+ const compare = options.compareMcpToolFilesImpl ?? compareMcpToolFiles;
1540
+ return showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath);
1541
+ }, (report, markdown) => markdown ? renderMcpToolDiffMarkdown(report) : renderMcpToolDiffReport(report));
1542
+ }
1543
+ if (maybePath === "prompts-diff") {
1544
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1545
+ 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).
1546
+ --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.`);
1547
+ return 0;
1453
1548
  }
1454
- return report.status === "pass" ? 0 : 1;
1549
+ return runOfflineDiffCommand(remainingArgs, io, promptsDiffUsage, (beforePath, afterPath, showNames) => {
1550
+ const compare = options.compareMcpPromptFilesImpl ?? compareMcpPromptFiles;
1551
+ return showNames ? compare(beforePath, afterPath, { showNames: true }) : compare(beforePath, afterPath);
1552
+ }, (report, markdown) => markdown ? renderMcpPromptDiffMarkdown(report) : renderMcpPromptDiffReport(report));
1455
1553
  }
1456
1554
  if (maybePath === "tools-file") {
1457
1555
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1458
1556
  io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
1459
1557
  return 0;
1460
1558
  }
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;
1559
+ return runOfflineFileCommand(remainingArgs, io, toolsFileUsage, options.inspectMcpToolFileImpl ?? inspectMcpToolFile, renderMcpToolFileReport);
1560
+ }
1561
+ if (maybePath === "prompts-file") {
1562
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1563
+ io.writeStdout(`${promptsFileUsage}\nInspects one saved UTF-8 MCP prompts/list response. No server connection or prompt retrieval.`);
1564
+ return 0;
1465
1565
  }
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;
1566
+ return runOfflineFileCommand(remainingArgs, io, promptsFileUsage, options.inspectMcpPromptFileImpl ?? inspectMcpPromptFile, renderMcpPromptFileReport);
1472
1567
  }
1473
1568
  if (maybePath === "tools") {
1474
1569
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
@@ -1481,61 +1576,29 @@ export async function runCli(args, io = defaultIo, options = {}) {
1481
1576
  return 2;
1482
1577
  }
1483
1578
  if (flags.includes("--server")) {
1484
- return runStdioToolCatalogCommand(url, flags, io, options, terminalContext.env);
1579
+ 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
1580
  }
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;
1581
+ return runHttpCatalogCommand(url, flags, io, terminalContext.env, TOOL_CATALOG_COMMAND, async (target, catalogOptions, json, savePath) => {
1582
+ const snapshot = savePath === undefined
1583
+ ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(target, catalogOptions), tools: null, cache: null }
1584
+ : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(target, catalogOptions);
1585
+ return finishToolCatalogCommand(io, snapshot, json, savePath);
1586
+ });
1587
+ }
1588
+ if (maybePath === "prompts") {
1589
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1590
+ 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}`);
1591
+ return 0;
1520
1592
  }
1521
- if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
1522
- io.writeStderr("doctor tools --save-response requires a local file path.");
1593
+ const [target, ...flags] = remainingArgs;
1594
+ if (!target || target.startsWith("-")) {
1595
+ io.writeStderr(promptsUsage);
1523
1596
  return 2;
1524
1597
  }
1525
- const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1526
- if ("error" in bearer) {
1527
- io.writeStderr(bearer.error);
1528
- return 2;
1598
+ if (flags.includes("--server")) {
1599
+ 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
1600
  }
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);
1601
+ 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
1602
  }
1540
1603
  if (maybePath === "discover") {
1541
1604
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.76.0",
3
+ "version": "1.77.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",