codex-plugin-doctor 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -166,6 +166,7 @@ codex-plugin-doctor self-test
166
166
  codex-plugin-doctor doctor
167
167
  codex-plugin-doctor init my-plugin
168
168
  codex-plugin-doctor compat .
169
+ codex-plugin-doctor compat . --all --scorecard
169
170
  codex-plugin-doctor compat . --client codex
170
171
  codex-plugin-doctor compat . --client generic-mcp
171
172
  codex-plugin-doctor compat . --client claude-desktop
@@ -184,6 +185,7 @@ codex-plugin-doctor check . --profile ci
184
185
  codex-plugin-doctor check . --profile strict
185
186
  codex-plugin-doctor check . --profile publish
186
187
  codex-plugin-doctor check . --json
188
+ codex-plugin-doctor check . --explain
187
189
  codex-plugin-doctor check . --json --output report.json
188
190
  codex-plugin-doctor check . --markdown --output report.md
189
191
  codex-plugin-doctor check . --badge-json --output doctor-badge.json
@@ -198,13 +200,14 @@ codex-plugin-doctor history validation-history.jsonl
198
200
  codex-plugin-doctor history validation-history.jsonl --json
199
201
  codex-plugin-doctor history validation-history.jsonl --fail-on-regression
200
202
  codex-plugin-doctor fix . --dry-run
203
+ codex-plugin-doctor fix . --interactive --backup
201
204
  codex-plugin-doctor fix . --apply --backup
202
205
  codex-plugin-doctor check . --json --runtime --verbose-runtime
203
206
  ```
204
207
 
205
208
  `self-test` runs the bundled runtime-complete sample through static validation, runtime MCP probes, and the compatibility scorecard. It is the fastest post-install check after `npm install -g codex-plugin-doctor`.
206
209
 
207
- `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility.
210
+ `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup.
208
211
 
209
212
  `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. Use `--apply --backup` only when you want the CLI to create a timestamped backup and merge the server config. Apply mode refuses to overwrite duplicate server names.
210
213
 
@@ -212,15 +215,17 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
212
215
 
213
216
  `compat --client cline` checks whether the MCP package can be added to Cline. It uses `CLINE_DIR/data/settings/cline_mcp_settings.json` when `CLINE_DIR` is set, otherwise `~/.cline/data/settings/cline_mcp_settings.json`. Add `--install-preview` to print the JSON snippet that should be merged into `cline_mcp_settings.json`.
214
217
 
215
- `compat --scorecard` turns the compatibility matrix into a compact score summary. `PASS` maps to `100`, `WARN` maps to `70`, and `FAIL` or `SKIPPED` maps to `0`.
218
+ `compat --all` makes the all-client matrix explicit when you want Codex, Generic MCP, Claude Desktop, Cursor, Cline, and Windsurf in one run. `compat --scorecard` turns the compatibility matrix into a compact score summary. `PASS` maps to `100`, `WARN` maps to `70`, and `FAIL` or `SKIPPED` maps to `0`.
216
219
 
217
220
  `check --profile ci|strict|publish` applies named validation policies. `ci` keeps default behavior, `strict` fails on warnings, and `publish` fails on warnings while enabling runtime probing by default.
218
221
 
222
+ `check --explain` adds inline rule catalog context to text reports, including why a finding matters, a more detailed fix path, and a compact example.
223
+
219
224
  `check --badge-json` emits Shields endpoint-compatible JSON such as `{"schemaVersion":1,"label":"doctor","message":"PASS","color":"brightgreen"}`. `check --badge-markdown` emits a static shields.io Markdown badge for README or release notes. Badge output is intentionally limited to single package checks, not `check --installed`.
220
225
 
221
226
  `check --history <path>` appends a compact JSONL validation snapshot after a single package check. `history <path>` reads the JSONL file and compares the latest run to the previous run, including status, finding-count deltas, and whether the latest run regressed. Add `history --json` for automation output or `history --fail-on-regression` when CI should fail after a worse latest run.
222
227
 
223
- `fix --dry-run` renders safe automatic fix plans without changing files. `fix --apply --backup` applies only supported safe fixes, such as manifest defaults and missing skills directories, after creating backups.
228
+ `fix --dry-run` renders safe automatic fix plans without changing files. `fix --interactive --backup` shows the same plan, then applies only after you type `yes`. `fix --apply --backup` applies supported safe fixes, such as manifest defaults and missing skills directories, after creating backups.
224
229
 
225
230
  Optional local policy file:
226
231
 
@@ -255,9 +260,9 @@ jobs:
255
260
  runs-on: ubuntu-latest
256
261
  steps:
257
262
  - uses: actions/checkout@v4
258
- - uses: Esquetta/CodexPluginDoctor@v0.7.0
263
+ - uses: Esquetta/CodexPluginDoctor@v0.9.0
259
264
  with:
260
- version: "0.7.0"
265
+ version: "0.9.0"
261
266
  path: .
262
267
  runtime: "false"
263
268
  ```
@@ -300,11 +305,12 @@ Recent validation waves covered:
300
305
 
301
306
  Release preparation is reproducible from the repository:
302
307
 
303
- ```bash
304
- npm run prepare-release
305
- ```
306
-
307
- This runs tests, builds the TypeScript output, and performs `npm pack --dry-run`.
308
+ ```bash
309
+ npm run prepare-release
310
+ npm run release-check
311
+ ```
312
+
313
+ `prepare-release` runs tests, builds the TypeScript output, and performs `npm pack --dry-run`. `release-check` adds release preflight checks for a clean git tree, existing npm versions, existing version tags, tests, build, and pack dry-run.
308
314
 
309
315
  Related docs:
310
316
 
@@ -18,5 +18,6 @@ export declare function readMcpConfigPath(targetPath: string): Promise<string |
18
18
  export declare function getClaudeDesktopConfigPath(environment?: CompatibilityEnvironment): string | null;
19
19
  export declare function getCursorMcpConfigPath(targetPath: string, environment?: CompatibilityEnvironment): Promise<string>;
20
20
  export declare function getClineMcpConfigPath(environment?: CompatibilityEnvironment): string;
21
+ export declare function getWindsurfMcpConfigPath(environment?: CompatibilityEnvironment): string;
21
22
  export declare function buildCompatibilityMatrix(targetPath: string, environment?: CompatibilityEnvironment): Promise<CompatibilityMatrix>;
22
23
  export declare function matrixExitCode(matrix: CompatibilityMatrix): 0 | 1;
@@ -141,6 +141,9 @@ export function getClineMcpConfigPath(environment = {}) {
141
141
  : path.join(getHomeDirectory(environment), ".cline");
142
142
  return path.join(clineDirectory, "data", "settings", "cline_mcp_settings.json");
143
143
  }
144
+ export function getWindsurfMcpConfigPath(environment = {}) {
145
+ return path.join(getHomeDirectory(environment), ".codeium", "windsurf", "mcp_config.json");
146
+ }
144
147
  async function checkClaudeDesktop(targetPath, genericMcpResult, environment = {}) {
145
148
  if (genericMcpResult.status !== "pass") {
146
149
  return {
@@ -365,12 +368,85 @@ async function checkCline(targetPath, genericMcpResult, environment = {}) {
365
368
  };
366
369
  }
367
370
  }
371
+ async function checkWindsurf(targetPath, genericMcpResult, environment = {}) {
372
+ if (genericMcpResult.status !== "pass") {
373
+ return {
374
+ client: "Windsurf",
375
+ status: "skipped",
376
+ summary: "No valid MCP package config is available for Windsurf.",
377
+ details: ["Add a valid `.mcp.json` with a non-empty `mcpServers` object first."]
378
+ };
379
+ }
380
+ const configPath = getWindsurfMcpConfigPath(environment);
381
+ if (!(await fileExists(configPath))) {
382
+ const configDirectory = path.dirname(configPath);
383
+ return {
384
+ client: "Windsurf",
385
+ status: await directoryExists(configDirectory) ? "pass" : "warn",
386
+ summary: await directoryExists(configDirectory)
387
+ ? "Windsurf MCP config directory exists and a config file can be created."
388
+ : "Windsurf was not detected on this machine.",
389
+ details: [
390
+ configPath,
391
+ "Windsurf stores Cascade MCP servers in `mcp_config.json` under `~/.codeium/windsurf`."
392
+ ]
393
+ };
394
+ }
395
+ try {
396
+ const parsed = await readJsonFile(configPath);
397
+ const servers = parsed.mcpServers;
398
+ if (servers !== undefined && (typeof servers !== "object" ||
399
+ servers === null ||
400
+ Array.isArray(servers))) {
401
+ return {
402
+ client: "Windsurf",
403
+ status: "fail",
404
+ summary: "Windsurf MCP config has an invalid `mcpServers` shape.",
405
+ details: [configPath, "`mcpServers` must be an object before this package can be added safely."]
406
+ };
407
+ }
408
+ const packageServerNames = await readMcpServerNames(targetPath);
409
+ const existingServerNames = typeof servers === "object" && servers !== null
410
+ ? Object.keys(servers)
411
+ : [];
412
+ const duplicateServerNames = packageServerNames.filter((serverName) => existingServerNames.includes(serverName));
413
+ if (duplicateServerNames.length > 0) {
414
+ return {
415
+ client: "Windsurf",
416
+ status: "warn",
417
+ summary: "Windsurf already has MCP server names from this package.",
418
+ details: [
419
+ configPath,
420
+ ...duplicateServerNames.map((serverName) => `Duplicate server: ${serverName}`)
421
+ ]
422
+ };
423
+ }
424
+ return {
425
+ client: "Windsurf",
426
+ status: "pass",
427
+ summary: "Windsurf MCP config is valid and this package can be added.",
428
+ details: [
429
+ configPath,
430
+ `Source package: ${path.resolve(targetPath)}`
431
+ ]
432
+ };
433
+ }
434
+ catch {
435
+ return {
436
+ client: "Windsurf",
437
+ status: "fail",
438
+ summary: "Windsurf MCP config is not valid JSON.",
439
+ details: [configPath, "Repair the local Windsurf MCP config before adding new MCP servers."]
440
+ };
441
+ }
442
+ }
368
443
  export async function buildCompatibilityMatrix(targetPath, environment = {}) {
369
444
  const rootPath = path.resolve(targetPath);
370
445
  const genericMcpResult = await checkGenericMcp(rootPath);
371
446
  const claudeDesktopResult = await checkClaudeDesktop(rootPath, genericMcpResult, environment);
372
447
  const cursorResult = await checkCursor(rootPath, genericMcpResult, environment);
373
448
  const clineResult = await checkCline(rootPath, genericMcpResult, environment);
449
+ const windsurfResult = await checkWindsurf(rootPath, genericMcpResult, environment);
374
450
  const codexResult = await validatePlugin(rootPath);
375
451
  const codexStatus = statusFromCheckResult(codexResult);
376
452
  const codexCompatibility = !await hasCodexManifest(rootPath)
@@ -394,7 +470,8 @@ export async function buildCompatibilityMatrix(targetPath, environment = {}) {
394
470
  genericMcpResult,
395
471
  claudeDesktopResult,
396
472
  cursorResult,
397
- clineResult
473
+ clineResult,
474
+ windsurfResult
398
475
  ];
399
476
  return {
400
477
  targetPath: rootPath,
@@ -0,0 +1,10 @@
1
+ import { type CompatibilityEnvironment } from "./compatibility-matrix.js";
2
+ export interface WindsurfInstallPreview {
3
+ targetPath: string;
4
+ configPath: string;
5
+ snippet: {
6
+ mcpServers: Record<string, unknown>;
7
+ };
8
+ }
9
+ export declare function buildWindsurfInstallPreview(targetPath: string, environment?: CompatibilityEnvironment): Promise<WindsurfInstallPreview>;
10
+ export declare function renderWindsurfInstallPreview(preview: WindsurfInstallPreview): string;
@@ -0,0 +1,65 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getWindsurfMcpConfigPath, 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 buildWindsurfInstallPreview(targetPath, environment = {}) {
32
+ const rootPath = path.resolve(targetPath);
33
+ const mcpConfigPath = await readMcpConfigPath(rootPath);
34
+ if (!mcpConfigPath) {
35
+ throw new Error("No MCP config found for install preview.");
36
+ }
37
+ const parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
+ const servers = parsed.mcpServers;
39
+ if (!isRecord(servers) || Object.keys(servers).length === 0) {
40
+ throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
41
+ }
42
+ return {
43
+ targetPath: rootPath,
44
+ configPath: getWindsurfMcpConfigPath(environment),
45
+ snippet: {
46
+ mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
47
+ serverName,
48
+ normalizeServerConfig(serverConfig, rootPath)
49
+ ]))
50
+ }
51
+ };
52
+ }
53
+ export function renderWindsurfInstallPreview(preview) {
54
+ return [
55
+ "Windsurf Install Preview",
56
+ "========================",
57
+ `Target: ${preview.targetPath}`,
58
+ `Config: ${preview.configPath}`,
59
+ "",
60
+ "Add or merge this snippet into `mcp_config.json`:",
61
+ JSON.stringify(preview.snippet, null, 2),
62
+ "",
63
+ "No files were modified."
64
+ ].join("\n");
65
+ }
@@ -1,2 +1,19 @@
1
1
  import type { CliTerminalContext } from "../run-cli.js";
2
+ export interface EnvironmentDoctorReport {
3
+ schemaVersion: "1.0.0";
4
+ version: string;
5
+ platform: string;
6
+ node: string;
7
+ npmGlobalPrefix: string;
8
+ codexHome: {
9
+ status: "pass" | "warn";
10
+ path: string | null;
11
+ };
12
+ codexPluginCache: {
13
+ status: "pass" | "warn";
14
+ path: string | null;
15
+ };
16
+ }
2
17
  export declare function renderEnvironmentDoctor(terminalContext: CliTerminalContext): Promise<string>;
18
+ export declare function buildEnvironmentDoctorReport(terminalContext: CliTerminalContext): Promise<EnvironmentDoctorReport>;
19
+ export declare function renderEnvironmentDoctorJson(terminalContext: CliTerminalContext): Promise<string>;
@@ -23,19 +23,48 @@ function resolveCodexHome(env) {
23
23
  return null;
24
24
  }
25
25
  export async function renderEnvironmentDoctor(terminalContext) {
26
+ const report = await buildEnvironmentDoctorReport(terminalContext);
27
+ return [
28
+ "Codex Plugin Doctor Environment",
29
+ "===============================",
30
+ `Version: ${report.version}`,
31
+ `Platform: ${report.platform}`,
32
+ `Node: ${report.node}`,
33
+ `npm global prefix: ${report.npmGlobalPrefix}`,
34
+ `Codex home: ${report.codexHome.status.toUpperCase()}${report.codexHome.path ? ` (${report.codexHome.path})` : ""}`,
35
+ `Codex plugin cache: ${report.codexPluginCache.status.toUpperCase()}${report.codexPluginCache.path ? ` (${report.codexPluginCache.path})` : ""}`,
36
+ "",
37
+ "Recommended next commands",
38
+ "-------------------------",
39
+ "codex-plugin-doctor self-test",
40
+ "codex-plugin-doctor list --installed",
41
+ "codex-plugin-doctor check . --runtime --explain",
42
+ "codex-plugin-doctor compat . --all --scorecard",
43
+ "codex-plugin-doctor init-ci ."
44
+ ].join("\n");
45
+ }
46
+ export async function buildEnvironmentDoctorReport(terminalContext) {
26
47
  const codexHome = resolveCodexHome(terminalContext.env);
27
48
  const codexHomeExists = codexHome ? await pathExists(codexHome) : false;
28
49
  const pluginCache = codexHome ? path.join(codexHome, "plugins", "cache") : null;
29
50
  const pluginCacheExists = pluginCache ? await pathExists(pluginCache) : false;
30
51
  const npmPrefix = terminalContext.env.npm_config_prefix ?? "(unknown)";
31
- return [
32
- "Codex Plugin Doctor Environment",
33
- "===============================",
34
- `Version: ${packageVersion}`,
35
- `Platform: ${terminalContext.platform ?? process.platform}`,
36
- `Node: ${process.version}`,
37
- `npm global prefix: ${npmPrefix}`,
38
- `Codex home: ${codexHomeExists ? "PASS" : "WARN"}${codexHome ? ` (${codexHome})` : ""}`,
39
- `Codex plugin cache: ${pluginCacheExists ? "PASS" : "WARN"}${pluginCache ? ` (${pluginCache})` : ""}`
40
- ].join("\n");
52
+ return {
53
+ schemaVersion: "1.0.0",
54
+ version: packageVersion,
55
+ platform: terminalContext.platform ?? process.platform,
56
+ node: process.version,
57
+ npmGlobalPrefix: npmPrefix,
58
+ codexHome: {
59
+ status: codexHomeExists ? "pass" : "warn",
60
+ path: codexHome
61
+ },
62
+ codexPluginCache: {
63
+ status: pluginCacheExists ? "pass" : "warn",
64
+ path: pluginCache
65
+ }
66
+ };
67
+ }
68
+ export async function renderEnvironmentDoctorJson(terminalContext) {
69
+ return JSON.stringify(await buildEnvironmentDoctorReport(terminalContext), null, 2);
41
70
  }
@@ -14,7 +14,22 @@ export interface ApplyFixPlanResult {
14
14
  filesChanged: number;
15
15
  backupDirectory: string;
16
16
  }
17
+ export interface FixPlanJsonReport {
18
+ schemaVersion: "1.0.0";
19
+ mode: "dry-run" | "apply";
20
+ targetPath: string;
21
+ filesChanged: number;
22
+ backupDirectory: string | null;
23
+ actions: Array<FixPlanAction & {
24
+ relativePath: string;
25
+ }>;
26
+ }
17
27
  export declare function buildFixPlan(targetPath: string): Promise<FixPlan>;
18
- export declare function renderFixPlan(plan: FixPlan, mode: "dry-run"): string;
28
+ export declare function renderFixPlan(plan: FixPlan, mode: "dry-run" | "interactive"): string;
19
29
  export declare function applyFixPlan(targetPath: string): Promise<ApplyFixPlanResult>;
20
30
  export declare function renderApplyFixResult(result: ApplyFixPlanResult): string;
31
+ export declare function renderFixPlanJsonReport(plan: FixPlan, options: {
32
+ mode: "dry-run" | "apply";
33
+ filesChanged?: number;
34
+ backupDirectory?: string | null;
35
+ }): string;
@@ -1,4 +1,4 @@
1
- import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { validatePlugin } from "./validate-plugin.js";
4
4
  function relativeToTarget(targetPath, candidatePath) {
@@ -33,6 +33,53 @@ export async function buildFixPlan(targetPath) {
33
33
  details: "Create the skills directory referenced by the manifest."
34
34
  });
35
35
  }
36
+ const manifest = await readFile(manifestPath, "utf8")
37
+ .then((content) => JSON.parse(content))
38
+ .catch(() => ({}));
39
+ if (typeof manifest.skills === "string") {
40
+ const skillsPath = path.resolve(rootPath, manifest.skills);
41
+ if (await directoryExists(skillsPath)) {
42
+ for (const entry of await readdir(skillsPath, { withFileTypes: true })) {
43
+ if (!entry.isDirectory()) {
44
+ continue;
45
+ }
46
+ const skillFilePath = path.join(skillsPath, entry.name, "SKILL.md");
47
+ if (!(await fileExists(skillFilePath))) {
48
+ actions.push({
49
+ id: "skill.scaffold_skill_md",
50
+ title: `Create missing SKILL.md for ${entry.name}`,
51
+ targetPath: skillFilePath,
52
+ operation: "update-json",
53
+ details: `Create ${relativeToTarget(rootPath, skillFilePath)} with safe frontmatter.`
54
+ });
55
+ continue;
56
+ }
57
+ const skillContent = await readFile(skillFilePath, "utf8");
58
+ const frontmatter = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "";
59
+ if (!/^name\s*:/im.test(frontmatter) || !/^description\s*:/im.test(frontmatter)) {
60
+ actions.push({
61
+ id: "skill.safe_frontmatter_defaults",
62
+ title: `Add missing skill frontmatter defaults for ${entry.name}`,
63
+ targetPath: skillFilePath,
64
+ operation: "update-json",
65
+ details: `Set missing name/description fields in ${relativeToTarget(rootPath, skillFilePath)}.`
66
+ });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ if (typeof manifest.mcpServers === "string") {
72
+ const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
73
+ if (!(await fileExists(mcpConfigPath))) {
74
+ actions.push({
75
+ id: "mcp.scaffold_config",
76
+ title: "Create missing MCP config",
77
+ targetPath: mcpConfigPath,
78
+ operation: "update-json",
79
+ details: `Create ${relativeToTarget(rootPath, mcpConfigPath)} with an empty mcpServers object.`
80
+ });
81
+ }
82
+ }
36
83
  return {
37
84
  targetPath: rootPath,
38
85
  actions
@@ -66,6 +113,54 @@ function timestampForPath() {
66
113
  function defaultManifestDescription() {
67
114
  return "Codex plugin package.";
68
115
  }
116
+ async function fileExists(targetPath) {
117
+ try {
118
+ const details = await stat(targetPath);
119
+ return details.isFile();
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ async function directoryExists(targetPath) {
126
+ try {
127
+ const details = await stat(targetPath);
128
+ return details.isDirectory();
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ function skillDescription(skillName) {
135
+ return `Use when running the ${skillName} skill.`;
136
+ }
137
+ function renderSkillScaffold(skillName, body = "") {
138
+ return [
139
+ "---",
140
+ `name: ${skillName}`,
141
+ `description: ${skillDescription(skillName)}`,
142
+ "---",
143
+ "",
144
+ body.trim() || `# ${skillName}`
145
+ ].join("\n") + "\n";
146
+ }
147
+ function replaceFrontmatter(content, skillName) {
148
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
149
+ const frontmatter = frontmatterMatch?.[1] ?? "";
150
+ const body = frontmatterMatch ? content.slice(frontmatterMatch[0].length) : content;
151
+ const lines = frontmatter.split(/\r?\n/).filter((line) => line.trim());
152
+ const hasName = lines.some((line) => /^name\s*:/i.test(line));
153
+ const hasDescription = lines.some((line) => /^description\s*:/i.test(line));
154
+ return [
155
+ "---",
156
+ ...(hasName ? [] : [`name: ${skillName}`]),
157
+ ...(hasDescription ? [] : [`description: ${skillDescription(skillName)}`]),
158
+ ...lines,
159
+ "---",
160
+ "",
161
+ body.trim() || `# ${skillName}`
162
+ ].join("\n") + "\n";
163
+ }
69
164
  async function backupFile(rootPath, backupDirectory, filePath) {
70
165
  const relativePath = path.relative(rootPath, filePath);
71
166
  const backupPath = path.join(backupDirectory, relativePath);
@@ -94,6 +189,24 @@ export async function applyFixPlan(targetPath) {
94
189
  if (action.operation === "mkdir" && action.id === "skills.create_directory") {
95
190
  await mkdir(action.targetPath, { recursive: true });
96
191
  filesChanged += 1;
192
+ continue;
193
+ }
194
+ if (action.operation === "update-json" && action.id === "skill.scaffold_skill_md") {
195
+ await mkdir(path.dirname(action.targetPath), { recursive: true });
196
+ await writeFile(action.targetPath, renderSkillScaffold(path.basename(path.dirname(action.targetPath))), "utf8");
197
+ filesChanged += 1;
198
+ continue;
199
+ }
200
+ if (action.operation === "update-json" && action.id === "skill.safe_frontmatter_defaults") {
201
+ await backupFile(plan.targetPath, backupDirectory, action.targetPath);
202
+ await writeFile(action.targetPath, replaceFrontmatter(await readFile(action.targetPath, "utf8"), path.basename(path.dirname(action.targetPath))), "utf8");
203
+ filesChanged += 1;
204
+ continue;
205
+ }
206
+ if (action.operation === "update-json" && action.id === "mcp.scaffold_config") {
207
+ await mkdir(path.dirname(action.targetPath), { recursive: true });
208
+ await writeFile(action.targetPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`, "utf8");
209
+ filesChanged += 1;
97
210
  }
98
211
  }
99
212
  return {
@@ -112,3 +225,17 @@ export function renderApplyFixResult(result) {
112
225
  `Backup: ${relativeToTarget(result.plan.targetPath, result.backupDirectory)}`
113
226
  ].join("\n");
114
227
  }
228
+ export function renderFixPlanJsonReport(plan, options) {
229
+ const report = {
230
+ schemaVersion: "1.0.0",
231
+ mode: options.mode,
232
+ targetPath: plan.targetPath,
233
+ filesChanged: options.filesChanged ?? 0,
234
+ backupDirectory: options.backupDirectory ?? null,
235
+ actions: plan.actions.map((action) => ({
236
+ ...action,
237
+ relativePath: relativeToTarget(plan.targetPath, action.targetPath)
238
+ }))
239
+ };
240
+ return JSON.stringify(report, null, 2);
241
+ }
@@ -0,0 +1,5 @@
1
+ export interface InitCiResult {
2
+ rootPath: string;
3
+ workflowPath: string;
4
+ }
5
+ export declare function initCiWorkflow(targetPath: string): Promise<InitCiResult>;
@@ -0,0 +1,36 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { packageVersion } from "../version.js";
4
+ function buildWorkflow() {
5
+ return [
6
+ "name: Validate Codex plugin",
7
+ "",
8
+ "on:",
9
+ " pull_request:",
10
+ " push:",
11
+ " branches:",
12
+ " - main",
13
+ "",
14
+ "jobs:",
15
+ " doctor:",
16
+ " runs-on: ubuntu-latest",
17
+ " steps:",
18
+ " - uses: actions/checkout@v4",
19
+ ` - uses: Esquetta/CodexPluginDoctor@v${packageVersion}`,
20
+ " with:",
21
+ ` version: \"${packageVersion}\"`,
22
+ " path: .",
23
+ " runtime: \"true\"",
24
+ ""
25
+ ].join("\n");
26
+ }
27
+ export async function initCiWorkflow(targetPath) {
28
+ const rootPath = path.resolve(targetPath);
29
+ const workflowPath = path.join(rootPath, ".github", "workflows", "codex-plugin-doctor.yml");
30
+ await mkdir(path.dirname(workflowPath), { recursive: true });
31
+ await writeFile(workflowPath, buildWorkflow(), "utf8");
32
+ return {
33
+ rootPath,
34
+ workflowPath
35
+ };
36
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CheckResult } from "../domain/types.js";
2
2
  export declare function renderTextReport(result: CheckResult, options?: {
3
3
  ascii?: boolean;
4
+ explain?: boolean;
4
5
  }): string;
@@ -1,3 +1,4 @@
1
+ import { findRuleDefinition } from "../rules/rule-catalog.js";
1
2
  function getCounts(result) {
2
3
  const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
3
4
  const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
@@ -22,6 +23,7 @@ function getGlyphs(ascii) {
22
23
  }
23
24
  export function renderTextReport(result, options = {}) {
24
25
  const ascii = options.ascii ?? false;
26
+ const explain = options.explain ?? false;
25
27
  const glyphs = getGlyphs(ascii);
26
28
  const { failCount, warnCount, totalCount } = getCounts(result);
27
29
  const lines = [
@@ -58,6 +60,14 @@ export function renderTextReport(result, options = {}) {
58
60
  lines.push(` Message: ${finding.message}`);
59
61
  lines.push(` Impact: ${finding.impact}`);
60
62
  lines.push(` Suggested fix: ${finding.suggestedFix}`);
63
+ if (explain) {
64
+ const rule = findRuleDefinition(finding.id);
65
+ if (rule) {
66
+ lines.push(` Why: ${rule.why}`);
67
+ lines.push(` Fix detail: ${rule.fix}`);
68
+ lines.push(` Example: ${rule.example}`);
69
+ }
70
+ }
61
71
  }
62
72
  };
63
73
  appendSection("Failures", failures, glyphs.fail);
package/dist/run-cli.d.ts CHANGED
@@ -2,6 +2,7 @@ import { runCheck } from "./index.js";
2
2
  export interface CliIo {
3
3
  writeStdout(message: string): void;
4
4
  writeStderr(message: string): void;
5
+ readStdin?(prompt: string): Promise<string>;
5
6
  }
6
7
  export interface CliTerminalContext {
7
8
  stdoutIsTTY: boolean;
package/dist/run-cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { discoverInstalledPlugins, filterInstalledPlugins } from "./core/discover-installed-plugins.js";
5
6
  import { appendValidationHistoryEntry, readValidationHistory, summarizeValidationHistory } from "./core/validation-history.js";
@@ -8,9 +9,11 @@ import { applyInstallPreview, renderApplyInstallResult } from "./compatibility/a
8
9
  import { buildClaudeDesktopInstallPreview, renderClaudeDesktopInstallPreview } from "./compatibility/claude-desktop-install-preview.js";
9
10
  import { buildCursorInstallPreview, renderCursorInstallPreview } from "./compatibility/cursor-install-preview.js";
10
11
  import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibility/cline-install-preview.js";
12
+ import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
11
13
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
12
- import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlan } from "./core/fix-plan.js";
13
- import { renderEnvironmentDoctor } from "./core/environment-doctor.js";
14
+ import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
15
+ import { renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
16
+ import { initCiWorkflow } from "./core/init-ci.js";
14
17
  import { initPluginPackage } from "./core/init-plugin.js";
15
18
  import { runCheck } from "./index.js";
16
19
  import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
@@ -34,10 +37,22 @@ const defaultIo = {
34
37
  },
35
38
  writeStderr(message) {
36
39
  process.stderr.write(`${message}\n`);
40
+ },
41
+ async readStdin(prompt) {
42
+ const readline = createInterface({
43
+ input: process.stdin,
44
+ output: process.stdout
45
+ });
46
+ try {
47
+ return await readline.question(prompt);
48
+ }
49
+ finally {
50
+ readline.close();
51
+ }
37
52
  }
38
53
  };
39
54
  function printUsage(io) {
40
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <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 fix <path> (--dry-run|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
55
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\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 doctor\n codex-plugin-doctor init-ci [path]\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");
41
56
  }
42
57
  function renderInstalledPlugins(plugins) {
43
58
  const lines = [
@@ -64,7 +79,8 @@ const compatibilityClientAliases = {
64
79
  "claude-desktop": "Claude Desktop",
65
80
  claude: "Claude Desktop",
66
81
  cursor: "Cursor",
67
- cline: "Cline"
82
+ cline: "Cline",
83
+ windsurf: "Windsurf"
68
84
  };
69
85
  const checkProfiles = ["ci", "strict", "publish"];
70
86
  function parseCheckProfile(value) {
@@ -130,7 +146,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
130
146
  return 0;
131
147
  }
132
148
  if (command === "doctor") {
133
- io.writeStdout(await renderEnvironmentDoctor(terminalContext));
149
+ io.writeStdout(maybePath === "--json"
150
+ ? await renderEnvironmentDoctorJson(terminalContext)
151
+ : await renderEnvironmentDoctor(terminalContext));
134
152
  return 0;
135
153
  }
136
154
  if (command === "explain") {
@@ -195,25 +213,68 @@ export async function runCli(args, io = defaultIo, options = {}) {
195
213
  ].join("\n"));
196
214
  return 0;
197
215
  }
216
+ if (command === "init-ci") {
217
+ const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
218
+ const result = await initCiWorkflow(targetPath);
219
+ io.writeStdout([
220
+ "Initialized Codex Plugin Doctor workflow",
221
+ `Root: ${result.rootPath}`,
222
+ `Workflow: ${result.workflowPath}`
223
+ ].join("\n"));
224
+ return 0;
225
+ }
198
226
  if (command === "fix") {
199
227
  if (!maybePath || maybePath.startsWith("--")) {
200
- io.writeStderr("Missing target path. Usage: codex-plugin-doctor fix <path> (--dry-run|--apply --backup)");
228
+ io.writeStderr("Missing target path. Usage: codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)");
201
229
  return 2;
202
230
  }
203
231
  const dryRun = remainingArgs.includes("--dry-run");
204
232
  const apply = remainingArgs.includes("--apply");
233
+ const interactive = remainingArgs.includes("--interactive");
205
234
  const backup = remainingArgs.includes("--backup");
206
- if (apply && !backup) {
207
- io.writeStderr("Fix apply requires --backup.");
235
+ const jsonOutput = remainingArgs.includes("--json");
236
+ if ((apply || interactive) && !backup) {
237
+ io.writeStderr("Fix mode requires --backup.");
238
+ return 2;
239
+ }
240
+ if ([dryRun, apply, interactive].filter(Boolean).length !== 1) {
241
+ io.writeStderr("Choose exactly one fix mode: --dry-run, --interactive --backup, or --apply --backup.");
208
242
  return 2;
209
243
  }
210
- if (dryRun === apply) {
211
- io.writeStderr("Choose exactly one fix mode: --dry-run or --apply --backup.");
244
+ if (interactive && jsonOutput) {
245
+ io.writeStderr("Interactive fix mode does not support --json.");
212
246
  return 2;
213
247
  }
214
- io.writeStdout(dryRun
215
- ? renderFixPlan(await buildFixPlan(maybePath), "dry-run")
216
- : renderApplyFixResult(await applyFixPlan(maybePath)));
248
+ if (dryRun) {
249
+ const plan = await buildFixPlan(maybePath);
250
+ io.writeStdout(jsonOutput
251
+ ? renderFixPlanJsonReport(plan, { mode: "dry-run" })
252
+ : renderFixPlan(plan, "dry-run"));
253
+ return 0;
254
+ }
255
+ if (interactive) {
256
+ const plan = await buildFixPlan(maybePath);
257
+ io.writeStdout([
258
+ renderFixPlan(plan, "interactive"),
259
+ "",
260
+ "Type yes to apply these fixes with a backup. Anything else cancels."
261
+ ].join("\n"));
262
+ const answer = (await io.readStdin?.("Apply fixes? ") ?? "").trim().toLowerCase();
263
+ if (answer !== "yes") {
264
+ io.writeStdout("Fix cancelled. No files changed.");
265
+ return 0;
266
+ }
267
+ io.writeStdout(renderApplyFixResult(await applyFixPlan(maybePath)));
268
+ return 0;
269
+ }
270
+ const result = await applyFixPlan(maybePath);
271
+ io.writeStdout(jsonOutput
272
+ ? renderFixPlanJsonReport(result.plan, {
273
+ mode: "apply",
274
+ filesChanged: result.filesChanged,
275
+ backupDirectory: result.backupDirectory
276
+ })
277
+ : renderApplyFixResult(result));
217
278
  return 0;
218
279
  }
219
280
  if (command === "compat") {
@@ -226,6 +287,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
226
287
  const installPreview = compatFlags.includes("--install-preview");
227
288
  const applyInstall = compatFlags.includes("--apply");
228
289
  const backupInstall = compatFlags.includes("--backup");
290
+ const allClients = compatFlags.includes("--all");
229
291
  const clientIndex = compatFlags.indexOf("--client");
230
292
  const clientFilter = clientIndex === -1 ? null : compatFlags[clientIndex + 1];
231
293
  const outputIndex = compatFlags.indexOf("--output");
@@ -234,6 +296,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
234
296
  io.writeStderr("Missing client after --client.");
235
297
  return 2;
236
298
  }
299
+ if (allClients && clientFilter) {
300
+ io.writeStderr("Use either --all or --client, not both.");
301
+ return 2;
302
+ }
237
303
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
238
304
  io.writeStderr("Missing path after --output.");
239
305
  return 2;
@@ -241,8 +307,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
241
307
  if ((installPreview || applyInstall) &&
242
308
  clientFilter?.toLowerCase() !== "claude-desktop" &&
243
309
  clientFilter?.toLowerCase() !== "cursor" &&
244
- clientFilter?.toLowerCase() !== "cline") {
245
- io.writeStderr("--install-preview and --apply require --client claude-desktop, cursor, or cline.");
310
+ clientFilter?.toLowerCase() !== "cline" &&
311
+ clientFilter?.toLowerCase() !== "windsurf") {
312
+ io.writeStderr("--install-preview and --apply require --client claude-desktop, cursor, cline, or windsurf.");
246
313
  return 2;
247
314
  }
248
315
  if (installPreview && applyInstall) {
@@ -266,21 +333,30 @@ export async function runCli(args, io = defaultIo, options = {}) {
266
333
  env: terminalContext.env,
267
334
  platform: terminalContext.platform
268
335
  })
269
- : await buildClaudeDesktopInstallPreview(targetPath, {
270
- env: terminalContext.env,
271
- platform: terminalContext.platform
272
- });
336
+ : normalizedClient === "windsurf"
337
+ ? await buildWindsurfInstallPreview(targetPath, {
338
+ env: terminalContext.env,
339
+ platform: terminalContext.platform
340
+ })
341
+ : await buildClaudeDesktopInstallPreview(targetPath, {
342
+ env: terminalContext.env,
343
+ platform: terminalContext.platform
344
+ });
273
345
  const report = applyInstall
274
346
  ? renderApplyInstallResult(await applyInstallPreview(normalizedClient === "cursor"
275
347
  ? "Cursor"
276
348
  : normalizedClient === "cline"
277
349
  ? "Cline"
278
- : "Claude Desktop", preview))
350
+ : normalizedClient === "windsurf"
351
+ ? "Windsurf"
352
+ : "Claude Desktop", preview))
279
353
  : normalizedClient === "cursor"
280
354
  ? renderCursorInstallPreview(preview)
281
355
  : normalizedClient === "cline"
282
356
  ? renderClineInstallPreview(preview)
283
- : renderClaudeDesktopInstallPreview(preview);
357
+ : normalizedClient === "windsurf"
358
+ ? renderWindsurfInstallPreview(preview)
359
+ : renderClaudeDesktopInstallPreview(preview);
284
360
  if (outputPath) {
285
361
  await writeFile(outputPath, report, "utf8");
286
362
  }
@@ -340,6 +416,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
340
416
  const sarifOutput = normalizedFlags.includes("--sarif");
341
417
  const runtimeProbeEnabled = normalizedFlags.includes("--runtime");
342
418
  const verboseRuntime = normalizedFlags.includes("--verbose-runtime");
419
+ const explainFindings = normalizedFlags.includes("--explain");
343
420
  const noAnimations = normalizedFlags.includes("--no-animations");
344
421
  const asciiMode = normalizedFlags.includes("--ascii");
345
422
  const installedSummary = normalizedFlags.includes("--all-summary");
@@ -422,7 +499,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
422
499
  ? buildMarkdownReport(item.result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
423
500
  : jsonOutput
424
501
  ? renderJsonReport(item.result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
425
- : renderTextReport(item.result, { ascii: outputPolicy.style === "ascii" }))
502
+ : renderTextReport(item.result, {
503
+ ascii: outputPolicy.style === "ascii",
504
+ explain: explainFindings
505
+ }))
426
506
  .join("\n\n");
427
507
  if (outputPath) {
428
508
  await writeFile(outputPath, report, "utf8");
@@ -459,7 +539,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
459
539
  ? renderBadgeJson(result)
460
540
  : badgeMarkdownOutput
461
541
  ? renderBadgeMarkdown(result)
462
- : renderTextReport(result, { ascii: outputPolicy.style === "ascii" });
542
+ : renderTextReport(result, {
543
+ ascii: outputPolicy.style === "ascii",
544
+ explain: explainFindings
545
+ });
463
546
  if (outputPath) {
464
547
  await writeFile(outputPath, report, "utf8");
465
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -26,6 +26,7 @@
26
26
  "generate-validation-artifacts": "node scripts/generate-validation-artifacts.mjs",
27
27
  "prepare-rc": "tsx scripts/prepare-release-candidate.ts",
28
28
  "prepare-release": "npm test && npm run build && npm pack --dry-run",
29
+ "release-check": "node scripts/release-check.mjs",
29
30
  "prepublishOnly": "npm test && npm run build",
30
31
  "test": "vitest run",
31
32
  "test:watch": "vitest"