codex-plugin-doctor 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,6 +157,13 @@ Run these from a Codex plugin package root:
157
157
  ```bash
158
158
  codex-plugin-doctor --version
159
159
  codex-plugin-doctor init my-plugin
160
+ codex-plugin-doctor compat .
161
+ codex-plugin-doctor compat . --client codex
162
+ codex-plugin-doctor compat . --client generic-mcp
163
+ codex-plugin-doctor compat . --client claude-desktop
164
+ codex-plugin-doctor compat . --client claude-desktop --install-preview
165
+ codex-plugin-doctor compat . --json
166
+ codex-plugin-doctor compat . --json --output compatibility.json
160
167
  codex-plugin-doctor check .
161
168
  codex-plugin-doctor check . --json
162
169
  codex-plugin-doctor check . --json --output report.json
@@ -169,6 +176,8 @@ codex-plugin-doctor check . --config .codex-doctor.json
169
176
  codex-plugin-doctor check . --json --runtime --verbose-runtime
170
177
  ```
171
178
 
179
+ `compat --client claude-desktop` checks whether the MCP package can be added to the local Claude Desktop setup. On Windows it looks for `%APPDATA%\Claude\claude_desktop_config.json`; on macOS it looks for `~/Library/Application Support/Claude/claude_desktop_config.json`. A valid existing config returns `PASS`, a missing Claude Desktop install returns `WARN`, and a malformed local config returns `FAIL` so you do not add new servers into a broken config file. If the package server name already exists in Claude Desktop, the command returns `WARN` with the duplicate server name. Add `--install-preview` to print the JSON snippet that should be merged into `claude_desktop_config.json`; it does not modify files.
180
+
172
181
  Optional local policy file:
173
182
 
174
183
  ```json
@@ -202,7 +211,7 @@ jobs:
202
211
  runs-on: ubuntu-latest
203
212
  steps:
204
213
  - uses: actions/checkout@v4
205
- - uses: Esquetta/CodexPluginDoctor@v0.1.4
214
+ - uses: Esquetta/CodexPluginDoctor@v0.2.0
206
215
  with:
207
216
  path: .
208
217
  runtime: "false"
@@ -0,0 +1,10 @@
1
+ import { type CompatibilityEnvironment } from "./compatibility-matrix.js";
2
+ export interface ClaudeDesktopInstallPreview {
3
+ targetPath: string;
4
+ configPath: string;
5
+ snippet: {
6
+ mcpServers: Record<string, unknown>;
7
+ };
8
+ }
9
+ export declare function buildClaudeDesktopInstallPreview(targetPath: string, environment?: CompatibilityEnvironment): Promise<ClaudeDesktopInstallPreview>;
10
+ export declare function renderClaudeDesktopInstallPreview(preview: ClaudeDesktopInstallPreview): string;
@@ -0,0 +1,69 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getClaudeDesktopConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
4
+ function isRecord(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ function isRelativeLocalPath(value) {
8
+ return value.startsWith("./") ||
9
+ value.startsWith("../") ||
10
+ value.startsWith(".\\") ||
11
+ value.startsWith("..\\");
12
+ }
13
+ function normalizeLocalPathArgument(value, rootPath) {
14
+ return typeof value === "string" && isRelativeLocalPath(value)
15
+ ? path.resolve(rootPath, value)
16
+ : value;
17
+ }
18
+ function normalizeServerConfig(serverConfig, rootPath) {
19
+ if (!isRecord(serverConfig)) {
20
+ return serverConfig;
21
+ }
22
+ const normalized = { ...serverConfig };
23
+ if (typeof normalized.command === "string" && isRelativeLocalPath(normalized.command)) {
24
+ normalized.command = path.resolve(rootPath, normalized.command);
25
+ }
26
+ if (Array.isArray(normalized.args)) {
27
+ normalized.args = normalized.args.map((argument) => normalizeLocalPathArgument(argument, rootPath));
28
+ }
29
+ return normalized;
30
+ }
31
+ export async function buildClaudeDesktopInstallPreview(targetPath, environment = {}) {
32
+ const rootPath = path.resolve(targetPath);
33
+ const configPath = getClaudeDesktopConfigPath(environment);
34
+ if (!configPath) {
35
+ throw new Error("Claude Desktop config path could not be resolved on this platform.");
36
+ }
37
+ const mcpConfigPath = await readMcpConfigPath(rootPath);
38
+ if (!mcpConfigPath) {
39
+ throw new Error("No MCP config found for install preview.");
40
+ }
41
+ const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
42
+ const servers = parsed.mcpServers;
43
+ if (!isRecord(servers) || Object.keys(servers).length === 0) {
44
+ throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
45
+ }
46
+ return {
47
+ targetPath: rootPath,
48
+ configPath,
49
+ snippet: {
50
+ mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
51
+ serverName,
52
+ normalizeServerConfig(serverConfig, rootPath)
53
+ ]))
54
+ }
55
+ };
56
+ }
57
+ export function renderClaudeDesktopInstallPreview(preview) {
58
+ return [
59
+ "Claude Desktop Install Preview",
60
+ "==============================",
61
+ `Target: ${preview.targetPath}`,
62
+ `Config: ${preview.configPath}`,
63
+ "",
64
+ "Add or merge this snippet into `claude_desktop_config.json`:",
65
+ JSON.stringify(preview.snippet, null, 2),
66
+ "",
67
+ "No files were modified."
68
+ ].join("\n");
69
+ }
@@ -0,0 +1,20 @@
1
+ export type CompatibilityStatus = "pass" | "warn" | "fail" | "skipped";
2
+ export interface CompatibilityResult {
3
+ client: string;
4
+ status: CompatibilityStatus;
5
+ summary: string;
6
+ details: string[];
7
+ }
8
+ export interface CompatibilityMatrix {
9
+ targetPath: string;
10
+ results: CompatibilityResult[];
11
+ }
12
+ export interface CompatibilityEnvironment {
13
+ env?: Record<string, string | undefined>;
14
+ platform?: NodeJS.Platform;
15
+ homedir?: string;
16
+ }
17
+ export declare function readMcpConfigPath(targetPath: string): Promise<string | null>;
18
+ export declare function getClaudeDesktopConfigPath(environment?: CompatibilityEnvironment): string | null;
19
+ export declare function buildCompatibilityMatrix(targetPath: string, environment?: CompatibilityEnvironment): Promise<CompatibilityMatrix>;
20
+ export declare function matrixExitCode(matrix: CompatibilityMatrix): 0 | 1;
@@ -0,0 +1,244 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { validatePlugin } from "../core/validate-plugin.js";
5
+ async function fileExists(targetPath) {
6
+ try {
7
+ const details = await stat(targetPath);
8
+ return details.isFile();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ async function directoryExists(targetPath) {
15
+ try {
16
+ const details = await stat(targetPath);
17
+ return details.isDirectory();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function statusFromCheckResult(result) {
24
+ if (result.status === "fail") {
25
+ return "fail";
26
+ }
27
+ if (result.status === "warn") {
28
+ return "warn";
29
+ }
30
+ return "pass";
31
+ }
32
+ export async function readMcpConfigPath(targetPath) {
33
+ const rootPath = path.resolve(targetPath);
34
+ const directMcpPath = path.join(rootPath, ".mcp.json");
35
+ if (await fileExists(directMcpPath)) {
36
+ return directMcpPath;
37
+ }
38
+ const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
39
+ if (!(await fileExists(manifestPath))) {
40
+ return null;
41
+ }
42
+ try {
43
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
44
+ return typeof manifest.mcpServers === "string"
45
+ ? path.resolve(rootPath, manifest.mcpServers)
46
+ : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ async function hasCodexManifest(targetPath) {
53
+ return fileExists(path.join(path.resolve(targetPath), ".codex-plugin", "plugin.json"));
54
+ }
55
+ async function checkGenericMcp(targetPath) {
56
+ const mcpConfigPath = await readMcpConfigPath(targetPath);
57
+ if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
58
+ return {
59
+ client: "Generic MCP",
60
+ status: "skipped",
61
+ summary: "No MCP config found.",
62
+ details: ["Expected `.mcp.json` or manifest `mcpServers` reference."]
63
+ };
64
+ }
65
+ try {
66
+ const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
67
+ const servers = parsed.mcpServers;
68
+ if (typeof servers !== "object" ||
69
+ servers === null ||
70
+ Array.isArray(servers) ||
71
+ Object.keys(servers).length === 0) {
72
+ return {
73
+ client: "Generic MCP",
74
+ status: "fail",
75
+ summary: "MCP config does not contain a non-empty `mcpServers` object.",
76
+ details: [mcpConfigPath]
77
+ };
78
+ }
79
+ return {
80
+ client: "Generic MCP",
81
+ status: "pass",
82
+ summary: "MCP server config is valid.",
83
+ details: [mcpConfigPath]
84
+ };
85
+ }
86
+ catch {
87
+ return {
88
+ client: "Generic MCP",
89
+ status: "fail",
90
+ summary: "MCP config is not valid JSON.",
91
+ details: [mcpConfigPath]
92
+ };
93
+ }
94
+ }
95
+ async function readMcpServerNames(targetPath) {
96
+ const mcpConfigPath = await readMcpConfigPath(targetPath);
97
+ if (!mcpConfigPath || !(await fileExists(mcpConfigPath))) {
98
+ return [];
99
+ }
100
+ try {
101
+ const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
102
+ const servers = parsed.mcpServers;
103
+ return typeof servers === "object" && servers !== null && !Array.isArray(servers)
104
+ ? Object.keys(servers)
105
+ : [];
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ export function getClaudeDesktopConfigPath(environment = {}) {
112
+ const platform = environment.platform ?? process.platform;
113
+ const env = environment.env ?? process.env;
114
+ const homeDirectory = environment.homedir ?? os.homedir();
115
+ if (platform === "win32") {
116
+ const appData = env.APPDATA;
117
+ return appData ? path.join(appData, "Claude", "claude_desktop_config.json") : null;
118
+ }
119
+ if (platform === "darwin") {
120
+ return path.join(homeDirectory, "Library", "Application Support", "Claude", "claude_desktop_config.json");
121
+ }
122
+ return null;
123
+ }
124
+ async function checkClaudeDesktop(targetPath, genericMcpResult, environment = {}) {
125
+ if (genericMcpResult.status !== "pass") {
126
+ return {
127
+ client: "Claude Desktop",
128
+ status: "skipped",
129
+ summary: "No valid MCP package config is available for Claude Desktop.",
130
+ details: ["Add a valid `.mcp.json` with a non-empty `mcpServers` object first."]
131
+ };
132
+ }
133
+ const configPath = getClaudeDesktopConfigPath(environment);
134
+ if (!configPath) {
135
+ return {
136
+ client: "Claude Desktop",
137
+ status: "warn",
138
+ summary: "Claude Desktop config path could not be resolved on this platform.",
139
+ details: ["Claude Desktop local config detection currently supports Windows and macOS."]
140
+ };
141
+ }
142
+ if (!(await fileExists(configPath))) {
143
+ const configDirectory = path.dirname(configPath);
144
+ return {
145
+ client: "Claude Desktop",
146
+ status: await directoryExists(configDirectory) ? "pass" : "warn",
147
+ summary: await directoryExists(configDirectory)
148
+ ? "Claude Desktop directory exists and a config file can be created."
149
+ : "Claude Desktop was not detected on this machine.",
150
+ details: [
151
+ configPath,
152
+ "Claude Desktop can add stdio MCP servers through `claude_desktop_config.json` when the app is installed."
153
+ ]
154
+ };
155
+ }
156
+ try {
157
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
158
+ const servers = parsed.mcpServers;
159
+ if (servers !== undefined && (typeof servers !== "object" ||
160
+ servers === null ||
161
+ Array.isArray(servers))) {
162
+ return {
163
+ client: "Claude Desktop",
164
+ status: "fail",
165
+ summary: "Claude Desktop config has an invalid `mcpServers` shape.",
166
+ details: [configPath, "`mcpServers` must be an object before this package can be added safely."]
167
+ };
168
+ }
169
+ const packageServerNames = await readMcpServerNames(targetPath);
170
+ const existingServerNames = typeof servers === "object" && servers !== null
171
+ ? Object.keys(servers)
172
+ : [];
173
+ const duplicateServerNames = packageServerNames.filter((serverName) => existingServerNames.includes(serverName));
174
+ if (duplicateServerNames.length > 0) {
175
+ return {
176
+ client: "Claude Desktop",
177
+ status: "warn",
178
+ summary: "Claude Desktop already has MCP server names from this package.",
179
+ details: [
180
+ configPath,
181
+ ...duplicateServerNames.map((serverName) => `Duplicate server: ${serverName}`)
182
+ ]
183
+ };
184
+ }
185
+ return {
186
+ client: "Claude Desktop",
187
+ status: "pass",
188
+ summary: "Claude Desktop config is valid and this MCP package can be added.",
189
+ details: [
190
+ configPath,
191
+ `Source package: ${path.resolve(targetPath)}`
192
+ ]
193
+ };
194
+ }
195
+ catch {
196
+ return {
197
+ client: "Claude Desktop",
198
+ status: "fail",
199
+ summary: "Claude Desktop config is not valid JSON.",
200
+ details: [configPath, "Repair the local Claude Desktop config before adding new MCP servers."]
201
+ };
202
+ }
203
+ }
204
+ export async function buildCompatibilityMatrix(targetPath, environment = {}) {
205
+ const rootPath = path.resolve(targetPath);
206
+ const genericMcpResult = await checkGenericMcp(rootPath);
207
+ const claudeDesktopResult = await checkClaudeDesktop(rootPath, genericMcpResult, environment);
208
+ const codexResult = await validatePlugin(rootPath);
209
+ const codexStatus = statusFromCheckResult(codexResult);
210
+ const codexCompatibility = !await hasCodexManifest(rootPath)
211
+ && genericMcpResult.status === "pass"
212
+ ? {
213
+ client: "Codex",
214
+ status: "skipped",
215
+ summary: "No Codex plugin manifest found; treating target as a standalone MCP package.",
216
+ details: ["Add `.codex-plugin/plugin.json` if this package should be installable as a Codex plugin."]
217
+ }
218
+ : {
219
+ client: "Codex",
220
+ status: codexStatus,
221
+ summary: codexStatus === "pass"
222
+ ? "Codex plugin package validation passed."
223
+ : "Codex plugin package validation produced findings.",
224
+ details: codexResult.findings.map((finding) => finding.id)
225
+ };
226
+ const results = [
227
+ codexCompatibility,
228
+ genericMcpResult,
229
+ claudeDesktopResult,
230
+ {
231
+ client: "Cursor",
232
+ status: "skipped",
233
+ summary: "Client-specific package adapter is not implemented yet.",
234
+ details: ["Planned adapter after generic MCP compatibility is stable."]
235
+ }
236
+ ];
237
+ return {
238
+ targetPath: rootPath,
239
+ results
240
+ };
241
+ }
242
+ export function matrixExitCode(matrix) {
243
+ return matrix.results.some((result) => result.status === "fail") ? 1 : 0;
244
+ }
@@ -0,0 +1,2 @@
1
+ import type { CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
+ export declare function renderCompatibilityReport(matrix: CompatibilityMatrix): string;
@@ -0,0 +1,18 @@
1
+ function statusLabel(status) {
2
+ return status.toUpperCase();
3
+ }
4
+ export function renderCompatibilityReport(matrix) {
5
+ const lines = [
6
+ "Compatibility Matrix",
7
+ "====================",
8
+ `Target: ${matrix.targetPath}`,
9
+ ""
10
+ ];
11
+ for (const result of matrix.results) {
12
+ lines.push(`${result.client}: ${statusLabel(result.status)} - ${result.summary}`);
13
+ for (const detail of result.details) {
14
+ lines.push(` - ${detail}`);
15
+ }
16
+ }
17
+ return lines.join("\n");
18
+ }
package/dist/run-cli.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import { discoverInstalledPlugins, filterInstalledPlugins } from "./core/discover-installed-plugins.js";
3
+ import { buildCompatibilityMatrix, matrixExitCode } from "./compatibility/compatibility-matrix.js";
4
+ import { buildClaudeDesktopInstallPreview, renderClaudeDesktopInstallPreview } from "./compatibility/claude-desktop-install-preview.js";
3
5
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
4
6
  import { initPluginPackage } from "./core/init-plugin.js";
5
7
  import { runCheck } from "./index.js";
6
8
  import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
9
+ import { renderCompatibilityReport } from "./reporting/render-compatibility-report.js";
7
10
  import { renderJsonReport } from "./reporting/render-json-report.js";
8
11
  import { buildMarkdownReport } from "./reporting/render-markdown-report.js";
9
12
  import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
@@ -23,7 +26,7 @@ const defaultIo = {
23
26
  }
24
27
  };
25
28
  function printUsage(io) {
26
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown] [--output <path>] [--runtime] [--verbose-runtime] [--no-animations] [--ascii]\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
29
+ 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] [--output <path>] [--install-preview]\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
27
30
  }
28
31
  function renderInstalledPlugins(plugins) {
29
32
  const lines = [
@@ -42,6 +45,25 @@ function renderInstalledPlugins(plugins) {
42
45
  }
43
46
  return lines.join("\n");
44
47
  }
48
+ const compatibilityClientAliases = {
49
+ codex: "Codex",
50
+ "generic-mcp": "Generic MCP",
51
+ generic: "Generic MCP",
52
+ mcp: "Generic MCP",
53
+ "claude-desktop": "Claude Desktop",
54
+ claude: "Claude Desktop",
55
+ cursor: "Cursor"
56
+ };
57
+ function filterCompatibilityMatrix(matrix, clientFilter) {
58
+ const client = compatibilityClientAliases[clientFilter.toLowerCase()];
59
+ if (!client) {
60
+ return null;
61
+ }
62
+ return {
63
+ ...matrix,
64
+ results: matrix.results.filter((result) => result.client === client)
65
+ };
66
+ }
45
67
  export async function runCli(args, io = defaultIo, options = {}) {
46
68
  const [command, maybePath, ...remainingArgs] = args;
47
69
  if (command === "--version" || command === "-v" || command === "version") {
@@ -86,6 +108,67 @@ export async function runCli(args, io = defaultIo, options = {}) {
86
108
  ].join("\n"));
87
109
  return 0;
88
110
  }
111
+ if (command === "compat") {
112
+ const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
113
+ const compatFlags = maybePath && maybePath.startsWith("--")
114
+ ? [maybePath, ...remainingArgs]
115
+ : remainingArgs;
116
+ const jsonOutput = compatFlags.includes("--json");
117
+ const installPreview = compatFlags.includes("--install-preview");
118
+ const clientIndex = compatFlags.indexOf("--client");
119
+ const clientFilter = clientIndex === -1 ? null : compatFlags[clientIndex + 1];
120
+ const outputIndex = compatFlags.indexOf("--output");
121
+ const outputPath = outputIndex === -1 ? null : compatFlags[outputIndex + 1];
122
+ if (clientIndex !== -1 && (!clientFilter || clientFilter.startsWith("--"))) {
123
+ io.writeStderr("Missing client after --client.");
124
+ return 2;
125
+ }
126
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
127
+ io.writeStderr("Missing path after --output.");
128
+ return 2;
129
+ }
130
+ if (installPreview && clientFilter?.toLowerCase() !== "claude-desktop") {
131
+ io.writeStderr("--install-preview requires --client claude-desktop.");
132
+ return 2;
133
+ }
134
+ if (installPreview) {
135
+ try {
136
+ const preview = await buildClaudeDesktopInstallPreview(targetPath, {
137
+ env: terminalContext.env
138
+ });
139
+ const report = renderClaudeDesktopInstallPreview(preview);
140
+ if (outputPath) {
141
+ await writeFile(outputPath, report, "utf8");
142
+ }
143
+ io.writeStdout(report);
144
+ return 0;
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : "Unknown install preview error.";
148
+ io.writeStderr(message);
149
+ return 1;
150
+ }
151
+ }
152
+ let matrix = await buildCompatibilityMatrix(targetPath, {
153
+ env: terminalContext.env
154
+ });
155
+ if (clientFilter) {
156
+ const filteredMatrix = filterCompatibilityMatrix(matrix, clientFilter);
157
+ if (!filteredMatrix) {
158
+ io.writeStderr(`Unknown compatibility client: ${clientFilter}`);
159
+ return 2;
160
+ }
161
+ matrix = filteredMatrix;
162
+ }
163
+ const report = jsonOutput
164
+ ? JSON.stringify({ schemaVersion: "1.0.0", ...matrix }, null, 2)
165
+ : renderCompatibilityReport(matrix);
166
+ if (outputPath) {
167
+ await writeFile(outputPath, report, "utf8");
168
+ }
169
+ io.writeStdout(report);
170
+ return matrixExitCode(matrix);
171
+ }
89
172
  if (command !== "check") {
90
173
  printUsage(io);
91
174
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
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",