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 +44 -28
- package/dist/compatibility/cline-install-preview.d.ts +10 -0
- package/dist/compatibility/cline-install-preview.js +65 -0
- package/dist/compatibility/compatibility-matrix.d.ts +2 -0
- package/dist/compatibility/compatibility-matrix.js +159 -1
- package/dist/compatibility/windsurf-install-preview.d.ts +10 -0
- package/dist/compatibility/windsurf-install-preview.js +65 -0
- package/dist/core/environment-doctor.d.ts +19 -0
- package/dist/core/environment-doctor.js +62 -0
- package/dist/core/fix-plan.d.ts +35 -0
- package/dist/core/fix-plan.js +241 -0
- package/dist/core/init-ci.d.ts +5 -0
- package/dist/core/init-ci.js +36 -0
- package/dist/run-cli.js +135 -23
- package/package.json +1 -1
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
|
|
167
|
-
codex-plugin-doctor
|
|
168
|
-
codex-plugin-doctor compat .
|
|
169
|
-
codex-plugin-doctor compat . --client
|
|
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 . --
|
|
177
|
-
codex-plugin-doctor compat . --
|
|
178
|
-
codex-plugin-doctor compat . --
|
|
179
|
-
codex-plugin-doctor
|
|
180
|
-
codex-plugin-doctor
|
|
181
|
-
codex-plugin-doctor check .
|
|
182
|
-
codex-plugin-doctor check . --
|
|
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
|
-
`
|
|
200
|
-
|
|
201
|
-
`compat --client
|
|
202
|
-
|
|
203
|
-
`compat --
|
|
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
|
-
|
|
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.
|
|
258
|
+
- uses: Esquetta/CodexPluginDoctor@v0.8.0
|
|
243
259
|
with:
|
|
244
|
-
version: "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,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
|
-
|
|
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
|
|
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
|
-
:
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
221
|
-
|
|
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
|
-
:
|
|
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:
|
|
338
|
-
runtimeTranscript:
|
|
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:
|
|
368
|
-
runtimeTranscript:
|
|
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, {
|
|
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