codex-plugin-doctor 0.3.0 → 0.5.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.
@@ -0,0 +1,71 @@
1
+ import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readJsonFile } from "../core/read-json-file.js";
4
+ async function fileExists(targetPath) {
5
+ try {
6
+ const details = await stat(targetPath);
7
+ return details.isFile();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function isRecord(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ function buildBackupPath(configPath) {
17
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
18
+ return `${configPath}.${timestamp}.bak`;
19
+ }
20
+ export async function applyInstallPreview(client, preview) {
21
+ await mkdir(path.dirname(preview.configPath), { recursive: true });
22
+ const configExists = await fileExists(preview.configPath);
23
+ const currentConfig = configExists
24
+ ? await readJsonFile(preview.configPath)
25
+ : {};
26
+ if (!isRecord(currentConfig)) {
27
+ throw new Error(`${client} MCP config must be a JSON object.`);
28
+ }
29
+ const currentServers = currentConfig.mcpServers;
30
+ if (currentServers !== undefined && !isRecord(currentServers)) {
31
+ throw new Error(`${client} MCP config has an invalid \`mcpServers\` shape.`);
32
+ }
33
+ const existingServers = currentServers ?? {};
34
+ const incomingServers = preview.snippet.mcpServers;
35
+ const duplicateServers = Object.keys(incomingServers).filter((serverName) => Object.prototype.hasOwnProperty.call(existingServers, serverName));
36
+ if (duplicateServers.length > 0) {
37
+ throw new Error(`Refusing to overwrite existing MCP server names: ${duplicateServers.join(", ")}`);
38
+ }
39
+ const backupPath = configExists ? buildBackupPath(preview.configPath) : null;
40
+ if (backupPath) {
41
+ await copyFile(preview.configPath, backupPath);
42
+ }
43
+ const nextConfig = {
44
+ ...currentConfig,
45
+ mcpServers: {
46
+ ...existingServers,
47
+ ...incomingServers
48
+ }
49
+ };
50
+ await writeFile(preview.configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
51
+ return {
52
+ client,
53
+ configPath: preview.configPath,
54
+ backupPath,
55
+ appliedServers: Object.keys(incomingServers)
56
+ };
57
+ }
58
+ export function renderApplyInstallResult(result) {
59
+ const lines = [
60
+ `Applied ${result.client} MCP config`,
61
+ "==============================",
62
+ `Config: ${result.configPath}`,
63
+ `Backup: ${result.backupPath ?? "No existing config file was present."}`,
64
+ "",
65
+ "Applied servers:"
66
+ ];
67
+ for (const serverName of result.appliedServers) {
68
+ lines.push(`- ${serverName}`);
69
+ }
70
+ return lines.join("\n");
71
+ }
@@ -1,7 +1,8 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { validatePlugin } from "../core/validate-plugin.js";
5
+ import { readJsonFile } from "../core/read-json-file.js";
5
6
  async function fileExists(targetPath) {
6
7
  try {
7
8
  const details = await stat(targetPath);
@@ -40,7 +41,7 @@ export async function readMcpConfigPath(targetPath) {
40
41
  return null;
41
42
  }
42
43
  try {
43
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
44
+ const manifest = await readJsonFile(manifestPath);
44
45
  return typeof manifest.mcpServers === "string"
45
46
  ? path.resolve(rootPath, manifest.mcpServers)
46
47
  : null;
@@ -63,7 +64,7 @@ async function checkGenericMcp(targetPath) {
63
64
  };
64
65
  }
65
66
  try {
66
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
67
+ const parsed = await readJsonFile(mcpConfigPath);
67
68
  const servers = parsed.mcpServers;
68
69
  if (typeof servers !== "object" ||
69
70
  servers === null ||
@@ -98,7 +99,7 @@ async function readMcpServerNames(targetPath) {
98
99
  return [];
99
100
  }
100
101
  try {
101
- const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
102
+ const parsed = await readJsonFile(mcpConfigPath);
102
103
  const servers = parsed.mcpServers;
103
104
  return typeof servers === "object" && servers !== null && !Array.isArray(servers)
104
105
  ? Object.keys(servers)
@@ -166,7 +167,7 @@ async function checkClaudeDesktop(targetPath, genericMcpResult, environment = {}
166
167
  };
167
168
  }
168
169
  try {
169
- const parsed = JSON.parse(await readFile(configPath, "utf8"));
170
+ const parsed = await readJsonFile(configPath);
170
171
  const servers = parsed.mcpServers;
171
172
  if (servers !== undefined && (typeof servers !== "object" ||
172
173
  servers === null ||
@@ -238,7 +239,7 @@ async function checkCursor(targetPath, genericMcpResult, environment = {}) {
238
239
  };
239
240
  }
240
241
  try {
241
- const parsed = JSON.parse(await readFile(configPath, "utf8"));
242
+ const parsed = await readJsonFile(configPath);
242
243
  const servers = parsed.mcpServers;
243
244
  if (servers !== undefined && (typeof servers !== "object" ||
244
245
  servers === null ||
@@ -0,0 +1,2 @@
1
+ export declare function parseJsonText<T>(text: string): T;
2
+ export declare function readJsonFile<T>(filePath: string): Promise<T>;
@@ -0,0 +1,8 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function parseJsonText(text) {
3
+ const normalizedText = text.startsWith("\uFEFF") ? text.slice(1) : text;
4
+ return JSON.parse(normalizedText);
5
+ }
6
+ export async function readJsonFile(filePath) {
7
+ return parseJsonText(await readFile(filePath, "utf8"));
8
+ }
@@ -0,0 +1,10 @@
1
+ import type { CheckResult } from "../domain/types.js";
2
+ export interface BadgeReport {
3
+ schemaVersion: 1;
4
+ label: "doctor";
5
+ message: "PASS" | "WARN" | "FAIL";
6
+ color: "brightgreen" | "yellow" | "red";
7
+ }
8
+ export declare function buildBadgeReport(result: CheckResult): BadgeReport;
9
+ export declare function renderBadgeJson(result: CheckResult): string;
10
+ export declare function renderBadgeMarkdown(result: CheckResult): string;
@@ -0,0 +1,32 @@
1
+ function badgeForStatus(status) {
2
+ if (status === "pass") {
3
+ return {
4
+ message: "PASS",
5
+ color: "brightgreen"
6
+ };
7
+ }
8
+ if (status === "warn") {
9
+ return {
10
+ message: "WARN",
11
+ color: "yellow"
12
+ };
13
+ }
14
+ return {
15
+ message: "FAIL",
16
+ color: "red"
17
+ };
18
+ }
19
+ export function buildBadgeReport(result) {
20
+ return {
21
+ schemaVersion: 1,
22
+ label: "doctor",
23
+ ...badgeForStatus(result.status)
24
+ };
25
+ }
26
+ export function renderBadgeJson(result) {
27
+ return JSON.stringify(buildBadgeReport(result), null, 2);
28
+ }
29
+ export function renderBadgeMarkdown(result) {
30
+ const badge = buildBadgeReport(result);
31
+ return `![Codex Plugin Doctor](https://img.shields.io/badge/${badge.label}-${badge.message}-${badge.color})`;
32
+ }
package/dist/run-cli.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface CliTerminalContext {
7
7
  stdoutIsTTY: boolean;
8
8
  stderrIsTTY: boolean;
9
9
  env: Record<string, string | undefined>;
10
+ platform?: NodeJS.Platform;
10
11
  }
11
12
  export interface RunCliOptions {
12
13
  terminalContext?: CliTerminalContext;
package/dist/run-cli.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { discoverInstalledPlugins, filterInstalledPlugins } from "./core/discover-installed-plugins.js";
3
5
  import { buildCompatibilityMatrix, matrixExitCode } from "./compatibility/compatibility-matrix.js";
6
+ import { applyInstallPreview, renderApplyInstallResult } from "./compatibility/apply-install-preview.js";
4
7
  import { buildClaudeDesktopInstallPreview, renderClaudeDesktopInstallPreview } from "./compatibility/claude-desktop-install-preview.js";
5
8
  import { buildCursorInstallPreview, renderCursorInstallPreview } from "./compatibility/cursor-install-preview.js";
6
9
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
7
10
  import { initPluginPackage } from "./core/init-plugin.js";
8
11
  import { runCheck } from "./index.js";
9
12
  import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
13
+ import { renderBadgeJson, renderBadgeMarkdown } from "./reporting/render-badge-report.js";
10
14
  import { renderCompatibilityScorecard } from "./reporting/render-compatibility-scorecard.js";
11
15
  import { renderCompatibilityReport } from "./reporting/render-compatibility-report.js";
12
16
  import { renderJsonReport } from "./reporting/render-json-report.js";
@@ -28,7 +32,7 @@ const defaultIo = {
28
32
  }
29
33
  };
30
34
  function printUsage(io) {
31
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown] [--output <path>] [--runtime] [--verbose-runtime] [--no-animations] [--ascii]\n codex-plugin-doctor compat <path> [--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview]\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
35
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--runtime] [--verbose-runtime] [--no-animations] [--ascii]\n codex-plugin-doctor compat <path> [--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
32
36
  }
33
37
  function renderInstalledPlugins(plugins) {
34
38
  const lines = [
@@ -66,6 +70,22 @@ function filterCompatibilityMatrix(matrix, clientFilter) {
66
70
  results: matrix.results.filter((result) => result.client === client)
67
71
  };
68
72
  }
73
+ function resolveBundledSelfTestTarget() {
74
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "examples", "codex-doctor-runtime");
75
+ }
76
+ function renderSelfTestReport(targetPath, validationStatus, findingsCount, compatibilityMatrix) {
77
+ return [
78
+ "Codex Plugin Doctor Self-Test",
79
+ "=============================",
80
+ `Version: ${packageVersion}`,
81
+ `Sample: ${targetPath}`,
82
+ `Validation: ${validationStatus.toUpperCase()}`,
83
+ "Runtime probes: enabled",
84
+ `Findings: ${findingsCount}`,
85
+ "",
86
+ renderCompatibilityScorecard(compatibilityMatrix)
87
+ ].join("\n");
88
+ }
69
89
  export async function runCli(args, io = defaultIo, options = {}) {
70
90
  const [command, maybePath, ...remainingArgs] = args;
71
91
  if (command === "--version" || command === "-v" || command === "version") {
@@ -75,7 +95,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
75
95
  const terminalContext = options.terminalContext ?? {
76
96
  stdoutIsTTY: Boolean(process.stdout.isTTY),
77
97
  stderrIsTTY: Boolean(process.stderr.isTTY),
78
- env: process.env
98
+ env: process.env,
99
+ platform: process.platform
79
100
  };
80
101
  if (command === "list" && maybePath === "--installed") {
81
102
  const installedPlugins = await discoverInstalledPlugins({
@@ -97,6 +118,17 @@ export async function runCli(args, io = defaultIo, options = {}) {
97
118
  io.writeStdout(renderRuleExplanation(rule));
98
119
  return 0;
99
120
  }
121
+ if (command === "self-test" || command === "demo") {
122
+ const targetPath = resolveBundledSelfTestTarget();
123
+ const runCheckImpl = options.runCheckImpl ?? runCheck;
124
+ const result = applyDoctorConfig(await runCheckImpl(targetPath, { runtime: true }), await loadDoctorConfig(targetPath));
125
+ const compatibilityMatrix = await buildCompatibilityMatrix(targetPath, {
126
+ env: terminalContext.env,
127
+ platform: terminalContext.platform
128
+ });
129
+ io.writeStdout(renderSelfTestReport(targetPath, result.status, result.findings.length, compatibilityMatrix));
130
+ return result.exitCode === 1 || matrixExitCode(compatibilityMatrix) === 1 ? 1 : 0;
131
+ }
100
132
  if (command === "init") {
101
133
  const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
102
134
  const result = await initPluginPackage(targetPath);
@@ -118,6 +150,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
118
150
  const jsonOutput = compatFlags.includes("--json");
119
151
  const scorecardOutput = compatFlags.includes("--scorecard");
120
152
  const installPreview = compatFlags.includes("--install-preview");
153
+ const applyInstall = compatFlags.includes("--apply");
154
+ const backupInstall = compatFlags.includes("--backup");
121
155
  const clientIndex = compatFlags.indexOf("--client");
122
156
  const clientFilter = clientIndex === -1 ? null : compatFlags[clientIndex + 1];
123
157
  const outputIndex = compatFlags.indexOf("--output");
@@ -130,21 +164,36 @@ export async function runCli(args, io = defaultIo, options = {}) {
130
164
  io.writeStderr("Missing path after --output.");
131
165
  return 2;
132
166
  }
133
- if (installPreview &&
167
+ if ((installPreview || applyInstall) &&
134
168
  clientFilter?.toLowerCase() !== "claude-desktop" &&
135
169
  clientFilter?.toLowerCase() !== "cursor") {
136
- io.writeStderr("--install-preview requires --client claude-desktop or --client cursor.");
170
+ io.writeStderr("--install-preview and --apply require --client claude-desktop or --client cursor.");
171
+ return 2;
172
+ }
173
+ if (installPreview && applyInstall) {
174
+ io.writeStderr("Use either --install-preview or --apply, not both.");
137
175
  return 2;
138
176
  }
139
- if (installPreview) {
177
+ if (applyInstall && !backupInstall) {
178
+ io.writeStderr("--apply requires --backup.");
179
+ return 2;
180
+ }
181
+ if (installPreview || applyInstall) {
140
182
  try {
141
- const report = clientFilter?.toLowerCase() === "cursor"
142
- ? renderCursorInstallPreview(await buildCursorInstallPreview(targetPath, {
143
- env: terminalContext.env
144
- }))
145
- : renderClaudeDesktopInstallPreview(await buildClaudeDesktopInstallPreview(targetPath, {
146
- env: terminalContext.env
147
- }));
183
+ const preview = clientFilter?.toLowerCase() === "cursor"
184
+ ? await buildCursorInstallPreview(targetPath, {
185
+ env: terminalContext.env,
186
+ platform: terminalContext.platform
187
+ })
188
+ : await buildClaudeDesktopInstallPreview(targetPath, {
189
+ env: terminalContext.env,
190
+ platform: terminalContext.platform
191
+ });
192
+ const report = applyInstall
193
+ ? renderApplyInstallResult(await applyInstallPreview(clientFilter?.toLowerCase() === "cursor" ? "Cursor" : "Claude Desktop", preview))
194
+ : clientFilter?.toLowerCase() === "cursor"
195
+ ? renderCursorInstallPreview(preview)
196
+ : renderClaudeDesktopInstallPreview(preview);
148
197
  if (outputPath) {
149
198
  await writeFile(outputPath, report, "utf8");
150
199
  }
@@ -158,7 +207,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
158
207
  }
159
208
  }
160
209
  let matrix = await buildCompatibilityMatrix(targetPath, {
161
- env: terminalContext.env
210
+ env: terminalContext.env,
211
+ platform: terminalContext.platform
162
212
  });
163
213
  if (clientFilter) {
164
214
  const filteredMatrix = filterCompatibilityMatrix(matrix, clientFilter);
@@ -198,6 +248,8 @@ export async function runCli(args, io = defaultIo, options = {}) {
198
248
  : remainingArgs;
199
249
  const jsonOutput = normalizedFlags.includes("--json");
200
250
  const markdownOutput = normalizedFlags.includes("--markdown");
251
+ const badgeJsonOutput = normalizedFlags.includes("--badge-json");
252
+ const badgeMarkdownOutput = normalizedFlags.includes("--badge-markdown");
201
253
  const sarifOutput = normalizedFlags.includes("--sarif");
202
254
  const runtimeProbeEnabled = normalizedFlags.includes("--runtime");
203
255
  const verboseRuntime = normalizedFlags.includes("--verbose-runtime");
@@ -216,9 +268,13 @@ export async function runCli(args, io = defaultIo, options = {}) {
216
268
  io.writeStderr("Missing path after --config.");
217
269
  return 2;
218
270
  }
271
+ if (checkInstalled && (badgeJsonOutput || badgeMarkdownOutput)) {
272
+ io.writeStderr("Badge output requires a single package target.");
273
+ return 2;
274
+ }
219
275
  const outputPolicy = determineOutputPolicy({
220
- jsonOutput,
221
- markdownOutput,
276
+ jsonOutput: jsonOutput || badgeJsonOutput,
277
+ markdownOutput: markdownOutput || badgeMarkdownOutput,
222
278
  outputPath,
223
279
  noAnimations,
224
280
  asciiMode,
@@ -290,7 +346,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
290
346
  ? renderSarifReport(result)
291
347
  : jsonOutput
292
348
  ? renderJsonReport(result, { runtimeProbeEnabled })
293
- : renderTextReport(result, { ascii: outputPolicy.style === "ascii" });
349
+ : badgeJsonOutput
350
+ ? renderBadgeJson(result)
351
+ : badgeMarkdownOutput
352
+ ? renderBadgeMarkdown(result)
353
+ : renderTextReport(result, { ascii: outputPolicy.style === "ascii" });
294
354
  if (outputPath) {
295
355
  await writeFile(outputPath, report, "utf8");
296
356
  }
@@ -0,0 +1,73 @@
1
+ # Examples
2
+
3
+ This folder contains manual example packs for local testing. Unlike `tests/fixtures`, these examples are meant for humans to run directly against the CLI.
4
+
5
+ ## Example Packs
6
+
7
+ ### `codex-doctor-starter`
8
+
9
+ Minimal valid Codex plugin package with one skill and no runtime MCP server.
10
+
11
+ Expected result:
12
+
13
+ - static validation passes
14
+ - no runtime probing needed
15
+
16
+ Command:
17
+
18
+ ```bash
19
+ codex-plugin-doctor check examples/codex-doctor-starter
20
+ ```
21
+
22
+ ### `codex-doctor-runtime`
23
+
24
+ Valid Codex plugin package with:
25
+
26
+ - skill metadata
27
+ - `.mcp.json`
28
+ - mock MCP stdio server
29
+ - `tools/list`
30
+ - `tools/call`
31
+ - `resources/list`
32
+ - `resources/read`
33
+ - `resources/templates/list`
34
+ - `prompts/list`
35
+ - `prompts/get`
36
+
37
+ Expected result:
38
+
39
+ - static validation passes
40
+ - runtime validation passes
41
+ - runtime scorecard shows all supported runtime capabilities as `pass`
42
+
43
+ Command:
44
+
45
+ ```bash
46
+ codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
47
+ ```
48
+
49
+ ### `codex-doctor-risky`
50
+
51
+ Intentionally flawed package for showing failure output.
52
+
53
+ Expected result:
54
+
55
+ - security finding for hard-coded secret
56
+
57
+ Command:
58
+
59
+ ```bash
60
+ codex-plugin-doctor check examples/codex-doctor-risky --ascii
61
+ ```
62
+
63
+ ## Suggested Local Flow
64
+
65
+ ```bash
66
+ npm install
67
+ npm run build
68
+ npm link
69
+ codex-plugin-doctor check examples/codex-doctor-starter
70
+ codex-plugin-doctor check examples/codex-doctor-runtime --json --runtime --verbose-runtime
71
+ codex-plugin-doctor check examples/codex-doctor-risky --ascii
72
+ ```
73
+
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "codex-doctor-risky",
3
+ "version": "1.0.0",
4
+ "description": "Intentionally risky sample package for showing Codex Doctor failure output.",
5
+ "mcpServers": "./.mcp.json"
6
+ }
7
+
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "dangerServer": {
4
+ "command": "node",
5
+ "args": ["./mock-server.js"],
6
+ "env": {
7
+ "OPENAI_API_KEY": "sk-live-example-secret-value"
8
+ }
9
+ }
10
+ }
11
+ }
12
+
@@ -0,0 +1 @@
1
+ process.stdin.resume();
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "codex-doctor-runtime",
3
+ "version": "1.0.0",
4
+ "description": "Runtime-complete Codex Doctor sample plugin with MCP validation coverage.",
5
+ "skills": "./skills/",
6
+ "mcpServers": "./.mcp.json"
7
+ }
8
+
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "doctorRuntime": {
4
+ "command": "node",
5
+ "args": ["./mock-server.js"]
6
+ }
7
+ }
8
+ }
9
+