codex-plugin-doctor 0.1.3 → 0.1.5

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
@@ -71,11 +71,22 @@ Global install from npm:
71
71
 
72
72
  ```bash
73
73
  npm install -g codex-plugin-doctor
74
+ codex-plugin-doctor --version
74
75
  codex-plugin-doctor check path/to/plugin-package
75
76
  ```
76
77
 
77
78
  Run `codex-plugin-doctor check .` from the root of a Codex plugin package that contains `.codex-plugin/plugin.json`. The Codex Plugin Doctor source repository is not itself a plugin package.
78
79
 
80
+ If you already have Codex installed locally and do not know plugin paths, discover the installed plugin cache:
81
+
82
+ ```bash
83
+ codex-plugin-doctor list --installed
84
+ codex-plugin-doctor check --installed
85
+ codex-plugin-doctor check --installed --all-summary
86
+ codex-plugin-doctor check --installed github
87
+ codex-plugin-doctor explain plugin.manifest.missing
88
+ ```
89
+
79
90
  Run from source:
80
91
 
81
92
  ```bash
@@ -144,16 +155,59 @@ x plugin.security.hard_coded_secret
144
155
  Run these from a Codex plugin package root:
145
156
 
146
157
  ```bash
158
+ codex-plugin-doctor --version
159
+ codex-plugin-doctor init my-plugin
147
160
  codex-plugin-doctor check .
148
161
  codex-plugin-doctor check . --json
149
162
  codex-plugin-doctor check . --json --output report.json
150
163
  codex-plugin-doctor check . --markdown --output report.md
164
+ codex-plugin-doctor check . --sarif --output results.sarif
151
165
  codex-plugin-doctor check . --ascii
152
166
  codex-plugin-doctor check . --no-animations
153
167
  codex-plugin-doctor check . --runtime
168
+ codex-plugin-doctor check . --config .codex-doctor.json
154
169
  codex-plugin-doctor check . --json --runtime --verbose-runtime
155
170
  ```
156
171
 
172
+ Optional local policy file:
173
+
174
+ ```json
175
+ {
176
+ "ignoreRules": ["plugin.heuristic.description.too_long"],
177
+ "failOnWarnings": true
178
+ }
179
+ ```
180
+
181
+ Run these when you want Codex Plugin Doctor to find plugins from the local Codex installation:
182
+
183
+ ```bash
184
+ codex-plugin-doctor list --installed
185
+ codex-plugin-doctor check --installed
186
+ codex-plugin-doctor check --installed --all-summary
187
+ codex-plugin-doctor check --installed github
188
+ codex-plugin-doctor check --installed github --runtime --no-animations
189
+ codex-plugin-doctor explain plugin.security.hard_coded_secret
190
+ ```
191
+
192
+ ## GitHub Action
193
+
194
+ ```yaml
195
+ name: Validate Codex plugin
196
+
197
+ on:
198
+ pull_request:
199
+
200
+ jobs:
201
+ doctor:
202
+ runs-on: ubuntu-latest
203
+ steps:
204
+ - uses: actions/checkout@v4
205
+ - uses: Esquetta/CodexPluginDoctor@v0.1.4
206
+ with:
207
+ path: .
208
+ runtime: "false"
209
+ ```
210
+
157
211
  To self-test this repository after cloning it:
158
212
 
159
213
  ```bash
@@ -177,6 +231,7 @@ The validator is tuned against local fixtures and real marketplace-style plugin
177
231
  - [Real-World Validation Workflow](./docs/engineering/real-world-validation-workflow.md)
178
232
  - [Validation Sessions](./validation-sessions/README.md)
179
233
  - [Examples](./examples/README.md)
234
+ - [Rule Catalog](./docs/rules/catalog.md)
180
235
 
181
236
  Recent validation waves covered:
182
237
 
@@ -0,0 +1,12 @@
1
+ export interface InstalledPlugin {
2
+ name: string;
3
+ version?: string;
4
+ rootPath: string;
5
+ manifestPath: string;
6
+ relativePath: string;
7
+ }
8
+ export interface InstalledPluginDiscoveryOptions {
9
+ env?: Record<string, string | undefined>;
10
+ }
11
+ export declare function discoverInstalledPlugins(options?: InstalledPluginDiscoveryOptions): Promise<InstalledPlugin[]>;
12
+ export declare function filterInstalledPlugins(plugins: InstalledPlugin[], query: string | null): InstalledPlugin[];
@@ -0,0 +1,69 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ async function directoryExists(targetPath) {
5
+ try {
6
+ const details = await stat(targetPath);
7
+ return details.isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function getCodexHomeCandidates(env) {
14
+ const candidates = env.CODEX_HOME
15
+ ? [env.CODEX_HOME]
16
+ : [path.join(os.homedir(), ".codex")];
17
+ return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];
18
+ }
19
+ async function findManifestPaths(rootPath) {
20
+ const entries = await readdir(rootPath, { withFileTypes: true });
21
+ const manifestPaths = [];
22
+ for (const entry of entries) {
23
+ const entryPath = path.join(rootPath, entry.name);
24
+ if (entry.isDirectory()) {
25
+ if (entry.name === ".codex-plugin") {
26
+ manifestPaths.push(path.join(entryPath, "plugin.json"));
27
+ continue;
28
+ }
29
+ manifestPaths.push(...(await findManifestPaths(entryPath)));
30
+ }
31
+ }
32
+ return manifestPaths;
33
+ }
34
+ export async function discoverInstalledPlugins(options = {}) {
35
+ const env = options.env ?? process.env;
36
+ const plugins = [];
37
+ for (const codexHome of getCodexHomeCandidates(env)) {
38
+ const cacheRoot = path.join(codexHome, "plugins", "cache");
39
+ if (!(await directoryExists(cacheRoot))) {
40
+ continue;
41
+ }
42
+ for (const manifestPath of await findManifestPaths(cacheRoot)) {
43
+ try {
44
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
45
+ const rootPath = path.dirname(path.dirname(manifestPath));
46
+ const relativePath = path.relative(cacheRoot, rootPath);
47
+ plugins.push({
48
+ name: typeof manifest.name === "string" ? manifest.name : path.basename(rootPath),
49
+ version: typeof manifest.version === "string" ? manifest.version : undefined,
50
+ rootPath,
51
+ manifestPath,
52
+ relativePath
53
+ });
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ }
59
+ }
60
+ return plugins.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
61
+ }
62
+ export function filterInstalledPlugins(plugins, query) {
63
+ if (!query) {
64
+ return plugins;
65
+ }
66
+ const normalizedQuery = query.toLowerCase();
67
+ return plugins.filter((plugin) => [plugin.name, plugin.relativePath, plugin.rootPath]
68
+ .some((value) => value.toLowerCase().includes(normalizedQuery)));
69
+ }
@@ -0,0 +1,7 @@
1
+ import type { CheckResult } from "../domain/types.js";
2
+ export interface DoctorConfig {
3
+ ignoreRules: string[];
4
+ failOnWarnings: boolean;
5
+ }
6
+ export declare function loadDoctorConfig(targetPath: string, explicitConfigPath?: string | null): Promise<DoctorConfig>;
7
+ export declare function applyDoctorConfig(result: CheckResult, config: DoctorConfig): CheckResult;
@@ -0,0 +1,45 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const defaultConfig = {
4
+ ignoreRules: [],
5
+ failOnWarnings: false
6
+ };
7
+ function isStringArray(value) {
8
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
9
+ }
10
+ export async function loadDoctorConfig(targetPath, explicitConfigPath) {
11
+ const configPath = explicitConfigPath
12
+ ? path.resolve(explicitConfigPath)
13
+ : path.join(path.resolve(targetPath), ".codex-doctor.json");
14
+ try {
15
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
16
+ return {
17
+ ignoreRules: isStringArray(parsed.ignoreRules)
18
+ ? parsed.ignoreRules
19
+ : defaultConfig.ignoreRules,
20
+ failOnWarnings: typeof parsed.failOnWarnings === "boolean"
21
+ ? parsed.failOnWarnings
22
+ : defaultConfig.failOnWarnings
23
+ };
24
+ }
25
+ catch {
26
+ return defaultConfig;
27
+ }
28
+ }
29
+ export function applyDoctorConfig(result, config) {
30
+ const findings = result.findings.filter((finding) => !config.ignoreRules.includes(finding.id));
31
+ const hasFailures = findings.some((finding) => finding.severity === "fail");
32
+ const hasWarnings = findings.some((finding) => finding.severity === "warn");
33
+ const warningFailure = config.failOnWarnings && hasWarnings;
34
+ const status = hasFailures || warningFailure
35
+ ? "fail"
36
+ : hasWarnings
37
+ ? "warn"
38
+ : "pass";
39
+ return {
40
+ ...result,
41
+ status,
42
+ exitCode: status === "fail" ? 1 : 0,
43
+ findings
44
+ };
45
+ }
@@ -0,0 +1,6 @@
1
+ export interface InitPluginResult {
2
+ rootPath: string;
3
+ manifestPath: string;
4
+ skillPath: string;
5
+ }
6
+ export declare function initPluginPackage(targetPath: string): Promise<InitPluginResult>;
@@ -0,0 +1,39 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ function toPackageName(inputPath) {
4
+ return path.basename(path.resolve(inputPath))
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9-]+/g, "-")
7
+ .replace(/^-+|-+$/g, "") || "codex-plugin";
8
+ }
9
+ export async function initPluginPackage(targetPath) {
10
+ const rootPath = path.resolve(targetPath);
11
+ const manifestDirectory = path.join(rootPath, ".codex-plugin");
12
+ const skillsDirectory = path.join(rootPath, "skills", "hello");
13
+ const manifestPath = path.join(manifestDirectory, "plugin.json");
14
+ const skillPath = path.join(skillsDirectory, "SKILL.md");
15
+ await mkdir(manifestDirectory, { recursive: true });
16
+ await mkdir(skillsDirectory, { recursive: true });
17
+ await writeFile(manifestPath, `${JSON.stringify({
18
+ name: toPackageName(rootPath),
19
+ version: "0.1.0",
20
+ description: "A Codex plugin package scaffolded by Codex Plugin Doctor.",
21
+ skills: "skills"
22
+ }, null, 2)}\n`, "utf8");
23
+ await writeFile(skillPath, [
24
+ "---",
25
+ "name: hello",
26
+ "description: Use when verifying that this Codex plugin package loads correctly.",
27
+ "---",
28
+ "",
29
+ "# Hello",
30
+ "",
31
+ "This starter skill confirms the plugin package structure is valid.",
32
+ ""
33
+ ].join("\n"), "utf8");
34
+ return {
35
+ rootPath,
36
+ manifestPath,
37
+ skillPath
38
+ };
39
+ }
@@ -131,6 +131,12 @@ function countMatches(input, pattern) {
131
131
  const matches = input.match(pattern);
132
132
  return matches ? matches.length : 0;
133
133
  }
134
+ function extractSupportAssetReferences(content) {
135
+ const references = [...content.matchAll(/`((?:scripts|templates|assets|examples)\/[^`]+)`/g)]
136
+ .map((match) => match[1].trim())
137
+ .filter((reference) => reference.length > 0);
138
+ return [...new Set(references)];
139
+ }
134
140
  function isDescriptionLikelyVerbose(description, mode) {
135
141
  const trimmed = description.trim();
136
142
  const length = trimmed.length;
@@ -252,6 +258,14 @@ async function validateSkillDefinitions(discoveredPackage) {
252
258
  isDescriptionLikelyVerbose(frontmatter.description, "skill")) {
253
259
  findings.push(buildWarning("plugin.heuristic.skill_description.too_long", `The skill \`${skillDirectory.name}\` description is likely too verbose.`, "Overly long skill descriptions increase context cost and reduce the precision of skill matching.", `Shorten the \`description\` field in \`${skillFilePath}\` to a tightly scoped summary.`));
254
260
  }
261
+ for (const supportReference of extractSupportAssetReferences(skillContent)) {
262
+ const supportPath = path.resolve(skillRoot, supportReference);
263
+ const supportFileExists = await fileExists(supportPath);
264
+ const supportDirectoryExists = await directoryExists(supportPath);
265
+ if (!supportFileExists && !supportDirectoryExists) {
266
+ findings.push(buildWarning("plugin.skill.asset_reference.missing", `The skill \`${skillDirectory.name}\` references missing support asset \`${supportReference}\`.`, "Skills that point to missing scripts, templates, assets, or examples are harder to execute and can fail when an agent follows the instructions.", `Create \`${supportPath}\` or update the reference in \`${skillFilePath}\`.`));
267
+ }
268
+ }
255
269
  }
256
270
  return findings;
257
271
  }
@@ -0,0 +1,7 @@
1
+ import type { CheckResult } from "../domain/types.js";
2
+ import type { InstalledPlugin } from "../core/discover-installed-plugins.js";
3
+ export interface InstalledPluginCheckResult {
4
+ plugin: InstalledPlugin;
5
+ result: CheckResult;
6
+ }
7
+ export declare function renderInstalledSummary(checkedPlugins: InstalledPluginCheckResult[]): string;
@@ -0,0 +1,22 @@
1
+ export function renderInstalledSummary(checkedPlugins) {
2
+ const passCount = checkedPlugins.filter((item) => item.result.status === "pass").length;
3
+ const warnCount = checkedPlugins.filter((item) => item.result.status === "warn").length;
4
+ const failCount = checkedPlugins.filter((item) => item.result.status === "fail").length;
5
+ const lines = [
6
+ "Installed Plugin Summary",
7
+ "========================",
8
+ `Checked: ${checkedPlugins.length}`,
9
+ `Pass: ${passCount}`,
10
+ `Warn: ${warnCount}`,
11
+ `Fail: ${failCount}`,
12
+ "",
13
+ "Plugins",
14
+ "-------"
15
+ ];
16
+ for (const item of checkedPlugins) {
17
+ const findingIds = item.result.findings.map((finding) => finding.id);
18
+ const suffix = findingIds.length > 0 ? ` (${findingIds.join(", ")})` : "";
19
+ lines.push(`${item.result.status.toUpperCase()} ${item.plugin.name} - ${item.plugin.relativePath}${suffix}`);
20
+ }
21
+ return lines.join("\n");
22
+ }
@@ -0,0 +1,2 @@
1
+ import type { RuleDefinition } from "../rules/rule-catalog.js";
2
+ export declare function renderRuleExplanation(rule: RuleDefinition): string;
@@ -0,0 +1,26 @@
1
+ export function renderRuleExplanation(rule) {
2
+ return [
3
+ `Rule: ${rule.id}`,
4
+ "==============================",
5
+ `Category: ${rule.category}`,
6
+ `Default severity: ${rule.defaultSeverity}`,
7
+ "",
8
+ "Summary",
9
+ "-------",
10
+ rule.summary,
11
+ "",
12
+ "Why it matters",
13
+ "--------------",
14
+ rule.why,
15
+ "",
16
+ "Suggested fix",
17
+ "-------------",
18
+ rule.fix,
19
+ "",
20
+ "Example",
21
+ "-------",
22
+ rule.example,
23
+ "",
24
+ "Full catalog: docs/rules/catalog.md"
25
+ ].join("\n");
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from "../domain/types.js";
2
+ export declare function renderSarifReport(result: CheckResult): string;
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ import { findRuleDefinition } from "../rules/rule-catalog.js";
3
+ function levelForFinding(finding) {
4
+ return finding.severity === "fail" ? "error" : "warning";
5
+ }
6
+ export function renderSarifReport(result) {
7
+ const rules = [...new Set(result.findings.map((finding) => finding.id))]
8
+ .map((ruleId) => {
9
+ const catalogEntry = findRuleDefinition(ruleId);
10
+ return {
11
+ id: ruleId,
12
+ name: ruleId,
13
+ shortDescription: {
14
+ text: catalogEntry?.summary ?? ruleId
15
+ },
16
+ help: {
17
+ text: catalogEntry
18
+ ? `${catalogEntry.why}\n\nSuggested fix: ${catalogEntry.fix}`
19
+ : "See the Codex Plugin Doctor report for remediation guidance."
20
+ }
21
+ };
22
+ });
23
+ const sarif = {
24
+ version: "2.1.0",
25
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
26
+ runs: [
27
+ {
28
+ tool: {
29
+ driver: {
30
+ name: "Codex Plugin Doctor",
31
+ informationUri: "https://github.com/Esquetta/CodexPluginDoctor",
32
+ rules
33
+ }
34
+ },
35
+ results: result.findings.map((finding) => ({
36
+ ruleId: finding.id,
37
+ level: levelForFinding(finding),
38
+ message: {
39
+ text: `${finding.message} Suggested fix: ${finding.suggestedFix}`
40
+ },
41
+ locations: [
42
+ {
43
+ physicalLocation: {
44
+ artifactLocation: {
45
+ uri: path.basename(result.targetPath)
46
+ }
47
+ }
48
+ }
49
+ ]
50
+ }))
51
+ }
52
+ ]
53
+ };
54
+ return JSON.stringify(sarif, null, 2);
55
+ }
@@ -0,0 +1,13 @@
1
+ export type RuleCategory = "package" | "skill" | "mcp" | "runtime" | "security";
2
+ export type RuleSeverity = "fail" | "warn";
3
+ export interface RuleDefinition {
4
+ id: string;
5
+ category: RuleCategory;
6
+ defaultSeverity: RuleSeverity;
7
+ summary: string;
8
+ why: string;
9
+ fix: string;
10
+ example: string;
11
+ }
12
+ export declare const ruleCatalog: RuleDefinition[];
13
+ export declare function findRuleDefinition(id: string): RuleDefinition | null;
@@ -0,0 +1,194 @@
1
+ export const ruleCatalog = [
2
+ {
3
+ id: "plugin.manifest.missing",
4
+ category: "package",
5
+ defaultSeverity: "fail",
6
+ summary: "The target directory is missing `.codex-plugin/plugin.json`.",
7
+ why: "Codex needs the plugin manifest as the package entry point. Without it, the directory cannot be treated as a plugin package.",
8
+ fix: "Run the doctor against a plugin package root, or create `.codex-plugin/plugin.json` with at least `name`, `version`, and `description`.",
9
+ example: '{ "name": "my-plugin", "version": "0.1.0", "description": "Adds focused Codex workflow helpers." }'
10
+ },
11
+ {
12
+ id: "plugin.manifest.name.missing",
13
+ category: "package",
14
+ defaultSeverity: "fail",
15
+ summary: "The plugin manifest is missing a stable `name` field.",
16
+ why: "Codex and release tooling need a stable package name for display, matching, and diagnostics.",
17
+ fix: "Add a kebab-case `name` field to `.codex-plugin/plugin.json`.",
18
+ example: '{ "name": "github-workflow-doctor" }'
19
+ },
20
+ {
21
+ id: "plugin.manifest.version.missing",
22
+ category: "package",
23
+ defaultSeverity: "fail",
24
+ summary: "The plugin manifest is missing a `version` field.",
25
+ why: "Compatibility checks and release workflows cannot reason about package changes without a version.",
26
+ fix: "Add a semantic `version` field to `.codex-plugin/plugin.json`.",
27
+ example: '{ "version": "0.1.0" }'
28
+ },
29
+ {
30
+ id: "plugin.manifest.description.missing",
31
+ category: "package",
32
+ defaultSeverity: "fail",
33
+ summary: "The plugin manifest is missing a `description` field.",
34
+ why: "Plugin surfaces and reviewers need concise package metadata to understand what the plugin does.",
35
+ fix: "Add a short, specific `description` field to `.codex-plugin/plugin.json`.",
36
+ example: '{ "description": "Validates GitHub PR automation workflows before release." }'
37
+ },
38
+ {
39
+ id: "plugin.heuristic.description.too_long",
40
+ category: "package",
41
+ defaultSeverity: "warn",
42
+ summary: "The plugin manifest description is likely too verbose.",
43
+ why: "Verbose package metadata increases context cost and can dilute plugin discovery quality.",
44
+ fix: "Shorten the manifest description to a precise one- or two-sentence summary.",
45
+ example: "Good: `Audits Codex plugin packages before publishing.`"
46
+ },
47
+ {
48
+ id: "plugin.skills.path.missing",
49
+ category: "skill",
50
+ defaultSeverity: "fail",
51
+ summary: "The manifest points to a missing skills directory.",
52
+ why: "Codex cannot load packaged skills when the manifest references a directory that does not exist.",
53
+ fix: "Create the referenced skills directory or update the `skills` path in `.codex-plugin/plugin.json`.",
54
+ example: '{ "skills": "skills" }'
55
+ },
56
+ {
57
+ id: "plugin.skill.skill_md.missing",
58
+ category: "skill",
59
+ defaultSeverity: "fail",
60
+ summary: "A skill directory does not contain `SKILL.md`.",
61
+ why: "`SKILL.md` is the required entry point for Codex to load skill instructions and metadata.",
62
+ fix: "Add `SKILL.md` with frontmatter containing at least `name` and `description`.",
63
+ example: "---\nname: repo-auditor\ndescription: Use when auditing repository health.\n---"
64
+ },
65
+ {
66
+ id: "plugin.skill.name.missing",
67
+ category: "skill",
68
+ defaultSeverity: "fail",
69
+ summary: "A skill `SKILL.md` file is missing `name` frontmatter.",
70
+ why: "Codex needs a stable skill name for matching, display, and diagnostics.",
71
+ fix: "Add a `name` field to the skill frontmatter.",
72
+ example: "---\nname: release-checker\n---"
73
+ },
74
+ {
75
+ id: "plugin.skill.description.missing",
76
+ category: "skill",
77
+ defaultSeverity: "fail",
78
+ summary: "A skill `SKILL.md` file is missing `description` frontmatter.",
79
+ why: "Skill descriptions drive discovery and implicit matching, so missing descriptions make skills harder to use.",
80
+ fix: "Add a scoped `description` field that says when the skill should be used.",
81
+ example: "---\ndescription: Use when preparing an npm release with verification gates.\n---"
82
+ },
83
+ {
84
+ id: "plugin.heuristic.skill_description.too_long",
85
+ category: "skill",
86
+ defaultSeverity: "warn",
87
+ summary: "A skill description is likely too verbose.",
88
+ why: "Long, vague descriptions increase context cost and reduce skill matching precision.",
89
+ fix: "Shorten the description while keeping concrete triggers, inputs, and output expectations.",
90
+ example: "Good: `Use when creating GitHub Actions release workflows for Node CLIs.`"
91
+ },
92
+ {
93
+ id: "plugin.skill.asset_reference.missing",
94
+ category: "skill",
95
+ defaultSeverity: "warn",
96
+ summary: "A skill references a missing local support asset.",
97
+ why: "Skills that point to missing scripts, templates, assets, or examples can fail when an agent follows the instructions.",
98
+ fix: "Create the referenced support file or update the backticked reference in `SKILL.md`.",
99
+ example: "If `SKILL.md` says `scripts/setup.ps1`, make sure that file exists inside the skill directory."
100
+ },
101
+ {
102
+ id: "plugin.mcp.path.missing",
103
+ category: "mcp",
104
+ defaultSeverity: "fail",
105
+ summary: "The manifest points to a missing `.mcp.json` file.",
106
+ why: "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.",
107
+ fix: "Create the referenced `.mcp.json` file or update the `mcpServers` path in the manifest.",
108
+ example: '{ "mcpServers": ".mcp.json" }'
109
+ },
110
+ {
111
+ id: "plugin.mcp.invalid_json",
112
+ category: "mcp",
113
+ defaultSeverity: "fail",
114
+ summary: "The referenced `.mcp.json` file is not valid JSON.",
115
+ why: "Codex must parse MCP configuration before it can start bundled servers.",
116
+ fix: "Fix the JSON syntax in the referenced `.mcp.json` file.",
117
+ example: '{ "mcpServers": { "doctor": { "command": "node", "args": ["server.js"] } } }'
118
+ },
119
+ {
120
+ id: "plugin.mcp.invalid_shape",
121
+ category: "mcp",
122
+ defaultSeverity: "fail",
123
+ summary: "The `.mcp.json` file does not expose a valid `mcpServers` object.",
124
+ why: "Codex expects MCP configuration to be object-shaped with named server entries.",
125
+ fix: "Define a non-empty top-level `mcpServers` object.",
126
+ example: '{ "mcpServers": { "doctor": { "command": "node", "args": ["server.js"] } } }'
127
+ },
128
+ {
129
+ id: "plugin.mcp.server.invalid",
130
+ category: "mcp",
131
+ defaultSeverity: "fail",
132
+ summary: "An MCP server entry is not an object.",
133
+ why: "Codex cannot interpret server settings unless each server is represented as an object.",
134
+ fix: "Change the server entry to an object with transport options.",
135
+ example: '{ "mcpServers": { "doctor": { "command": "node" } } }'
136
+ },
137
+ {
138
+ id: "plugin.mcp.server.transport.missing",
139
+ category: "mcp",
140
+ defaultSeverity: "fail",
141
+ summary: "An MCP server entry is missing both `command` and `url`.",
142
+ why: "Codex needs either a stdio command or a streamable HTTP URL to connect to a server.",
143
+ fix: "Add `command` for stdio servers or `url` for remote servers.",
144
+ example: '{ "command": "node", "args": ["server.js"] }'
145
+ },
146
+ {
147
+ id: "plugin.security.path_traversal",
148
+ category: "security",
149
+ defaultSeverity: "fail",
150
+ summary: "A manifest path escapes the plugin package root.",
151
+ why: "Paths outside the package root can expose unintended files and make package review unreliable.",
152
+ fix: "Keep manifest paths such as `skills` and `mcpServers` inside the plugin root.",
153
+ example: '{ "skills": "skills", "mcpServers": ".mcp.json" }'
154
+ },
155
+ {
156
+ id: "plugin.security.hard_coded_secret",
157
+ category: "security",
158
+ defaultSeverity: "fail",
159
+ summary: "An MCP server config contains a hard-coded secret-like env value.",
160
+ why: "Bundled credentials can leak through source control, npm packages, logs, or support bundles.",
161
+ fix: "Replace literal secrets with environment references or externally injected secrets.",
162
+ example: '{ "env": { "OPENAI_API_KEY": "${OPENAI_API_KEY}" } }'
163
+ },
164
+ {
165
+ id: "plugin.runtime.exited_early",
166
+ category: "runtime",
167
+ defaultSeverity: "fail",
168
+ summary: "An MCP server exited before the startup probe completed.",
169
+ why: "A server that exits immediately is unlikely to remain available during normal Codex use.",
170
+ fix: "Run the configured command manually, inspect stderr, and fix startup exceptions or missing dependencies.",
171
+ example: "node server.js"
172
+ },
173
+ {
174
+ id: "plugin.runtime.initialize.timeout",
175
+ category: "runtime",
176
+ defaultSeverity: "fail",
177
+ summary: "An MCP server did not answer `initialize` in time.",
178
+ why: "Codex cannot negotiate capabilities with a server that does not complete initialization.",
179
+ fix: "Ensure the server reads JSON-RPC from stdin, writes responses to stdout, and avoids slow startup work.",
180
+ example: "Respond to the `initialize` request before starting expensive background tasks."
181
+ },
182
+ {
183
+ id: "plugin.runtime.protocol.invalid_message",
184
+ category: "runtime",
185
+ defaultSeverity: "fail",
186
+ summary: "An MCP server wrote invalid JSON-RPC data to stdout.",
187
+ why: "MCP stdio transport requires newline-delimited JSON-RPC messages on stdout.",
188
+ fix: "Send logs to stderr and reserve stdout for JSON-RPC protocol messages only.",
189
+ example: "Use `console.error` for diagnostics in Node stdio servers."
190
+ }
191
+ ];
192
+ export function findRuleDefinition(id) {
193
+ return ruleCatalog.find((rule) => rule.id === id) ?? null;
194
+ }
package/dist/run-cli.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { writeFile } from "node:fs/promises";
2
+ import { discoverInstalledPlugins, filterInstalledPlugins } from "./core/discover-installed-plugins.js";
3
+ import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
4
+ import { initPluginPackage } from "./core/init-plugin.js";
2
5
  import { runCheck } from "./index.js";
6
+ import { renderInstalledSummary } from "./reporting/render-installed-summary.js";
3
7
  import { renderJsonReport } from "./reporting/render-json-report.js";
4
8
  import { buildMarkdownReport } from "./reporting/render-markdown-report.js";
9
+ import { renderRuleExplanation } from "./reporting/render-rule-explanation.js";
10
+ import { renderSarifReport } from "./reporting/render-sarif-report.js";
5
11
  import { renderTextReport } from "./reporting/render-text-report.js";
12
+ import { findRuleDefinition } from "./rules/rule-catalog.js";
6
13
  import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
7
14
  import { determineOutputPolicy } from "./terminal/output-policy.js";
8
15
  import { getSpinner } from "./terminal/spinner-registry.js";
16
+ import { packageVersion } from "./version.js";
9
17
  const defaultIo = {
10
18
  writeStdout(message) {
11
19
  process.stdout.write(`${message}\n`);
@@ -15,35 +23,106 @@ const defaultIo = {
15
23
  }
16
24
  };
17
25
  function printUsage(io) {
18
- io.writeStderr("Usage: codex-plugin-doctor check <path> [--json|--markdown] [--output <path>] [--runtime] [--verbose-runtime] [--no-animations] [--ascii]");
26
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--json|--markdown] [--output <path>] [--runtime] [--verbose-runtime] [--no-animations] [--ascii]\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version");
27
+ }
28
+ function renderInstalledPlugins(plugins) {
29
+ const lines = [
30
+ "Installed Codex Plugins",
31
+ "======================="
32
+ ];
33
+ if (plugins.length === 0) {
34
+ lines.push("", "No installed Codex plugins found.");
35
+ return lines.join("\n");
36
+ }
37
+ for (const plugin of plugins) {
38
+ const version = plugin.version ? `@${plugin.version}` : "";
39
+ lines.push("", `- ${plugin.name}${version}`);
40
+ lines.push(` Path: ${plugin.rootPath}`);
41
+ lines.push(` Cache: ${plugin.relativePath}`);
42
+ }
43
+ return lines.join("\n");
19
44
  }
20
45
  export async function runCli(args, io = defaultIo, options = {}) {
21
46
  const [command, maybePath, ...remainingArgs] = args;
47
+ if (command === "--version" || command === "-v" || command === "version") {
48
+ io.writeStdout(packageVersion);
49
+ return 0;
50
+ }
51
+ const terminalContext = options.terminalContext ?? {
52
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
53
+ stderrIsTTY: Boolean(process.stderr.isTTY),
54
+ env: process.env
55
+ };
56
+ if (command === "list" && maybePath === "--installed") {
57
+ const installedPlugins = await discoverInstalledPlugins({
58
+ env: terminalContext.env
59
+ });
60
+ io.writeStdout(renderInstalledPlugins(installedPlugins));
61
+ return 0;
62
+ }
63
+ if (command === "explain") {
64
+ if (!maybePath || maybePath.startsWith("--")) {
65
+ io.writeStderr("Missing finding id. Usage: codex-plugin-doctor explain <finding-id>");
66
+ return 2;
67
+ }
68
+ const rule = findRuleDefinition(maybePath);
69
+ if (!rule) {
70
+ io.writeStderr(`Unknown finding id: ${maybePath}`);
71
+ return 1;
72
+ }
73
+ io.writeStdout(renderRuleExplanation(rule));
74
+ return 0;
75
+ }
76
+ if (command === "init") {
77
+ const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
78
+ const result = await initPluginPackage(targetPath);
79
+ io.writeStdout([
80
+ "Initialized Codex plugin package",
81
+ `Root: ${result.rootPath}`,
82
+ `Manifest: ${result.manifestPath}`,
83
+ `Skill: ${result.skillPath}`,
84
+ "",
85
+ `Next: codex-plugin-doctor check ${result.rootPath}`
86
+ ].join("\n"));
87
+ return 0;
88
+ }
22
89
  if (command !== "check") {
23
90
  printUsage(io);
24
91
  return 2;
25
92
  }
26
- const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
27
- const normalizedFlags = maybePath && maybePath.startsWith("--")
28
- ? [maybePath, ...remainingArgs]
93
+ const checkInstalled = maybePath === "--installed";
94
+ const installedFilter = checkInstalled && remainingArgs[0] && !remainingArgs[0].startsWith("--")
95
+ ? remainingArgs[0]
96
+ : null;
97
+ const flagsAfterInstalledFilter = checkInstalled && installedFilter
98
+ ? remainingArgs.slice(1)
29
99
  : remainingArgs;
100
+ const targetPath = maybePath && !maybePath.startsWith("--") ? maybePath : ".";
101
+ const normalizedFlags = checkInstalled
102
+ ? [maybePath, ...flagsAfterInstalledFilter]
103
+ : maybePath && maybePath.startsWith("--")
104
+ ? [maybePath, ...remainingArgs]
105
+ : remainingArgs;
30
106
  const jsonOutput = normalizedFlags.includes("--json");
31
107
  const markdownOutput = normalizedFlags.includes("--markdown");
108
+ const sarifOutput = normalizedFlags.includes("--sarif");
32
109
  const runtimeProbeEnabled = normalizedFlags.includes("--runtime");
33
110
  const verboseRuntime = normalizedFlags.includes("--verbose-runtime");
34
111
  const noAnimations = normalizedFlags.includes("--no-animations");
35
112
  const asciiMode = normalizedFlags.includes("--ascii");
113
+ const installedSummary = normalizedFlags.includes("--all-summary");
36
114
  const outputIndex = normalizedFlags.indexOf("--output");
37
115
  const outputPath = outputIndex === -1 ? null : normalizedFlags[outputIndex + 1];
116
+ const configIndex = normalizedFlags.indexOf("--config");
117
+ const configPath = configIndex === -1 ? null : normalizedFlags[configIndex + 1];
38
118
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
39
119
  io.writeStderr("Missing path after --output.");
40
120
  return 2;
41
121
  }
42
- const terminalContext = options.terminalContext ?? {
43
- stdoutIsTTY: Boolean(process.stdout.isTTY),
44
- stderrIsTTY: Boolean(process.stderr.isTTY),
45
- env: process.env
46
- };
122
+ if (configIndex !== -1 && (!configPath || configPath.startsWith("--"))) {
123
+ io.writeStderr("Missing path after --config.");
124
+ return 2;
125
+ }
47
126
  const outputPolicy = determineOutputPolicy({
48
127
  jsonOutput,
49
128
  markdownOutput,
@@ -55,17 +134,55 @@ export async function runCli(args, io = defaultIo, options = {}) {
55
134
  env: terminalContext.env
56
135
  });
57
136
  const runCheckImpl = options.runCheckImpl ?? runCheck;
137
+ if (checkInstalled) {
138
+ const installedPlugins = filterInstalledPlugins(await discoverInstalledPlugins({ env: terminalContext.env }), installedFilter);
139
+ if (installedPlugins.length === 0) {
140
+ io.writeStderr(installedFilter
141
+ ? `No installed Codex plugins matched '${installedFilter}'.`
142
+ : "No installed Codex plugins found.");
143
+ return 1;
144
+ }
145
+ const checkedPlugins = [];
146
+ for (const plugin of installedPlugins) {
147
+ const config = await loadDoctorConfig(plugin.rootPath, configPath);
148
+ checkedPlugins.push({
149
+ plugin,
150
+ result: applyDoctorConfig(await runCheckImpl(plugin.rootPath, {
151
+ runtime: runtimeProbeEnabled,
152
+ runtimeTranscript: runtimeProbeEnabled && verboseRuntime
153
+ ? (line) => io.writeStderr(line)
154
+ : undefined
155
+ }), config)
156
+ });
157
+ }
158
+ const report = installedSummary
159
+ ? renderInstalledSummary(checkedPlugins)
160
+ : checkedPlugins
161
+ .map((item) => sarifOutput
162
+ ? renderSarifReport(item.result)
163
+ : markdownOutput
164
+ ? buildMarkdownReport(item.result, { runtimeProbeEnabled })
165
+ : jsonOutput
166
+ ? renderJsonReport(item.result, { runtimeProbeEnabled })
167
+ : renderTextReport(item.result, { ascii: outputPolicy.style === "ascii" }))
168
+ .join("\n\n");
169
+ if (outputPath) {
170
+ await writeFile(outputPath, report, "utf8");
171
+ }
172
+ io.writeStdout(report);
173
+ return checkedPlugins.some((item) => item.result.exitCode === 1) ? 1 : 0;
174
+ }
58
175
  const renderer = outputPolicy.interactive
59
176
  && !verboseRuntime
60
177
  ? createLiveStatusRenderer(io, getSpinner(outputPolicy.style === "ascii" ? "ascii" : "doctor"))
61
178
  : null;
62
179
  renderer?.start("Validating package");
63
- const result = await runCheckImpl(targetPath, {
180
+ const result = applyDoctorConfig(await runCheckImpl(targetPath, {
64
181
  runtime: runtimeProbeEnabled,
65
182
  runtimeTranscript: runtimeProbeEnabled && verboseRuntime
66
183
  ? (line) => io.writeStderr(line)
67
184
  : undefined
68
- });
185
+ }), await loadDoctorConfig(targetPath, configPath));
69
186
  if (renderer) {
70
187
  if (result.status === "fail") {
71
188
  renderer.stopFailure("Validation failed");
@@ -76,9 +193,11 @@ export async function runCli(args, io = defaultIo, options = {}) {
76
193
  }
77
194
  const report = markdownOutput
78
195
  ? buildMarkdownReport(result, { runtimeProbeEnabled })
79
- : jsonOutput
80
- ? renderJsonReport(result, { runtimeProbeEnabled })
81
- : renderTextReport(result, { ascii: outputPolicy.style === "ascii" });
196
+ : sarifOutput
197
+ ? renderSarifReport(result)
198
+ : jsonOutput
199
+ ? renderJsonReport(result, { runtimeProbeEnabled })
200
+ : renderTextReport(result, { ascii: outputPolicy.style === "ascii" });
82
201
  if (outputPath) {
83
202
  await writeFile(outputPath, report, "utf8");
84
203
  }
@@ -0,0 +1 @@
1
+ export declare const packageVersion: string;
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const packageJson = require("../package.json");
4
+ export const packageVersion = packageJson.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",