codex-plugin-doctor 0.6.0 → 0.8.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
@@ -162,24 +162,30 @@ Run these from a Codex plugin package root:
162
162
 
163
163
  ```bash
164
164
  codex-plugin-doctor --version
165
- codex-plugin-doctor self-test
166
- codex-plugin-doctor init my-plugin
167
- codex-plugin-doctor compat .
168
- codex-plugin-doctor compat . --client codex
169
- codex-plugin-doctor compat . --client generic-mcp
165
+ codex-plugin-doctor self-test
166
+ codex-plugin-doctor doctor
167
+ codex-plugin-doctor init my-plugin
168
+ codex-plugin-doctor compat .
169
+ codex-plugin-doctor compat . --client codex
170
+ codex-plugin-doctor compat . --client generic-mcp
170
171
  codex-plugin-doctor compat . --client claude-desktop
171
172
  codex-plugin-doctor compat . --client claude-desktop --install-preview
172
173
  codex-plugin-doctor compat . --client claude-desktop --apply --backup
173
- codex-plugin-doctor compat . --client cursor
174
- codex-plugin-doctor compat . --client cursor --install-preview
175
- codex-plugin-doctor compat . --client cursor --apply --backup
176
- codex-plugin-doctor compat . --scorecard
177
- codex-plugin-doctor compat . --json
178
- codex-plugin-doctor compat . --json --output compatibility.json
179
- codex-plugin-doctor check .
180
- codex-plugin-doctor check . --json
181
- codex-plugin-doctor check . --json --output report.json
182
- codex-plugin-doctor check . --markdown --output report.md
174
+ codex-plugin-doctor compat . --client cursor
175
+ codex-plugin-doctor compat . --client cursor --install-preview
176
+ codex-plugin-doctor compat . --client cursor --apply --backup
177
+ codex-plugin-doctor compat . --client cline
178
+ codex-plugin-doctor compat . --client cline --install-preview
179
+ codex-plugin-doctor compat . --scorecard
180
+ codex-plugin-doctor compat . --json
181
+ codex-plugin-doctor compat . --json --output compatibility.json
182
+ codex-plugin-doctor check .
183
+ codex-plugin-doctor check . --profile ci
184
+ codex-plugin-doctor check . --profile strict
185
+ codex-plugin-doctor check . --profile publish
186
+ codex-plugin-doctor check . --json
187
+ codex-plugin-doctor check . --json --output report.json
188
+ codex-plugin-doctor check . --markdown --output report.md
183
189
  codex-plugin-doctor check . --badge-json --output doctor-badge.json
184
190
  codex-plugin-doctor check . --badge-markdown
185
191
  codex-plugin-doctor check . --sarif --output results.sarif
@@ -191,22 +197,32 @@ codex-plugin-doctor check . --history validation-history.jsonl
191
197
  codex-plugin-doctor history validation-history.jsonl
192
198
  codex-plugin-doctor history validation-history.jsonl --json
193
199
  codex-plugin-doctor history validation-history.jsonl --fail-on-regression
200
+ codex-plugin-doctor fix . --dry-run
201
+ codex-plugin-doctor fix . --apply --backup
194
202
  codex-plugin-doctor check . --json --runtime --verbose-runtime
195
203
  ```
196
-
197
- `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`.
198
-
199
- `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.
200
-
201
- `compat --client cursor` checks whether the MCP package can be added to Cursor. It prefers a project-level `.cursor/mcp.json` when one already exists in the target package, then falls back to the global `~/.cursor/mcp.json` path. A valid existing config returns `PASS`, a missing Cursor config returns `WARN`, malformed JSON returns `FAIL`, and duplicate MCP server names return `WARN`. Add `--install-preview` to print the JSON snippet that should be merged into Cursor's `mcp.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.
202
-
203
- `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`.
204
-
204
+
205
+ `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
+
207
+ `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility.
208
+
209
+ `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
+
211
+ `compat --client cursor` checks whether the MCP package can be added to Cursor. It prefers a project-level `.cursor/mcp.json` when one already exists in the target package, then falls back to the global `~/.cursor/mcp.json` path. A valid existing config returns `PASS`, a missing Cursor config returns `WARN`, malformed JSON returns `FAIL`, and duplicate MCP server names return `WARN`. Add `--install-preview` to print the JSON snippet that should be merged into Cursor's `mcp.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.
212
+
213
+ `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
+
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`.
216
+
217
+ `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
+
205
219
  `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`.
206
220
 
207
221
  `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.
208
-
209
- Optional local policy file:
222
+
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.
224
+
225
+ Optional local policy file:
210
226
 
211
227
  ```json
212
228
  {
@@ -239,9 +255,9 @@ jobs:
239
255
  runs-on: ubuntu-latest
240
256
  steps:
241
257
  - uses: actions/checkout@v4
242
- - uses: Esquetta/CodexPluginDoctor@v0.6.0
258
+ - uses: Esquetta/CodexPluginDoctor@v0.8.0
243
259
  with:
244
- version: "0.6.0"
260
+ version: "0.8.0"
245
261
  path: .
246
262
  runtime: "false"
247
263
  ```
@@ -0,0 +1,10 @@
1
+ import { type CompatibilityEnvironment } from "./compatibility-matrix.js";
2
+ export interface ClineInstallPreview {
3
+ targetPath: string;
4
+ configPath: string;
5
+ snippet: {
6
+ mcpServers: Record<string, unknown>;
7
+ };
8
+ }
9
+ export declare function buildClineInstallPreview(targetPath: string, environment?: CompatibilityEnvironment): Promise<ClineInstallPreview>;
10
+ export declare function renderClineInstallPreview(preview: ClineInstallPreview): string;
@@ -0,0 +1,65 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getClineMcpConfigPath, 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 buildClineInstallPreview(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: getClineMcpConfigPath(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 renderClineInstallPreview(preview) {
54
+ return [
55
+ "Cline Install Preview",
56
+ "=====================",
57
+ `Target: ${preview.targetPath}`,
58
+ `Config: ${preview.configPath}`,
59
+ "",
60
+ "Add or merge this snippet into `cline_mcp_settings.json`:",
61
+ JSON.stringify(preview.snippet, null, 2),
62
+ "",
63
+ "No files were modified."
64
+ ].join("\n");
65
+ }
@@ -17,5 +17,7 @@ export interface CompatibilityEnvironment {
17
17
  export declare function readMcpConfigPath(targetPath: string): Promise<string | null>;
18
18
  export declare function getClaudeDesktopConfigPath(environment?: CompatibilityEnvironment): string | null;
19
19
  export declare function getCursorMcpConfigPath(targetPath: string, environment?: CompatibilityEnvironment): Promise<string>;
20
+ export declare function getClineMcpConfigPath(environment?: CompatibilityEnvironment): string;
21
+ export declare function getWindsurfMcpConfigPath(environment?: CompatibilityEnvironment): string;
20
22
  export declare function buildCompatibilityMatrix(targetPath: string, environment?: CompatibilityEnvironment): Promise<CompatibilityMatrix>;
21
23
  export declare function matrixExitCode(matrix: CompatibilityMatrix): 0 | 1;
@@ -134,6 +134,16 @@ export async function getCursorMcpConfigPath(targetPath, environment = {}) {
134
134
  }
135
135
  return path.join(getHomeDirectory(environment), ".cursor", "mcp.json");
136
136
  }
137
+ export function getClineMcpConfigPath(environment = {}) {
138
+ const env = environment.env ?? process.env;
139
+ const clineDirectory = env.CLINE_DIR
140
+ ? path.resolve(env.CLINE_DIR)
141
+ : path.join(getHomeDirectory(environment), ".cline");
142
+ return path.join(clineDirectory, "data", "settings", "cline_mcp_settings.json");
143
+ }
144
+ export function getWindsurfMcpConfigPath(environment = {}) {
145
+ return path.join(getHomeDirectory(environment), ".codeium", "windsurf", "mcp_config.json");
146
+ }
137
147
  async function checkClaudeDesktop(targetPath, genericMcpResult, environment = {}) {
138
148
  if (genericMcpResult.status !== "pass") {
139
149
  return {
@@ -286,11 +296,157 @@ async function checkCursor(targetPath, genericMcpResult, environment = {}) {
286
296
  };
287
297
  }
288
298
  }
299
+ async function checkCline(targetPath, genericMcpResult, environment = {}) {
300
+ if (genericMcpResult.status !== "pass") {
301
+ return {
302
+ client: "Cline",
303
+ status: "skipped",
304
+ summary: "No valid MCP package config is available for Cline.",
305
+ details: ["Add a valid `.mcp.json` with a non-empty `mcpServers` object first."]
306
+ };
307
+ }
308
+ const configPath = getClineMcpConfigPath(environment);
309
+ if (!(await fileExists(configPath))) {
310
+ const configDirectory = path.dirname(configPath);
311
+ return {
312
+ client: "Cline",
313
+ status: await directoryExists(configDirectory) ? "pass" : "warn",
314
+ summary: await directoryExists(configDirectory)
315
+ ? "Cline MCP settings directory exists and a config file can be created."
316
+ : "Cline was not detected on this machine.",
317
+ details: [
318
+ configPath,
319
+ "Cline stores MCP servers in `cline_mcp_settings.json` under its settings directory."
320
+ ]
321
+ };
322
+ }
323
+ try {
324
+ const parsed = await readJsonFile(configPath);
325
+ const servers = parsed.mcpServers;
326
+ if (servers !== undefined && (typeof servers !== "object" ||
327
+ servers === null ||
328
+ Array.isArray(servers))) {
329
+ return {
330
+ client: "Cline",
331
+ status: "fail",
332
+ summary: "Cline MCP config has an invalid `mcpServers` shape.",
333
+ details: [configPath, "`mcpServers` must be an object before this package can be added safely."]
334
+ };
335
+ }
336
+ const packageServerNames = await readMcpServerNames(targetPath);
337
+ const existingServerNames = typeof servers === "object" && servers !== null
338
+ ? Object.keys(servers)
339
+ : [];
340
+ const duplicateServerNames = packageServerNames.filter((serverName) => existingServerNames.includes(serverName));
341
+ if (duplicateServerNames.length > 0) {
342
+ return {
343
+ client: "Cline",
344
+ status: "warn",
345
+ summary: "Cline already has MCP server names from this package.",
346
+ details: [
347
+ configPath,
348
+ ...duplicateServerNames.map((serverName) => `Duplicate server: ${serverName}`)
349
+ ]
350
+ };
351
+ }
352
+ return {
353
+ client: "Cline",
354
+ status: "pass",
355
+ summary: "Cline MCP config is valid and this package can be added.",
356
+ details: [
357
+ configPath,
358
+ `Source package: ${path.resolve(targetPath)}`
359
+ ]
360
+ };
361
+ }
362
+ catch {
363
+ return {
364
+ client: "Cline",
365
+ status: "fail",
366
+ summary: "Cline MCP config is not valid JSON.",
367
+ details: [configPath, "Repair the local Cline MCP config before adding new MCP servers."]
368
+ };
369
+ }
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
+ }
289
443
  export async function buildCompatibilityMatrix(targetPath, environment = {}) {
290
444
  const rootPath = path.resolve(targetPath);
291
445
  const genericMcpResult = await checkGenericMcp(rootPath);
292
446
  const claudeDesktopResult = await checkClaudeDesktop(rootPath, genericMcpResult, environment);
293
447
  const cursorResult = await checkCursor(rootPath, genericMcpResult, environment);
448
+ const clineResult = await checkCline(rootPath, genericMcpResult, environment);
449
+ const windsurfResult = await checkWindsurf(rootPath, genericMcpResult, environment);
294
450
  const codexResult = await validatePlugin(rootPath);
295
451
  const codexStatus = statusFromCheckResult(codexResult);
296
452
  const codexCompatibility = !await hasCodexManifest(rootPath)
@@ -313,7 +469,9 @@ export async function buildCompatibilityMatrix(targetPath, environment = {}) {
313
469
  codexCompatibility,
314
470
  genericMcpResult,
315
471
  claudeDesktopResult,
316
- cursorResult
472
+ cursorResult,
473
+ clineResult,
474
+ windsurfResult
317
475
  ];
318
476
  return {
319
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
+ }
@@ -0,0 +1,19 @@
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
+ }
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>;
@@ -0,0 +1,62 @@
1
+ import { access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { packageVersion } from "../version.js";
4
+ async function pathExists(targetPath) {
5
+ try {
6
+ await access(targetPath);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function resolveCodexHome(env) {
14
+ if (env.CODEX_HOME) {
15
+ return path.resolve(env.CODEX_HOME);
16
+ }
17
+ if (env.USERPROFILE) {
18
+ return path.join(env.USERPROFILE, ".codex");
19
+ }
20
+ if (env.HOME) {
21
+ return path.join(env.HOME, ".codex");
22
+ }
23
+ return null;
24
+ }
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
+ ].join("\n");
37
+ }
38
+ export async function buildEnvironmentDoctorReport(terminalContext) {
39
+ const codexHome = resolveCodexHome(terminalContext.env);
40
+ const codexHomeExists = codexHome ? await pathExists(codexHome) : false;
41
+ const pluginCache = codexHome ? path.join(codexHome, "plugins", "cache") : null;
42
+ const pluginCacheExists = pluginCache ? await pathExists(pluginCache) : false;
43
+ const npmPrefix = terminalContext.env.npm_config_prefix ?? "(unknown)";
44
+ return {
45
+ schemaVersion: "1.0.0",
46
+ version: packageVersion,
47
+ platform: terminalContext.platform ?? process.platform,
48
+ node: process.version,
49
+ npmGlobalPrefix: npmPrefix,
50
+ codexHome: {
51
+ status: codexHomeExists ? "pass" : "warn",
52
+ path: codexHome
53
+ },
54
+ codexPluginCache: {
55
+ status: pluginCacheExists ? "pass" : "warn",
56
+ path: pluginCache
57
+ }
58
+ };
59
+ }
60
+ export async function renderEnvironmentDoctorJson(terminalContext) {
61
+ return JSON.stringify(await buildEnvironmentDoctorReport(terminalContext), null, 2);
62
+ }
@@ -0,0 +1,35 @@
1
+ export interface FixPlanAction {
2
+ id: string;
3
+ title: string;
4
+ targetPath: string;
5
+ operation: "update-json" | "mkdir";
6
+ details: string;
7
+ }
8
+ export interface FixPlan {
9
+ targetPath: string;
10
+ actions: FixPlanAction[];
11
+ }
12
+ export interface ApplyFixPlanResult {
13
+ plan: FixPlan;
14
+ filesChanged: number;
15
+ backupDirectory: string;
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
+ }
27
+ export declare function buildFixPlan(targetPath: string): Promise<FixPlan>;
28
+ export declare function renderFixPlan(plan: FixPlan, mode: "dry-run"): string;
29
+ export declare function applyFixPlan(targetPath: string): Promise<ApplyFixPlanResult>;
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;
@@ -0,0 +1,241 @@
1
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { validatePlugin } from "./validate-plugin.js";
4
+ function relativeToTarget(targetPath, candidatePath) {
5
+ return path.relative(targetPath, candidatePath).replace(/\\/g, "/");
6
+ }
7
+ export async function buildFixPlan(targetPath) {
8
+ const result = await validatePlugin(targetPath);
9
+ const rootPath = result.targetPath;
10
+ const actions = [];
11
+ const findingIds = new Set(result.findings.map((finding) => finding.id));
12
+ const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
13
+ if (findingIds.has("plugin.manifest.version.missing") ||
14
+ findingIds.has("plugin.manifest.description.missing")) {
15
+ const fields = [
16
+ findingIds.has("plugin.manifest.version.missing") ? "`version`" : null,
17
+ findingIds.has("plugin.manifest.description.missing") ? "`description`" : null
18
+ ].filter(Boolean);
19
+ actions.push({
20
+ id: "manifest.safe_defaults",
21
+ title: "Add missing safe manifest defaults",
22
+ targetPath: manifestPath,
23
+ operation: "update-json",
24
+ details: `Set ${fields.join(" and ")} in ${relativeToTarget(rootPath, manifestPath)}.`
25
+ });
26
+ }
27
+ if (findingIds.has("plugin.skills.path.missing")) {
28
+ actions.push({
29
+ id: "skills.create_directory",
30
+ title: "Create missing skills directory",
31
+ targetPath: path.join(rootPath, "skills"),
32
+ operation: "mkdir",
33
+ details: "Create the skills directory referenced by the manifest."
34
+ });
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
+ }
83
+ return {
84
+ targetPath: rootPath,
85
+ actions
86
+ };
87
+ }
88
+ export function renderFixPlan(plan, mode) {
89
+ const lines = [
90
+ "Fix Plan",
91
+ "========",
92
+ `Mode: ${mode}`,
93
+ `Target: ${plan.targetPath}`
94
+ ];
95
+ if (plan.actions.length === 0) {
96
+ lines.push("", "No safe automatic fixes available.");
97
+ return lines.join("\n");
98
+ }
99
+ lines.push("", "Actions");
100
+ lines.push("-------");
101
+ plan.actions.forEach((action, index) => {
102
+ lines.push(`${index + 1}. ${action.title}`);
103
+ lines.push(` Path: ${relativeToTarget(plan.targetPath, action.targetPath)}`);
104
+ lines.push(` Operation: ${action.operation}`);
105
+ lines.push(` Details: ${action.details}`);
106
+ });
107
+ lines.push("", "No files changed.");
108
+ return lines.join("\n");
109
+ }
110
+ function timestampForPath() {
111
+ return new Date().toISOString().replace(/[:.]/g, "-");
112
+ }
113
+ function defaultManifestDescription() {
114
+ return "Codex plugin package.";
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
+ }
164
+ async function backupFile(rootPath, backupDirectory, filePath) {
165
+ const relativePath = path.relative(rootPath, filePath);
166
+ const backupPath = path.join(backupDirectory, relativePath);
167
+ await mkdir(path.dirname(backupPath), { recursive: true });
168
+ await copyFile(filePath, backupPath);
169
+ }
170
+ export async function applyFixPlan(targetPath) {
171
+ const plan = await buildFixPlan(targetPath);
172
+ const backupDirectory = path.join(plan.targetPath, ".codex-doctor-backups", timestampForPath());
173
+ let filesChanged = 0;
174
+ for (const action of plan.actions) {
175
+ if (action.operation === "update-json" && action.id === "manifest.safe_defaults") {
176
+ await backupFile(plan.targetPath, backupDirectory, action.targetPath);
177
+ const manifest = JSON.parse(await readFile(action.targetPath, "utf8"));
178
+ if (typeof manifest.version !== "string" || manifest.version.trim() === "") {
179
+ manifest.version = "0.1.0";
180
+ }
181
+ if (typeof manifest.description !== "string" ||
182
+ manifest.description.trim() === "") {
183
+ manifest.description = defaultManifestDescription();
184
+ }
185
+ await writeFile(action.targetPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
186
+ filesChanged += 1;
187
+ continue;
188
+ }
189
+ if (action.operation === "mkdir" && action.id === "skills.create_directory") {
190
+ await mkdir(action.targetPath, { recursive: true });
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;
210
+ }
211
+ }
212
+ return {
213
+ plan,
214
+ filesChanged,
215
+ backupDirectory
216
+ };
217
+ }
218
+ export function renderApplyFixResult(result) {
219
+ return [
220
+ "Fix Plan",
221
+ "========",
222
+ "Mode: apply",
223
+ `Target: ${result.plan.targetPath}`,
224
+ `Files changed: ${result.filesChanged}`,
225
+ `Backup: ${relativeToTarget(result.plan.targetPath, result.backupDirectory)}`
226
+ ].join("\n");
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
+ }
package/dist/run-cli.js CHANGED
@@ -7,7 +7,12 @@ import { buildCompatibilityMatrix, matrixExitCode } from "./compatibility/compat
7
7
  import { applyInstallPreview, renderApplyInstallResult } from "./compatibility/apply-install-preview.js";
8
8
  import { buildClaudeDesktopInstallPreview, renderClaudeDesktopInstallPreview } from "./compatibility/claude-desktop-install-preview.js";
9
9
  import { buildCursorInstallPreview, renderCursorInstallPreview } from "./compatibility/cursor-install-preview.js";
10
+ import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibility/cline-install-preview.js";
11
+ import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
10
12
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
13
+ import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
14
+ import { renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
15
+ import { initCiWorkflow } from "./core/init-ci.js";
11
16
  import { initPluginPackage } from "./core/init-plugin.js";
12
17
  import { runCheck } from "./index.js";
13
18
  import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
@@ -34,7 +39,7 @@ const defaultIo = {
34
39
  }
35
40
  };
36
41
  function printUsage(io) {
37
- 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 history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
42
+ 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 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");
38
43
  }
39
44
  function renderInstalledPlugins(plugins) {
40
45
  const lines = [
@@ -60,8 +65,28 @@ const compatibilityClientAliases = {
60
65
  mcp: "Generic MCP",
61
66
  "claude-desktop": "Claude Desktop",
62
67
  claude: "Claude Desktop",
63
- cursor: "Cursor"
68
+ cursor: "Cursor",
69
+ cline: "Cline",
70
+ windsurf: "Windsurf"
64
71
  };
72
+ const checkProfiles = ["ci", "strict", "publish"];
73
+ function parseCheckProfile(value) {
74
+ if (!value) {
75
+ return null;
76
+ }
77
+ return checkProfiles.includes(value)
78
+ ? value
79
+ : null;
80
+ }
81
+ function applyCheckProfile(config, profile) {
82
+ if (profile === "strict" || profile === "publish") {
83
+ return {
84
+ ...config,
85
+ failOnWarnings: true
86
+ };
87
+ }
88
+ return config;
89
+ }
65
90
  function filterCompatibilityMatrix(matrix, clientFilter) {
66
91
  const client = compatibilityClientAliases[clientFilter.toLowerCase()];
67
92
  if (!client) {
@@ -107,6 +132,12 @@ export async function runCli(args, io = defaultIo, options = {}) {
107
132
  io.writeStdout(renderInstalledPlugins(installedPlugins));
108
133
  return 0;
109
134
  }
135
+ if (command === "doctor") {
136
+ io.writeStdout(maybePath === "--json"
137
+ ? await renderEnvironmentDoctorJson(terminalContext)
138
+ : await renderEnvironmentDoctor(terminalContext));
139
+ return 0;
140
+ }
110
141
  if (command === "explain") {
111
142
  if (!maybePath || maybePath.startsWith("--")) {
112
143
  io.writeStderr("Missing finding id. Usage: codex-plugin-doctor explain <finding-id>");
@@ -169,6 +200,50 @@ export async function runCli(args, io = defaultIo, options = {}) {
169
200
  ].join("\n"));
170
201
  return 0;
171
202
  }
203
+ if (command === "init-ci") {
204
+ const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
205
+ const result = await initCiWorkflow(targetPath);
206
+ io.writeStdout([
207
+ "Initialized Codex Plugin Doctor workflow",
208
+ `Root: ${result.rootPath}`,
209
+ `Workflow: ${result.workflowPath}`
210
+ ].join("\n"));
211
+ return 0;
212
+ }
213
+ if (command === "fix") {
214
+ if (!maybePath || maybePath.startsWith("--")) {
215
+ io.writeStderr("Missing target path. Usage: codex-plugin-doctor fix <path> (--dry-run|--apply --backup)");
216
+ return 2;
217
+ }
218
+ const dryRun = remainingArgs.includes("--dry-run");
219
+ const apply = remainingArgs.includes("--apply");
220
+ const backup = remainingArgs.includes("--backup");
221
+ const jsonOutput = remainingArgs.includes("--json");
222
+ if (apply && !backup) {
223
+ io.writeStderr("Fix apply requires --backup.");
224
+ return 2;
225
+ }
226
+ if (dryRun === apply) {
227
+ io.writeStderr("Choose exactly one fix mode: --dry-run or --apply --backup.");
228
+ return 2;
229
+ }
230
+ if (dryRun) {
231
+ const plan = await buildFixPlan(maybePath);
232
+ io.writeStdout(jsonOutput
233
+ ? renderFixPlanJsonReport(plan, { mode: "dry-run" })
234
+ : renderFixPlan(plan, "dry-run"));
235
+ return 0;
236
+ }
237
+ const result = await applyFixPlan(maybePath);
238
+ io.writeStdout(jsonOutput
239
+ ? renderFixPlanJsonReport(result.plan, {
240
+ mode: "apply",
241
+ filesChanged: result.filesChanged,
242
+ backupDirectory: result.backupDirectory
243
+ })
244
+ : renderApplyFixResult(result));
245
+ return 0;
246
+ }
172
247
  if (command === "compat") {
173
248
  const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
174
249
  const compatFlags = maybePath && maybePath.startsWith("--")
@@ -193,8 +268,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
193
268
  }
194
269
  if ((installPreview || applyInstall) &&
195
270
  clientFilter?.toLowerCase() !== "claude-desktop" &&
196
- clientFilter?.toLowerCase() !== "cursor") {
197
- io.writeStderr("--install-preview and --apply require --client claude-desktop or --client cursor.");
271
+ clientFilter?.toLowerCase() !== "cursor" &&
272
+ clientFilter?.toLowerCase() !== "cline" &&
273
+ clientFilter?.toLowerCase() !== "windsurf") {
274
+ io.writeStderr("--install-preview and --apply require --client claude-desktop, cursor, cline, or windsurf.");
198
275
  return 2;
199
276
  }
200
277
  if (installPreview && applyInstall) {
@@ -207,20 +284,41 @@ export async function runCli(args, io = defaultIo, options = {}) {
207
284
  }
208
285
  if (installPreview || applyInstall) {
209
286
  try {
210
- const preview = clientFilter?.toLowerCase() === "cursor"
287
+ const normalizedClient = clientFilter?.toLowerCase();
288
+ const preview = normalizedClient === "cursor"
211
289
  ? await buildCursorInstallPreview(targetPath, {
212
290
  env: terminalContext.env,
213
291
  platform: terminalContext.platform
214
292
  })
215
- : await buildClaudeDesktopInstallPreview(targetPath, {
216
- env: terminalContext.env,
217
- platform: terminalContext.platform
218
- });
293
+ : normalizedClient === "cline"
294
+ ? await buildClineInstallPreview(targetPath, {
295
+ env: terminalContext.env,
296
+ platform: terminalContext.platform
297
+ })
298
+ : normalizedClient === "windsurf"
299
+ ? await buildWindsurfInstallPreview(targetPath, {
300
+ env: terminalContext.env,
301
+ platform: terminalContext.platform
302
+ })
303
+ : await buildClaudeDesktopInstallPreview(targetPath, {
304
+ env: terminalContext.env,
305
+ platform: terminalContext.platform
306
+ });
219
307
  const report = applyInstall
220
- ? renderApplyInstallResult(await applyInstallPreview(clientFilter?.toLowerCase() === "cursor" ? "Cursor" : "Claude Desktop", preview))
221
- : clientFilter?.toLowerCase() === "cursor"
308
+ ? renderApplyInstallResult(await applyInstallPreview(normalizedClient === "cursor"
309
+ ? "Cursor"
310
+ : normalizedClient === "cline"
311
+ ? "Cline"
312
+ : normalizedClient === "windsurf"
313
+ ? "Windsurf"
314
+ : "Claude Desktop", preview))
315
+ : normalizedClient === "cursor"
222
316
  ? renderCursorInstallPreview(preview)
223
- : renderClaudeDesktopInstallPreview(preview);
317
+ : normalizedClient === "cline"
318
+ ? renderClineInstallPreview(preview)
319
+ : normalizedClient === "windsurf"
320
+ ? renderWindsurfInstallPreview(preview)
321
+ : renderClaudeDesktopInstallPreview(preview);
224
322
  if (outputPath) {
225
323
  await writeFile(outputPath, report, "utf8");
226
324
  }
@@ -287,6 +385,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
287
385
  const outputPath = outputIndex === -1 ? null : normalizedFlags[outputIndex + 1];
288
386
  const configIndex = normalizedFlags.indexOf("--config");
289
387
  const configPath = configIndex === -1 ? null : normalizedFlags[configIndex + 1];
388
+ const profileIndex = normalizedFlags.indexOf("--profile");
389
+ const profileName = profileIndex === -1 ? null : normalizedFlags[profileIndex + 1];
390
+ const checkProfile = parseCheckProfile(profileName);
290
391
  const historyIndex = normalizedFlags.indexOf("--history");
291
392
  const historyPath = historyIndex === -1 ? null : normalizedFlags[historyIndex + 1];
292
393
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
@@ -297,6 +398,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
297
398
  io.writeStderr("Missing path after --config.");
298
399
  return 2;
299
400
  }
401
+ if (profileIndex !== -1 && (!profileName || profileName.startsWith("--"))) {
402
+ io.writeStderr("Missing profile after --profile.");
403
+ return 2;
404
+ }
405
+ if (profileIndex !== -1 && !checkProfile) {
406
+ io.writeStderr("Unknown profile. Supported profiles: ci, strict, publish.");
407
+ return 2;
408
+ }
300
409
  if (historyIndex !== -1 && (!historyPath || historyPath.startsWith("--"))) {
301
410
  io.writeStderr("Missing path after --history.");
302
411
  return 2;
@@ -309,6 +418,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
309
418
  io.writeStderr("History output requires a single package target.");
310
419
  return 2;
311
420
  }
421
+ const effectiveRuntimeProbeEnabled = runtimeProbeEnabled || checkProfile === "publish";
312
422
  const outputPolicy = determineOutputPolicy({
313
423
  jsonOutput: jsonOutput || badgeJsonOutput,
314
424
  markdownOutput: markdownOutput || badgeMarkdownOutput,
@@ -334,11 +444,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
334
444
  checkedPlugins.push({
335
445
  plugin,
336
446
  result: applyDoctorConfig(await runCheckImpl(plugin.rootPath, {
337
- runtime: runtimeProbeEnabled,
338
- runtimeTranscript: runtimeProbeEnabled && verboseRuntime
447
+ runtime: effectiveRuntimeProbeEnabled,
448
+ runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
339
449
  ? (line) => io.writeStderr(line)
340
450
  : undefined
341
- }), config)
451
+ }), applyCheckProfile(config, checkProfile))
342
452
  });
343
453
  }
344
454
  const report = installedSummary
@@ -347,9 +457,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
347
457
  .map((item) => sarifOutput
348
458
  ? renderSarifReport(item.result)
349
459
  : markdownOutput
350
- ? buildMarkdownReport(item.result, { runtimeProbeEnabled })
460
+ ? buildMarkdownReport(item.result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
351
461
  : jsonOutput
352
- ? renderJsonReport(item.result, { runtimeProbeEnabled })
462
+ ? renderJsonReport(item.result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
353
463
  : renderTextReport(item.result, { ascii: outputPolicy.style === "ascii" }))
354
464
  .join("\n\n");
355
465
  if (outputPath) {
@@ -364,11 +474,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
364
474
  : null;
365
475
  renderer?.start("Validating package");
366
476
  const result = applyDoctorConfig(await runCheckImpl(targetPath, {
367
- runtime: runtimeProbeEnabled,
368
- runtimeTranscript: runtimeProbeEnabled && verboseRuntime
477
+ runtime: effectiveRuntimeProbeEnabled,
478
+ runtimeTranscript: effectiveRuntimeProbeEnabled && verboseRuntime
369
479
  ? (line) => io.writeStderr(line)
370
480
  : undefined
371
- }), await loadDoctorConfig(targetPath, configPath));
481
+ }), applyCheckProfile(await loadDoctorConfig(targetPath, configPath), checkProfile));
372
482
  if (renderer) {
373
483
  if (result.status === "fail") {
374
484
  renderer.stopFailure("Validation failed");
@@ -378,11 +488,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
378
488
  }
379
489
  }
380
490
  const report = markdownOutput
381
- ? buildMarkdownReport(result, { runtimeProbeEnabled })
491
+ ? buildMarkdownReport(result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
382
492
  : sarifOutput
383
493
  ? renderSarifReport(result)
384
494
  : jsonOutput
385
- ? renderJsonReport(result, { runtimeProbeEnabled })
495
+ ? renderJsonReport(result, { runtimeProbeEnabled: effectiveRuntimeProbeEnabled })
386
496
  : badgeJsonOutput
387
497
  ? renderBadgeJson(result)
388
498
  : badgeMarkdownOutput
@@ -392,7 +502,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
392
502
  await writeFile(outputPath, report, "utf8");
393
503
  }
394
504
  if (historyPath) {
395
- await appendValidationHistoryEntry(historyPath, result, { runtimeProbeEnabled });
505
+ await appendValidationHistoryEntry(historyPath, result, {
506
+ runtimeProbeEnabled: effectiveRuntimeProbeEnabled
507
+ });
396
508
  }
397
509
  io.writeStdout(report);
398
510
  return result.exitCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",