codex-plugin-doctor 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,6 +176,14 @@ Run these from a Codex plugin package root:
176
176
  codex-plugin-doctor --version
177
177
  codex-plugin-doctor self-test
178
178
  codex-plugin-doctor doctor
179
+ codex-plugin-doctor doctor recommend .
180
+ codex-plugin-doctor doctor recommend . --json --output recommendations.json
181
+ codex-plugin-doctor doctor trust .
182
+ codex-plugin-doctor doctor trust . --json --output trust-score.json
183
+ codex-plugin-doctor doctor perf .
184
+ codex-plugin-doctor doctor perf . --json --output perf.json
185
+ codex-plugin-doctor doctor export --bundle .
186
+ codex-plugin-doctor doctor export --bundle . --output doctor-bundle.json
179
187
  codex-plugin-doctor doctor snapshot
180
188
  codex-plugin-doctor doctor snapshot --json
181
189
  codex-plugin-doctor doctor snapshot --output doctor-snapshot.json
@@ -184,6 +192,8 @@ codex-plugin-doctor doctor --update-check
184
192
  codex-plugin-doctor audit --installed
185
193
  codex-plugin-doctor audit --installed --security --compat
186
194
  codex-plugin-doctor audit --installed --security --compat --json --output local-audit.json
195
+ codex-plugin-doctor audit --installed --security --compat --cache
196
+ codex-plugin-doctor audit --installed --changed --cache
187
197
  codex-plugin-doctor mcp .
188
198
  codex-plugin-doctor mcp . --json
189
199
  codex-plugin-doctor mcp . --json --output mcp-doctor.json
@@ -240,9 +250,9 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
240
250
 
241
251
  `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`.
242
252
 
243
- `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
253
+ `doctor` checks the local environment, including package version, platform, Node version, npm global prefix, Codex home, and Codex plugin cache visibility. The text output also includes recommended next commands for self-test, installed plugin discovery, runtime checks, compatibility scoring, and CI setup. `doctor recommend <path>` turns validation, security, and compatibility signals into a prioritized action plan with blocker, high, medium, and info actions. Add `--json` for automation or `--output recommendations.json` to write the report to disk. `doctor trust <path>` creates a local trust score from package lifecycle scripts, dependency specs, and MCP security findings. Use it before release when you want supply-chain risks summarized as one score. `doctor perf <path>` profiles the shared package analysis pipeline and reports per-stage durations for validation, config, security, compatibility, trust, recommendations, and total runtime. `doctor export --bundle <path>` creates a redacted operator handoff bundle that includes validation JSON, security scorecard data, compatibility matrix, recommendations, and trust score in one file. `doctor snapshot` creates a redacted diagnostics bundle with environment health, client config readiness, installed plugin metadata, and next commands. Add `--json` for machine-readable output or `--output doctor-snapshot.json` to write the bundle to disk. `doctor clients` reports local Codex, Claude Desktop, Cursor, Cline, and Windsurf config readiness. `doctor --update-check` compares the installed CLI version with the latest npm version and prints the upgrade command when a newer release is available.
244
254
 
245
- `audit --installed` runs a local ecosystem audit against every discovered Codex plugin in the installed plugin cache. Add `--security` to include security scorecards, `--compat` to include the all-client compatibility matrix, and `--json --output local-audit.json` when you want a shareable machine-readable report.
255
+ `audit --installed` runs a local ecosystem audit against every discovered Codex plugin in the installed plugin cache. Add `--security` to include security scorecards, `--compat` to include the all-client compatibility matrix, and `--json --output local-audit.json` when you want a shareable machine-readable report. Add `--cache` to reuse unchanged plugin results between runs; add `--changed` to only report plugins whose fingerprint changed since the last cached audit. Use `--cache-file path/to/audit-cache.json` when CI or scripted runs need an explicit cache location.
246
256
 
247
257
  `--policy codex-publish|mcp-strict|security` applies opinionated gates without requiring a local `.codex-doctor.json`. `codex-publish` fails warnings and enables runtime probes for release checks, `mcp-strict` does the same for MCP-heavy packages, and `security` fails warning-level security findings so advisory risks can block a local audit or CI gate.
248
258
 
@@ -0,0 +1,15 @@
1
+ import type { EcosystemAuditPluginResult } from "./ecosystem-audit.js";
2
+ import type { InstalledPlugin } from "../core/discover-installed-plugins.js";
3
+ export interface EcosystemAuditCacheEntry {
4
+ fingerprint: string;
5
+ cachedAt: string;
6
+ result: EcosystemAuditPluginResult;
7
+ }
8
+ export interface EcosystemAuditCacheFile {
9
+ schemaVersion: "1.0.0";
10
+ entries: Record<string, EcosystemAuditCacheEntry>;
11
+ }
12
+ export declare function resolveEcosystemAuditCachePath(env?: Record<string, string | undefined>, cachePath?: string | null): string;
13
+ export declare function loadEcosystemAuditCache(cachePath: string): Promise<EcosystemAuditCacheFile>;
14
+ export declare function writeEcosystemAuditCache(cachePath: string, cache: EcosystemAuditCacheFile): Promise<void>;
15
+ export declare function fingerprintInstalledPlugin(plugin: InstalledPlugin): Promise<string>;
@@ -0,0 +1,78 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ const skippedDirectories = new Set([
6
+ ".git",
7
+ "coverage",
8
+ "dist",
9
+ "node_modules",
10
+ "validation-artifacts-local",
11
+ "validation-sessions"
12
+ ]);
13
+ export function resolveEcosystemAuditCachePath(env = process.env, cachePath) {
14
+ if (cachePath) {
15
+ return path.resolve(cachePath);
16
+ }
17
+ const codexHome = env.CODEX_HOME
18
+ ? path.resolve(env.CODEX_HOME)
19
+ : path.join(os.homedir(), ".codex");
20
+ return path.join(codexHome, "plugin-doctor", "audit-cache.json");
21
+ }
22
+ export async function loadEcosystemAuditCache(cachePath) {
23
+ try {
24
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
25
+ if (parsed.schemaVersion === "1.0.0" && parsed.entries && typeof parsed.entries === "object") {
26
+ return {
27
+ schemaVersion: "1.0.0",
28
+ entries: parsed.entries
29
+ };
30
+ }
31
+ }
32
+ catch {
33
+ // Invalid or missing cache files are treated as cold caches.
34
+ }
35
+ return {
36
+ schemaVersion: "1.0.0",
37
+ entries: {}
38
+ };
39
+ }
40
+ export async function writeEcosystemAuditCache(cachePath, cache) {
41
+ await mkdir(path.dirname(cachePath), { recursive: true });
42
+ await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
43
+ }
44
+ async function walkFingerprintEntries(rootPath, currentPath = rootPath) {
45
+ const entries = await readdir(currentPath, { withFileTypes: true });
46
+ const fingerprintEntries = [];
47
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
48
+ const entryPath = path.join(currentPath, entry.name);
49
+ if (entry.isDirectory()) {
50
+ if (skippedDirectories.has(entry.name)) {
51
+ continue;
52
+ }
53
+ fingerprintEntries.push(...(await walkFingerprintEntries(rootPath, entryPath)));
54
+ continue;
55
+ }
56
+ if (!entry.isFile()) {
57
+ continue;
58
+ }
59
+ const details = await stat(entryPath);
60
+ const relativePath = path.relative(rootPath, entryPath).replace(/\\/g, "/");
61
+ fingerprintEntries.push(`${relativePath}\t${details.size}\t${Math.trunc(details.mtimeMs)}`);
62
+ }
63
+ return fingerprintEntries;
64
+ }
65
+ export async function fingerprintInstalledPlugin(plugin) {
66
+ const hash = createHash("sha256");
67
+ hash.update(plugin.name);
68
+ hash.update("\n");
69
+ hash.update(plugin.version ?? "");
70
+ hash.update("\n");
71
+ hash.update(plugin.relativePath);
72
+ hash.update("\n");
73
+ for (const entry of await walkFingerprintEntries(plugin.rootPath)) {
74
+ hash.update(entry);
75
+ hash.update("\n");
76
+ }
77
+ return hash.digest("hex");
78
+ }
@@ -9,12 +9,18 @@ export interface EcosystemAuditOptions {
9
9
  includeSecurity?: boolean;
10
10
  includeCompatibility?: boolean;
11
11
  failOnWarnings?: boolean;
12
+ cache?: {
13
+ enabled?: boolean;
14
+ changedOnly?: boolean;
15
+ cachePath?: string | null;
16
+ };
12
17
  validatePlugin: (targetPath: string) => Promise<CheckResult>;
13
18
  }
14
19
  export interface EcosystemAuditPluginResult {
15
20
  plugin: InstalledPlugin;
16
21
  status: "pass" | "warn" | "fail";
17
22
  validation: CheckResult;
23
+ cached?: boolean;
18
24
  security?: SecurityAudit;
19
25
  compatibility?: CompatibilityMatrix;
20
26
  }
@@ -27,6 +33,8 @@ export interface EcosystemAuditReport {
27
33
  pass: number;
28
34
  warn: number;
29
35
  fail: number;
36
+ cached: number;
37
+ skippedUnchanged: number;
30
38
  };
31
39
  plugins: EcosystemAuditPluginResult[];
32
40
  priorityActions: string[];
@@ -1,6 +1,9 @@
1
+ import path from "node:path";
1
2
  import { buildCompatibilityMatrix, matrixExitCode } from "../compatibility/compatibility-matrix.js";
2
3
  import { discoverInstalledPlugins, filterInstalledPlugins } from "../core/discover-installed-plugins.js";
3
4
  import { buildSecurityAudit } from "../security/security-audit.js";
5
+ import { packageVersion } from "../version.js";
6
+ import { fingerprintInstalledPlugin, loadEcosystemAuditCache, resolveEcosystemAuditCachePath, writeEcosystemAuditCache } from "./ecosystem-audit-cache.js";
4
7
  function mergeStatus(statuses) {
5
8
  if (statuses.includes("fail")) {
6
9
  return "fail";
@@ -19,12 +22,23 @@ function compatibilityStatus(matrix) {
19
22
  }
20
23
  return matrix.results.some((result) => result.status === "warn") ? "warn" : "pass";
21
24
  }
22
- function summarizePlugins(plugins) {
25
+ function cacheKeyForPlugin(plugin, options) {
26
+ return [
27
+ path.resolve(plugin.rootPath).toLowerCase(),
28
+ `version=${packageVersion}`,
29
+ `security=${Boolean(options.includeSecurity)}`,
30
+ `compatibility=${Boolean(options.includeCompatibility)}`,
31
+ `failOnWarnings=${Boolean(options.failOnWarnings)}`
32
+ ].join("\n");
33
+ }
34
+ function summarizePlugins(plugins, skippedUnchanged) {
23
35
  return {
24
36
  totalPlugins: plugins.length,
25
37
  pass: plugins.filter((plugin) => plugin.status === "pass").length,
26
38
  warn: plugins.filter((plugin) => plugin.status === "warn").length,
27
- fail: plugins.filter((plugin) => plugin.status === "fail").length
39
+ fail: plugins.filter((plugin) => plugin.status === "fail").length,
40
+ cached: plugins.filter((plugin) => plugin.cached).length,
41
+ skippedUnchanged
28
42
  };
29
43
  }
30
44
  function buildPriorityActions(plugins) {
@@ -58,7 +72,35 @@ export async function buildEcosystemAudit(options) {
58
72
  platform: options.platform
59
73
  };
60
74
  const plugins = [];
75
+ const cacheEnabled = Boolean(options.cache?.enabled);
76
+ const changedOnly = Boolean(options.cache?.changedOnly);
77
+ const cachePath = cacheEnabled
78
+ ? resolveEcosystemAuditCachePath(options.env, options.cache?.cachePath)
79
+ : null;
80
+ const cache = cachePath
81
+ ? await loadEcosystemAuditCache(cachePath)
82
+ : null;
83
+ let skippedUnchanged = 0;
61
84
  for (const plugin of installedPlugins) {
85
+ const cacheKey = cacheKeyForPlugin(plugin, options);
86
+ const fingerprint = cache
87
+ ? await fingerprintInstalledPlugin(plugin)
88
+ : null;
89
+ const cachedEntry = cache && fingerprint
90
+ ? cache.entries[cacheKey]
91
+ : undefined;
92
+ if (cachedEntry && cachedEntry.fingerprint === fingerprint) {
93
+ if (changedOnly) {
94
+ skippedUnchanged += 1;
95
+ continue;
96
+ }
97
+ plugins.push({
98
+ ...cachedEntry.result,
99
+ plugin,
100
+ cached: true
101
+ });
102
+ continue;
103
+ }
62
104
  const validation = await options.validatePlugin(plugin.rootPath);
63
105
  const security = options.includeSecurity
64
106
  ? await buildSecurityAudit(plugin.rootPath)
@@ -74,15 +116,26 @@ export async function buildEcosystemAudit(options) {
74
116
  const status = options.failOnWarnings && rawStatus === "warn"
75
117
  ? "fail"
76
118
  : rawStatus;
77
- plugins.push({
119
+ const result = {
78
120
  plugin,
79
121
  status,
80
122
  validation,
81
123
  ...(security ? { security } : {}),
82
124
  ...(compatibility ? { compatibility } : {})
83
- });
125
+ };
126
+ plugins.push(result);
127
+ if (cache && fingerprint) {
128
+ cache.entries[cacheKey] = {
129
+ fingerprint,
130
+ cachedAt: new Date().toISOString(),
131
+ result
132
+ };
133
+ }
134
+ }
135
+ if (cache && cachePath) {
136
+ await writeEcosystemAuditCache(cachePath, cache);
84
137
  }
85
- const summary = summarizePlugins(plugins);
138
+ const summary = summarizePlugins(plugins, skippedUnchanged);
86
139
  const status = summary.fail > 0
87
140
  ? "fail"
88
141
  : summary.warn > 0
@@ -106,7 +159,8 @@ export function renderEcosystemAudit(report) {
106
159
  "=====================",
107
160
  `Status: ${report.status.toUpperCase()}`,
108
161
  `Installed plugins: ${report.summary.totalPlugins}`,
109
- `Summary: ${report.summary.fail} fail, ${report.summary.warn} warn, ${report.summary.pass} pass`
162
+ `Summary: ${report.summary.fail} fail, ${report.summary.warn} warn, ${report.summary.pass} pass`,
163
+ `Cache: ${report.summary.cached} reused, ${report.summary.skippedUnchanged} skipped unchanged`
110
164
  ];
111
165
  if (report.plugins.length === 0) {
112
166
  lines.push("", "No installed Codex plugins found.");
@@ -126,6 +180,9 @@ export function renderEcosystemAudit(report) {
126
180
  .join(", ");
127
181
  lines.push(` Compatibility: ${compatibilitySummary}`);
128
182
  }
183
+ if (item.cached) {
184
+ lines.push(" Cache: reused");
185
+ }
129
186
  }
130
187
  if (report.priorityActions.length > 0) {
131
188
  lines.push("", "Priority Actions", "----------------");
@@ -0,0 +1,22 @@
1
+ import type { CompatibilityEnvironment, CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
+ import type { JsonReport } from "../domain/types.js";
3
+ import type { DoctorRecommendationsReport } from "./doctor-recommendations.js";
4
+ import type { SecurityAudit } from "../security/security-audit.js";
5
+ import type { TrustScoreReport } from "../security/trust-score.js";
6
+ export interface DoctorExportBundle {
7
+ schemaVersion: "1.0.0";
8
+ generatedAt: string;
9
+ kind: "doctor.export.bundle";
10
+ version: string;
11
+ targetPath: string;
12
+ validation: JsonReport;
13
+ security: SecurityAudit;
14
+ compatibility: CompatibilityMatrix;
15
+ recommendations: DoctorRecommendationsReport;
16
+ trust: TrustScoreReport;
17
+ }
18
+ export declare function buildDoctorExportBundle(targetPath: string, environment?: CompatibilityEnvironment): Promise<DoctorExportBundle>;
19
+ export declare function renderDoctorExportBundleJson(bundle: DoctorExportBundle): string;
20
+ export declare function renderDoctorExportBundle(bundle: DoctorExportBundle, options?: {
21
+ outputPath?: string | null;
22
+ }): string;
@@ -0,0 +1,52 @@
1
+ import { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./package-analysis.js";
2
+ function redactString(value) {
3
+ return value
4
+ .replace(/sk-[A-Za-z0-9_-]{12,}/g, "[REDACTED_SECRET]")
5
+ .replace(/npm_[A-Za-z0-9_-]{12,}/g, "[REDACTED_SECRET]")
6
+ .replace(/gh[pousr]_[A-Za-z0-9_]{12,}/g, "[REDACTED_SECRET]")
7
+ .replace(/SHOULD_NOT_LEAK/g, "[REDACTED_SECRET]");
8
+ }
9
+ function redactValue(value) {
10
+ if (typeof value === "string") {
11
+ return redactString(value);
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map(redactValue);
15
+ }
16
+ if (typeof value === "object" && value !== null) {
17
+ return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
18
+ key,
19
+ redactValue(nestedValue)
20
+ ]));
21
+ }
22
+ return value;
23
+ }
24
+ export async function buildDoctorExportBundle(targetPath, environment = {}) {
25
+ const analysis = await buildPackageAnalysis(targetPath, { environment });
26
+ return buildDoctorExportBundleFromAnalysis(analysis, buildDoctorRecommendationsFromAnalysis(analysis));
27
+ }
28
+ export function renderDoctorExportBundleJson(bundle) {
29
+ return JSON.stringify(redactValue(bundle), null, 2);
30
+ }
31
+ export function renderDoctorExportBundle(bundle, options = {}) {
32
+ const lines = [
33
+ "Doctor Export Bundle",
34
+ "====================",
35
+ `Target: ${bundle.targetPath}`,
36
+ `Version: ${bundle.version}`,
37
+ `Validation: ${bundle.validation.summary.status.toUpperCase()}`,
38
+ `Security: ${bundle.security.status.toUpperCase()} (${bundle.security.score}/100)`,
39
+ `Trust: ${bundle.trust.status.toUpperCase()} (${bundle.trust.score}/100)`,
40
+ `Recommendations: ${bundle.recommendations.actions.length}`
41
+ ];
42
+ if (options.outputPath) {
43
+ lines.push(`Output: ${options.outputPath}`);
44
+ }
45
+ lines.push("", "Bundle sections", "---------------");
46
+ lines.push("validation");
47
+ lines.push("security");
48
+ lines.push("compatibility");
49
+ lines.push("recommendations");
50
+ lines.push("trust");
51
+ return lines.join("\n");
52
+ }
@@ -0,0 +1,42 @@
1
+ import type { CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
2
+ import type { CheckResult } from "../domain/types.js";
3
+ import type { SecurityAudit } from "../security/security-audit.js";
4
+ export type RecommendationPriority = "blocker" | "high" | "medium" | "info";
5
+ export type RecommendationCategory = "validation" | "security" | "compatibility" | "release";
6
+ export interface DoctorRecommendationAction {
7
+ priority: RecommendationPriority;
8
+ category: RecommendationCategory;
9
+ title: string;
10
+ reason: string;
11
+ nextCommand: string;
12
+ findingId?: string;
13
+ }
14
+ export interface DoctorRecommendationsReport {
15
+ schemaVersion: "1.0.0";
16
+ generatedAt: string;
17
+ targetPath: string;
18
+ status: "pass" | "warn" | "fail";
19
+ exitCode: 0 | 1;
20
+ summary: {
21
+ actionCounts: Record<RecommendationPriority, number>;
22
+ };
23
+ validation: {
24
+ status: CheckResult["status"];
25
+ findingCount: number;
26
+ };
27
+ security: {
28
+ status: SecurityAudit["status"];
29
+ score: number;
30
+ findingCount: number;
31
+ };
32
+ compatibility: {
33
+ failedClients: string[];
34
+ };
35
+ actions: DoctorRecommendationAction[];
36
+ }
37
+ export declare function buildDoctorRecommendations(targetPath: string, options?: {
38
+ environment?: CompatibilityEnvironment;
39
+ runCheck?: (targetPath: string) => Promise<CheckResult>;
40
+ }): Promise<DoctorRecommendationsReport>;
41
+ export declare function renderDoctorRecommendationsJson(report: DoctorRecommendationsReport): string;
42
+ export declare function renderDoctorRecommendations(report: DoctorRecommendationsReport): string;
@@ -0,0 +1,31 @@
1
+ import { buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./package-analysis.js";
2
+ export async function buildDoctorRecommendations(targetPath, options = {}) {
3
+ return buildDoctorRecommendationsFromAnalysis(await buildPackageAnalysis(targetPath, {
4
+ environment: options.environment,
5
+ runCheck: options.runCheck
6
+ }));
7
+ }
8
+ export function renderDoctorRecommendationsJson(report) {
9
+ return JSON.stringify(report, null, 2);
10
+ }
11
+ export function renderDoctorRecommendations(report) {
12
+ const lines = [
13
+ "Doctor Recommendations",
14
+ "======================",
15
+ `Target: ${report.targetPath}`,
16
+ `Status: ${report.status.toUpperCase()}`,
17
+ `Actions: ${report.summary.actionCounts.blocker} blocker, ${report.summary.actionCounts.high} high, ${report.summary.actionCounts.medium} medium, ${report.summary.actionCounts.info} info`,
18
+ `Security: ${report.security.status.toUpperCase()} (${report.security.score}/100)`
19
+ ];
20
+ lines.push("", "Actions", "-------");
21
+ for (const action of report.actions) {
22
+ lines.push(`[${action.priority.toUpperCase()}] ${action.title}`);
23
+ if (action.findingId) {
24
+ lines.push(` Finding: ${action.findingId}`);
25
+ }
26
+ lines.push(` Category: ${action.category}`);
27
+ lines.push(` Reason: ${action.reason}`);
28
+ lines.push(` Next: ${action.nextCommand}`);
29
+ }
30
+ return lines.join("\n");
31
+ }
@@ -0,0 +1,28 @@
1
+ import { type CompatibilityEnvironment, type CompatibilityMatrix } from "../compatibility/compatibility-matrix.js";
2
+ import type { CheckResult, JsonReport } from "../domain/types.js";
3
+ import type { DoctorRecommendationsReport } from "./doctor-recommendations.js";
4
+ import type { DoctorExportBundle } from "./doctor-export-bundle.js";
5
+ import { type SecurityAudit } from "../security/security-audit.js";
6
+ import { type TrustScoreReport } from "../security/trust-score.js";
7
+ export interface PackageAnalysis {
8
+ generatedAt: string;
9
+ targetPath: string;
10
+ validation: CheckResult;
11
+ validationJson: JsonReport;
12
+ security: SecurityAudit;
13
+ compatibility: CompatibilityMatrix;
14
+ trust: TrustScoreReport;
15
+ }
16
+ export type PackageAnalysisStage = "validation" | "doctorConfig" | "security" | "compatibility" | "trust";
17
+ export interface PackageAnalysisTiming {
18
+ stage: PackageAnalysisStage;
19
+ durationMs: number;
20
+ }
21
+ export interface PackageAnalysisOptions {
22
+ environment?: CompatibilityEnvironment;
23
+ recordTiming?: (timing: PackageAnalysisTiming) => void;
24
+ runCheck?: (targetPath: string) => Promise<CheckResult>;
25
+ }
26
+ export declare function buildPackageAnalysis(targetPath: string, options?: PackageAnalysisOptions): Promise<PackageAnalysis>;
27
+ export declare function buildDoctorRecommendationsFromAnalysis(analysis: PackageAnalysis): DoctorRecommendationsReport;
28
+ export declare function buildDoctorExportBundleFromAnalysis(analysis: PackageAnalysis, recommendations?: DoctorRecommendationsReport): DoctorExportBundle;
@@ -0,0 +1,180 @@
1
+ import path from "node:path";
2
+ import { performance } from "node:perf_hooks";
3
+ import { buildCompatibilityMatrix, matrixExitCode } from "../compatibility/compatibility-matrix.js";
4
+ import { applyDoctorConfig, loadDoctorConfig } from "./doctor-config.js";
5
+ import { buildJsonReport } from "../reporting/render-json-report.js";
6
+ import { buildSecurityAudit } from "../security/security-audit.js";
7
+ import { buildTrustScore } from "../security/trust-score.js";
8
+ import { validatePlugin } from "./validate-plugin.js";
9
+ import { packageVersion } from "../version.js";
10
+ const priorityRank = {
11
+ blocker: 0,
12
+ high: 1,
13
+ medium: 2,
14
+ info: 3
15
+ };
16
+ function countActions(actions) {
17
+ return {
18
+ blocker: actions.filter((action) => action.priority === "blocker").length,
19
+ high: actions.filter((action) => action.priority === "high").length,
20
+ medium: actions.filter((action) => action.priority === "medium").length,
21
+ info: actions.filter((action) => action.priority === "info").length
22
+ };
23
+ }
24
+ function priorityForFinding(finding) {
25
+ if (finding.severity === "fail") {
26
+ return "blocker";
27
+ }
28
+ return finding.id.startsWith("plugin.security.") ? "high" : "medium";
29
+ }
30
+ function categoryForFinding(finding) {
31
+ return finding.id.startsWith("plugin.security.") ? "security" : "validation";
32
+ }
33
+ function commandForCategory(category, targetPath) {
34
+ if (category === "security") {
35
+ return `codex-plugin-doctor security ${targetPath} --scorecard`;
36
+ }
37
+ if (category === "compatibility") {
38
+ return `codex-plugin-doctor compat ${targetPath} --all --scorecard`;
39
+ }
40
+ return `codex-plugin-doctor check ${targetPath} --explain`;
41
+ }
42
+ function actionFromFinding(finding, targetPath) {
43
+ const category = categoryForFinding(finding);
44
+ return {
45
+ priority: priorityForFinding(finding),
46
+ category,
47
+ findingId: finding.id,
48
+ title: finding.message,
49
+ reason: finding.impact,
50
+ nextCommand: commandForCategory(category, targetPath)
51
+ };
52
+ }
53
+ function dedupeActions(actions) {
54
+ const seen = new Set();
55
+ return actions.filter((action) => {
56
+ const key = `${action.category}\n${action.findingId ?? action.title}\n${action.reason}`;
57
+ if (seen.has(key)) {
58
+ return false;
59
+ }
60
+ seen.add(key);
61
+ return true;
62
+ });
63
+ }
64
+ function actionsFromCompatibility(matrix, targetPath) {
65
+ return matrix.results
66
+ .filter((result) => result.status === "fail")
67
+ .map((result) => ({
68
+ priority: "high",
69
+ category: "compatibility",
70
+ title: `${result.client} compatibility failed.`,
71
+ reason: result.summary,
72
+ nextCommand: commandForCategory("compatibility", targetPath)
73
+ }));
74
+ }
75
+ function sortActions(actions) {
76
+ return [...actions].sort((left, right) => {
77
+ const priorityDelta = priorityRank[left.priority] - priorityRank[right.priority];
78
+ if (priorityDelta !== 0) {
79
+ return priorityDelta;
80
+ }
81
+ return left.category.localeCompare(right.category);
82
+ });
83
+ }
84
+ async function measureStage(stage, operation, recordTiming) {
85
+ const startedAt = performance.now();
86
+ try {
87
+ return await operation();
88
+ }
89
+ finally {
90
+ recordTiming?.({
91
+ stage,
92
+ durationMs: performance.now() - startedAt
93
+ });
94
+ }
95
+ }
96
+ export async function buildPackageAnalysis(targetPath, options = {}) {
97
+ const rootPath = path.resolve(targetPath);
98
+ const runCheck = options.runCheck ?? validatePlugin;
99
+ const [rawValidation, doctorConfig, security, compatibility] = await Promise.all([
100
+ measureStage("validation", () => runCheck(rootPath), options.recordTiming),
101
+ measureStage("doctorConfig", () => loadDoctorConfig(rootPath), options.recordTiming),
102
+ measureStage("security", () => buildSecurityAudit(rootPath), options.recordTiming),
103
+ measureStage("compatibility", () => buildCompatibilityMatrix(rootPath, options.environment ?? {}), options.recordTiming)
104
+ ]);
105
+ const validation = applyDoctorConfig(rawValidation, doctorConfig);
106
+ const trust = await measureStage("trust", () => buildTrustScore(rootPath, { securityAudit: security }), options.recordTiming);
107
+ return {
108
+ generatedAt: new Date().toISOString(),
109
+ targetPath: rootPath,
110
+ validation,
111
+ validationJson: buildJsonReport(validation, { runtimeProbeEnabled: false }),
112
+ security,
113
+ compatibility,
114
+ trust
115
+ };
116
+ }
117
+ export function buildDoctorRecommendationsFromAnalysis(analysis) {
118
+ const actions = sortActions(dedupeActions([
119
+ ...analysis.validation.findings.map((finding) => actionFromFinding(finding, analysis.targetPath)),
120
+ ...analysis.security.findings.map((finding) => actionFromFinding(finding, analysis.targetPath)),
121
+ ...actionsFromCompatibility(analysis.compatibility, analysis.targetPath)
122
+ ]));
123
+ const finalActions = actions.length > 0
124
+ ? actions
125
+ : [
126
+ {
127
+ priority: "info",
128
+ category: "release",
129
+ title: "No blocker actions.",
130
+ reason: "The package has no validation, security, or compatibility blockers in this recommendation pass.",
131
+ nextCommand: `codex-plugin-doctor check ${analysis.targetPath} --profile publish`
132
+ }
133
+ ];
134
+ const status = finalActions.some((action) => action.priority === "blocker")
135
+ ? "fail"
136
+ : finalActions.some((action) => action.priority === "high" || action.priority === "medium")
137
+ ? "warn"
138
+ : "pass";
139
+ return {
140
+ schemaVersion: "1.0.0",
141
+ generatedAt: analysis.generatedAt,
142
+ targetPath: analysis.targetPath,
143
+ status,
144
+ exitCode: status === "fail" ? 1 : 0,
145
+ summary: {
146
+ actionCounts: countActions(finalActions)
147
+ },
148
+ validation: {
149
+ status: analysis.validation.status,
150
+ findingCount: analysis.validation.findings.length
151
+ },
152
+ security: {
153
+ status: analysis.security.status,
154
+ score: analysis.security.score,
155
+ findingCount: analysis.security.findings.length
156
+ },
157
+ compatibility: {
158
+ failedClients: matrixExitCode(analysis.compatibility) === 1
159
+ ? analysis.compatibility.results
160
+ .filter((result) => result.status === "fail")
161
+ .map((result) => result.client)
162
+ : []
163
+ },
164
+ actions: finalActions
165
+ };
166
+ }
167
+ export function buildDoctorExportBundleFromAnalysis(analysis, recommendations = buildDoctorRecommendationsFromAnalysis(analysis)) {
168
+ return {
169
+ schemaVersion: "1.0.0",
170
+ generatedAt: analysis.generatedAt,
171
+ kind: "doctor.export.bundle",
172
+ version: packageVersion,
173
+ targetPath: analysis.targetPath,
174
+ validation: analysis.validationJson,
175
+ security: analysis.security,
176
+ compatibility: analysis.compatibility,
177
+ recommendations,
178
+ trust: analysis.trust
179
+ };
180
+ }
@@ -0,0 +1,37 @@
1
+ import { type CompatibilityEnvironment } from "../compatibility/compatibility-matrix.js";
2
+ import type { CheckResult } from "../domain/types.js";
3
+ import { type PackageAnalysisStage } from "./package-analysis.js";
4
+ export type DoctorPerformanceStageName = PackageAnalysisStage | "recommendations" | "total";
5
+ export interface DoctorPerformanceStage {
6
+ name: DoctorPerformanceStageName;
7
+ durationMs: number;
8
+ status?: "pass" | "warn" | "fail";
9
+ itemCount?: number;
10
+ }
11
+ export interface DoctorPerformanceReport {
12
+ schemaVersion: "1.0.0";
13
+ generatedAt: string;
14
+ kind: "doctor.perf";
15
+ targetPath: string;
16
+ status: "pass";
17
+ exitCode: 0;
18
+ summary: {
19
+ stageCount: number;
20
+ slowestStage: DoctorPerformanceStageName;
21
+ totalDurationMs: number;
22
+ validationStatus: "pass" | "warn" | "fail";
23
+ securityStatus: "pass" | "warn" | "fail";
24
+ trustScore: number;
25
+ compatibilityFailures: number;
26
+ };
27
+ stages: DoctorPerformanceStage[];
28
+ }
29
+ export interface BuildDoctorPerformanceReportOptions {
30
+ environment?: CompatibilityEnvironment;
31
+ runCheck?: (targetPath: string) => Promise<CheckResult>;
32
+ }
33
+ export declare function buildDoctorPerformanceReport(targetPath: string, options?: BuildDoctorPerformanceReportOptions): Promise<DoctorPerformanceReport>;
34
+ export declare function renderDoctorPerformanceReportJson(report: DoctorPerformanceReport): string;
35
+ export declare function renderDoctorPerformanceReport(report: DoctorPerformanceReport, options?: {
36
+ outputPath?: string | null;
37
+ }): string;
@@ -0,0 +1,141 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { matrixExitCode } from "../compatibility/compatibility-matrix.js";
3
+ import { buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./package-analysis.js";
4
+ const stageOrder = [
5
+ "validation",
6
+ "doctorConfig",
7
+ "security",
8
+ "compatibility",
9
+ "trust",
10
+ "recommendations",
11
+ "total"
12
+ ];
13
+ function roundDuration(durationMs) {
14
+ return Number(durationMs.toFixed(3));
15
+ }
16
+ function compatibilityStatus(exitCode, warningCount) {
17
+ if (exitCode === 1) {
18
+ return "fail";
19
+ }
20
+ return warningCount > 0 ? "warn" : "pass";
21
+ }
22
+ function slowestStage(stages) {
23
+ return stages
24
+ .filter((stage) => stage.name !== "total")
25
+ .reduce((slowest, stage) => (stage.durationMs > slowest.durationMs ? stage : slowest), stages[0]).name;
26
+ }
27
+ export async function buildDoctorPerformanceReport(targetPath, options = {}) {
28
+ const timings = [];
29
+ const startedAt = performance.now();
30
+ const analysis = await buildPackageAnalysis(targetPath, {
31
+ environment: options.environment,
32
+ recordTiming: (timing) => timings.push(timing),
33
+ runCheck: options.runCheck
34
+ });
35
+ const recommendationsStartedAt = performance.now();
36
+ const recommendations = buildDoctorRecommendationsFromAnalysis(analysis);
37
+ const recommendationTiming = performance.now() - recommendationsStartedAt;
38
+ const totalDurationMs = performance.now() - startedAt;
39
+ const compatibilityFailures = analysis.compatibility.results
40
+ .filter((result) => result.status === "fail").length;
41
+ const compatibilityWarnings = analysis.compatibility.results
42
+ .filter((result) => result.status === "warn").length;
43
+ const timingByStage = new Map(timings.map((timing) => [timing.stage, timing.durationMs]));
44
+ const stages = stageOrder.map((stageName) => {
45
+ if (stageName === "validation") {
46
+ return {
47
+ name: stageName,
48
+ durationMs: roundDuration(timingByStage.get(stageName) ?? 0),
49
+ status: analysis.validation.status,
50
+ itemCount: analysis.validation.findings.length
51
+ };
52
+ }
53
+ if (stageName === "doctorConfig") {
54
+ return {
55
+ name: stageName,
56
+ durationMs: roundDuration(timingByStage.get(stageName) ?? 0),
57
+ status: "pass"
58
+ };
59
+ }
60
+ if (stageName === "security") {
61
+ return {
62
+ name: stageName,
63
+ durationMs: roundDuration(timingByStage.get(stageName) ?? 0),
64
+ status: analysis.security.status,
65
+ itemCount: analysis.security.findings.length
66
+ };
67
+ }
68
+ if (stageName === "compatibility") {
69
+ return {
70
+ name: stageName,
71
+ durationMs: roundDuration(timingByStage.get(stageName) ?? 0),
72
+ status: compatibilityStatus(matrixExitCode(analysis.compatibility), compatibilityWarnings),
73
+ itemCount: analysis.compatibility.results.length
74
+ };
75
+ }
76
+ if (stageName === "trust") {
77
+ return {
78
+ name: stageName,
79
+ durationMs: roundDuration(timingByStage.get(stageName) ?? 0),
80
+ status: analysis.trust.status,
81
+ itemCount: analysis.trust.findings.length
82
+ };
83
+ }
84
+ if (stageName === "recommendations") {
85
+ return {
86
+ name: stageName,
87
+ durationMs: roundDuration(recommendationTiming),
88
+ status: recommendations.status,
89
+ itemCount: recommendations.actions.length
90
+ };
91
+ }
92
+ return {
93
+ name: "total",
94
+ durationMs: roundDuration(totalDurationMs)
95
+ };
96
+ });
97
+ return {
98
+ schemaVersion: "1.0.0",
99
+ generatedAt: analysis.generatedAt,
100
+ kind: "doctor.perf",
101
+ targetPath: analysis.targetPath,
102
+ status: "pass",
103
+ exitCode: 0,
104
+ summary: {
105
+ stageCount: stages.length,
106
+ slowestStage: slowestStage(stages),
107
+ totalDurationMs: roundDuration(totalDurationMs),
108
+ validationStatus: analysis.validation.status,
109
+ securityStatus: analysis.security.status,
110
+ trustScore: analysis.trust.score,
111
+ compatibilityFailures
112
+ },
113
+ stages
114
+ };
115
+ }
116
+ export function renderDoctorPerformanceReportJson(report) {
117
+ return JSON.stringify(report, null, 2);
118
+ }
119
+ export function renderDoctorPerformanceReport(report, options = {}) {
120
+ const lines = [
121
+ "Doctor Performance",
122
+ "==================",
123
+ `Target: ${report.targetPath}`,
124
+ `Status: ${report.status.toUpperCase()}`,
125
+ `Total: ${report.summary.totalDurationMs}ms`,
126
+ `Slowest: ${report.summary.slowestStage}`,
127
+ `Validation: ${report.summary.validationStatus.toUpperCase()}`,
128
+ `Security: ${report.summary.securityStatus.toUpperCase()}`,
129
+ `Trust: ${report.summary.trustScore}/100`
130
+ ];
131
+ if (options.outputPath) {
132
+ lines.push(`Output: ${options.outputPath}`);
133
+ }
134
+ lines.push("", "Stages", "------");
135
+ for (const stage of report.stages) {
136
+ const status = stage.status ? ` (${stage.status.toUpperCase()})` : "";
137
+ const count = stage.itemCount === undefined ? "" : `, items: ${stage.itemCount}`;
138
+ lines.push(`${stage.name}: ${stage.durationMs}ms${status}${count}`);
139
+ }
140
+ return lines.join("\n");
141
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { CheckOptions, CheckResult } from "./domain/types.js";
2
2
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard, type SecurityAudit } from "./security/security-audit.js";
3
+ export { buildTrustScore, renderTrustScore, renderTrustScoreJson, type BuildTrustScoreOptions, type TrustScoreReport } from "./security/trust-score.js";
3
4
  export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson, type DoctorSnapshot } from "./core/doctor-snapshot.js";
5
+ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRecommendationsJson, type DoctorRecommendationAction, type DoctorRecommendationsReport } from "./core/doctor-recommendations.js";
6
+ export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson, type DoctorExportBundle } from "./core/doctor-export-bundle.js";
7
+ export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis, type PackageAnalysis, type PackageAnalysisOptions, type PackageAnalysisStage, type PackageAnalysisTiming } from "./core/package-analysis.js";
8
+ export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson, type BuildDoctorPerformanceReportOptions, type DoctorPerformanceReport, type DoctorPerformanceStage, type DoctorPerformanceStageName } from "./core/performance-report.js";
4
9
  export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson, type EcosystemAuditReport } from "./audit/ecosystem-audit.js";
5
10
  export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames, type PolicyPackName } from "./policy/policy-packs.js";
6
11
  export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson, type GenericMcpDoctorReport } from "./mcp/generic-mcp-doctor.js";
package/dist/index.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { validatePlugin } from "./core/validate-plugin.js";
2
2
  export { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
3
+ export { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
3
4
  export { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
5
+ export { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRecommendationsJson } from "./core/doctor-recommendations.js";
6
+ export { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
7
+ export { buildDoctorExportBundleFromAnalysis, buildDoctorRecommendationsFromAnalysis, buildPackageAnalysis } from "./core/package-analysis.js";
8
+ export { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
4
9
  export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson } from "./audit/ecosystem-audit.js";
5
10
  export { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
6
11
  export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson } from "./mcp/generic-mcp-doctor.js";
package/dist/run-cli.js CHANGED
@@ -14,6 +14,9 @@ import { buildClineInstallPreview, renderClineInstallPreview } from "./compatibi
14
14
  import { buildWindsurfInstallPreview, renderWindsurfInstallPreview } from "./compatibility/windsurf-install-preview.js";
15
15
  import { applyDoctorConfig, loadDoctorConfig } from "./core/doctor-config.js";
16
16
  import { buildDoctorSnapshot, renderDoctorSnapshot, renderDoctorSnapshotJson } from "./core/doctor-snapshot.js";
17
+ import { buildDoctorRecommendations, renderDoctorRecommendations, renderDoctorRecommendationsJson } from "./core/doctor-recommendations.js";
18
+ import { buildDoctorExportBundle, renderDoctorExportBundle, renderDoctorExportBundleJson } from "./core/doctor-export-bundle.js";
19
+ import { buildDoctorPerformanceReport, renderDoctorPerformanceReport, renderDoctorPerformanceReportJson } from "./core/performance-report.js";
17
20
  import { applyFixPlan, buildFixPlan, renderApplyFixResult, renderFixPlanJsonReport, renderFixPlan } from "./core/fix-plan.js";
18
21
  import { renderClientDoctor, renderEnvironmentDoctor, renderEnvironmentDoctorJson } from "./core/environment-doctor.js";
19
22
  import { initCiWorkflow } from "./core/init-ci.js";
@@ -33,6 +36,7 @@ import { renderTextReport } from "./reporting/render-text-report.js";
33
36
  import { applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames } from "./policy/policy-packs.js";
34
37
  import { findRuleDefinition } from "./rules/rule-catalog.js";
35
38
  import { buildSecurityAudit, renderSecurityAuditJson, renderSecurityScorecard } from "./security/security-audit.js";
39
+ import { buildTrustScore, renderTrustScore, renderTrustScoreJson } from "./security/trust-score.js";
36
40
  import { createLiveStatusRenderer } from "./terminal/live-status-renderer.js";
37
41
  import { determineOutputPolicy } from "./terminal/output-policy.js";
38
42
  import { getSpinner } from "./terminal/spinner-registry.js";
@@ -58,7 +62,7 @@ const defaultIo = {
58
62
  }
59
63
  };
60
64
  function printUsage(io) {
61
- io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
65
+ io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime] [--verbose-runtime] [--explain] [--no-animations] [--ascii]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor mcp <path> [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor doctor [recommend <path>|trust <path>|perf <path>|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
62
66
  }
63
67
  function renderInstalledPlugins(plugins) {
64
68
  const lines = [
@@ -191,6 +195,123 @@ export async function runCli(args, io = defaultIo, options = {}) {
191
195
  const doctorFlags = maybePath?.startsWith("--")
192
196
  ? [maybePath, ...remainingArgs]
193
197
  : remainingArgs;
198
+ if (maybePath === "recommend") {
199
+ const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
200
+ ? remainingArgs[0]
201
+ : ".";
202
+ const recommendFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
203
+ ? remainingArgs.slice(1)
204
+ : remainingArgs;
205
+ const jsonOutput = recommendFlags.includes("--json");
206
+ const outputIndex = recommendFlags.indexOf("--output");
207
+ const outputPath = outputIndex === -1 ? null : recommendFlags[outputIndex + 1];
208
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
209
+ io.writeStderr("Missing path after --output.");
210
+ return 2;
211
+ }
212
+ const report = await buildDoctorRecommendations(targetPath, {
213
+ environment: {
214
+ env: terminalContext.env,
215
+ platform: terminalContext.platform
216
+ },
217
+ runCheck: options.runCheckImpl
218
+ ? (pathToCheck) => options.runCheckImpl(pathToCheck)
219
+ : undefined
220
+ });
221
+ const renderedReport = jsonOutput
222
+ ? renderDoctorRecommendationsJson(report)
223
+ : renderDoctorRecommendations(report);
224
+ if (outputPath) {
225
+ await writeFile(outputPath, renderedReport, "utf8");
226
+ }
227
+ io.writeStdout(renderedReport);
228
+ return report.exitCode;
229
+ }
230
+ if (maybePath === "trust") {
231
+ const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
232
+ ? remainingArgs[0]
233
+ : ".";
234
+ const trustFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
235
+ ? remainingArgs.slice(1)
236
+ : remainingArgs;
237
+ const jsonOutput = trustFlags.includes("--json");
238
+ const outputIndex = trustFlags.indexOf("--output");
239
+ const outputPath = outputIndex === -1 ? null : trustFlags[outputIndex + 1];
240
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
241
+ io.writeStderr("Missing path after --output.");
242
+ return 2;
243
+ }
244
+ const report = await buildTrustScore(targetPath);
245
+ const renderedReport = jsonOutput
246
+ ? renderTrustScoreJson(report)
247
+ : renderTrustScore(report);
248
+ if (outputPath) {
249
+ await writeFile(outputPath, renderedReport, "utf8");
250
+ }
251
+ io.writeStdout(renderedReport);
252
+ return report.exitCode;
253
+ }
254
+ if (maybePath === "perf") {
255
+ const targetPath = remainingArgs[0] && !remainingArgs[0].startsWith("--")
256
+ ? remainingArgs[0]
257
+ : ".";
258
+ const perfFlags = remainingArgs[0] && !remainingArgs[0].startsWith("--")
259
+ ? remainingArgs.slice(1)
260
+ : remainingArgs;
261
+ const jsonOutput = perfFlags.includes("--json");
262
+ const outputIndex = perfFlags.indexOf("--output");
263
+ const outputPath = outputIndex === -1 ? null : perfFlags[outputIndex + 1];
264
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
265
+ io.writeStderr("Missing path after --output.");
266
+ return 2;
267
+ }
268
+ const report = await buildDoctorPerformanceReport(targetPath, {
269
+ environment: {
270
+ env: terminalContext.env,
271
+ platform: terminalContext.platform
272
+ },
273
+ runCheck: options.runCheckImpl
274
+ ? (pathToCheck) => options.runCheckImpl(pathToCheck)
275
+ : undefined
276
+ });
277
+ const renderedReport = jsonOutput
278
+ ? renderDoctorPerformanceReportJson(report)
279
+ : renderDoctorPerformanceReport(report, { outputPath });
280
+ if (outputPath) {
281
+ await writeFile(outputPath, renderedReport, "utf8");
282
+ }
283
+ io.writeStdout(renderedReport);
284
+ return report.exitCode;
285
+ }
286
+ if (maybePath === "export") {
287
+ const bundleIndex = remainingArgs.indexOf("--bundle");
288
+ if (bundleIndex === -1) {
289
+ io.writeStderr("Usage: codex-plugin-doctor doctor export --bundle <path> [--json] [--output <path>]");
290
+ return 2;
291
+ }
292
+ const targetPath = remainingArgs[bundleIndex + 1] && !remainingArgs[bundleIndex + 1].startsWith("--")
293
+ ? remainingArgs[bundleIndex + 1]
294
+ : ".";
295
+ const jsonOutput = remainingArgs.includes("--json");
296
+ const outputIndex = remainingArgs.indexOf("--output");
297
+ const outputPath = outputIndex === -1 ? null : remainingArgs[outputIndex + 1];
298
+ if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
299
+ io.writeStderr("Missing path after --output.");
300
+ return 2;
301
+ }
302
+ const bundle = await buildDoctorExportBundle(targetPath, {
303
+ env: terminalContext.env,
304
+ platform: terminalContext.platform
305
+ });
306
+ const bundleJson = renderDoctorExportBundleJson(bundle);
307
+ if (outputPath) {
308
+ await writeFile(outputPath, bundleJson, "utf8");
309
+ }
310
+ io.writeStdout(jsonOutput
311
+ ? bundleJson
312
+ : renderDoctorExportBundle(bundle, { outputPath }));
313
+ return 0;
314
+ }
194
315
  if (maybePath === "snapshot") {
195
316
  const jsonOutput = doctorFlags.includes("--json");
196
317
  const outputIndex = doctorFlags.indexOf("--output");
@@ -432,7 +553,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
432
553
  const auditFlags = maybePath ? [maybePath, ...remainingArgs] : remainingArgs;
433
554
  const installed = auditFlags.includes("--installed");
434
555
  if (!installed) {
435
- io.writeStderr("Usage: codex-plugin-doctor audit --installed [filter] [--security] [--compat] [--json] [--output <path>]");
556
+ io.writeStderr("Usage: codex-plugin-doctor audit --installed [filter] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]");
436
557
  return 2;
437
558
  }
438
559
  const installedIndex = auditFlags.indexOf("--installed");
@@ -447,6 +568,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
447
568
  const policyIndex = auditFlags.indexOf("--policy");
448
569
  const policyName = policyIndex === -1 ? null : auditFlags[policyIndex + 1];
449
570
  const policy = parsePolicyPack(policyName);
571
+ const cacheEnabled = auditFlags.includes("--cache") || auditFlags.includes("--changed");
572
+ const changedOnly = auditFlags.includes("--changed");
573
+ const cacheFileIndex = auditFlags.indexOf("--cache-file");
574
+ const cachePath = cacheFileIndex === -1 ? null : auditFlags[cacheFileIndex + 1];
450
575
  if (outputIndex !== -1 && (!outputPath || outputPath.startsWith("--"))) {
451
576
  io.writeStderr("Missing path after --output.");
452
577
  return 2;
@@ -455,6 +580,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
455
580
  io.writeStderr("Missing policy after --policy.");
456
581
  return 2;
457
582
  }
583
+ if (cacheFileIndex !== -1 && (!cachePath || cachePath.startsWith("--"))) {
584
+ io.writeStderr("Missing path after --cache-file.");
585
+ return 2;
586
+ }
458
587
  if (policyIndex !== -1 && !policy) {
459
588
  io.writeStderr(`Unknown policy: ${policyName}. Supported policies: ${policyPackNames.join(", ")}.`);
460
589
  return 2;
@@ -466,9 +595,14 @@ export async function runCli(args, io = defaultIo, options = {}) {
466
595
  includeSecurity,
467
596
  includeCompatibility,
468
597
  failOnWarnings: policyFailsOnWarnings(policy),
598
+ cache: {
599
+ enabled: cacheEnabled,
600
+ changedOnly,
601
+ cachePath
602
+ },
469
603
  validatePlugin: options.runCheckImpl ?? runCheck
470
604
  });
471
- if (report.summary.totalPlugins === 0) {
605
+ if (report.summary.totalPlugins === 0 && !changedOnly) {
472
606
  io.writeStderr(installedFilter
473
607
  ? `No installed Codex plugins matched '${installedFilter}'.`
474
608
  : "No installed Codex plugins found.");
@@ -0,0 +1,27 @@
1
+ import type { Finding } from "../domain/types.js";
2
+ import { type SecurityAudit } from "./security-audit.js";
3
+ export interface TrustScoreReport {
4
+ schemaVersion: "1.0.0";
5
+ generatedAt: string;
6
+ targetPath: string;
7
+ status: "pass" | "warn" | "fail";
8
+ exitCode: 0 | 1;
9
+ score: number;
10
+ findingCounts: {
11
+ fail: number;
12
+ warn: number;
13
+ total: number;
14
+ };
15
+ packageJson: {
16
+ present: boolean;
17
+ scriptsChecked: number;
18
+ dependenciesChecked: number;
19
+ };
20
+ findings: Finding[];
21
+ }
22
+ export interface BuildTrustScoreOptions {
23
+ securityAudit?: SecurityAudit | null;
24
+ }
25
+ export declare function buildTrustScore(targetPath: string, options?: BuildTrustScoreOptions): Promise<TrustScoreReport>;
26
+ export declare function renderTrustScoreJson(report: TrustScoreReport): string;
27
+ export declare function renderTrustScore(report: TrustScoreReport): string;
@@ -0,0 +1,197 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { discoverPackage } from "../core/discover-package.js";
4
+ import { parseJsonText } from "../core/read-json-file.js";
5
+ import { buildSecurityAudit } from "./security-audit.js";
6
+ const lifecycleScripts = new Set([
7
+ "preinstall",
8
+ "install",
9
+ "postinstall",
10
+ "prepublish",
11
+ "prepare"
12
+ ]);
13
+ function buildFinding(severity, id, message, impact, suggestedFix) {
14
+ return {
15
+ id,
16
+ severity,
17
+ message,
18
+ impact,
19
+ suggestedFix
20
+ };
21
+ }
22
+ async function fileExists(targetPath) {
23
+ try {
24
+ const details = await stat(targetPath);
25
+ return details.isFile();
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function isPlainObject(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function containsRemotePipeInstall(script) {
35
+ const normalized = script.toLowerCase();
36
+ return (/\b(curl|wget)\b[^|]*\|\s*(sh|bash)\b/.test(normalized) ||
37
+ /\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(normalized) ||
38
+ /\binvoke-expression\b/.test(normalized));
39
+ }
40
+ async function readPackageJson(rootPath) {
41
+ const packageJsonPath = path.join(rootPath, "package.json");
42
+ if (!(await fileExists(packageJsonPath))) {
43
+ return null;
44
+ }
45
+ try {
46
+ const parsed = parseJsonText(await readFile(packageJsonPath, "utf8"));
47
+ return isPlainObject(parsed) ? parsed : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function auditScripts(packageJson) {
54
+ const scripts = isPlainObject(packageJson.scripts) ? packageJson.scripts : {};
55
+ const findings = [];
56
+ let scriptsChecked = 0;
57
+ for (const [scriptName, scriptValue] of Object.entries(scripts)) {
58
+ if (!lifecycleScripts.has(scriptName) || typeof scriptValue !== "string") {
59
+ continue;
60
+ }
61
+ scriptsChecked += 1;
62
+ if (containsRemotePipeInstall(scriptValue)) {
63
+ findings.push(buildFinding("fail", "trust.package.remote_pipe_install", `The package lifecycle script \`${scriptName}\` pipes remote content into a shell.`, "Remote download-and-execute scripts can run unreviewed code during install or publish workflows.", "Replace remote pipe execution with pinned package dependencies or a checked-in reviewed setup script."));
64
+ continue;
65
+ }
66
+ findings.push(buildFinding("warn", "trust.package.lifecycle_script", `The package defines lifecycle script \`${scriptName}\`.`, "Lifecycle scripts execute automatically during package manager workflows and increase supply-chain review scope.", "Keep lifecycle scripts minimal, documented, and covered by release review."));
67
+ }
68
+ return {
69
+ findings,
70
+ scriptsChecked
71
+ };
72
+ }
73
+ function dependencySections(packageJson) {
74
+ return [
75
+ packageJson.dependencies,
76
+ packageJson.devDependencies,
77
+ packageJson.optionalDependencies,
78
+ packageJson.peerDependencies
79
+ ].filter(isPlainObject);
80
+ }
81
+ function auditDependencies(packageJson) {
82
+ const findings = [];
83
+ let dependenciesChecked = 0;
84
+ for (const dependencies of dependencySections(packageJson)) {
85
+ for (const [dependencyName, versionSpec] of Object.entries(dependencies)) {
86
+ if (typeof versionSpec !== "string") {
87
+ continue;
88
+ }
89
+ dependenciesChecked += 1;
90
+ if (versionSpec === "*" || versionSpec.toLowerCase() === "latest") {
91
+ findings.push(buildFinding("warn", "trust.package.unpinned_dependency", `The dependency \`${dependencyName}\` uses broad version spec \`${versionSpec}\`.`, "Broad dependency ranges make package resolution less reproducible across installs and releases.", "Pin the dependency to a specific compatible range or exact version."));
92
+ }
93
+ if (/^(git\+|github:|http:\/\/|https:\/\/)/i.test(versionSpec)) {
94
+ findings.push(buildFinding("warn", "trust.package.remote_dependency", `The dependency \`${dependencyName}\` resolves from remote spec \`${versionSpec}\`.`, "Remote dependency specs can change outside the npm registry's normal version and integrity workflow.", "Prefer registry-published dependencies with pinned semver ranges."));
95
+ }
96
+ }
97
+ }
98
+ return {
99
+ findings,
100
+ dependenciesChecked
101
+ };
102
+ }
103
+ function dedupeFindings(findings) {
104
+ const seen = new Set();
105
+ return findings.filter((finding) => {
106
+ const key = `${finding.id}\n${finding.message}`;
107
+ if (seen.has(key)) {
108
+ return false;
109
+ }
110
+ seen.add(key);
111
+ return true;
112
+ });
113
+ }
114
+ function scoreFindings(findings) {
115
+ const failCount = findings.filter((finding) => finding.severity === "fail").length;
116
+ const warnCount = findings.filter((finding) => finding.severity === "warn").length;
117
+ return Math.max(0, 100 - (failCount * 35) - (warnCount * 10));
118
+ }
119
+ export async function buildTrustScore(targetPath, options = {}) {
120
+ const rootPath = path.resolve(targetPath);
121
+ const packageJson = await readPackageJson(rootPath);
122
+ const scriptAudit = packageJson
123
+ ? auditScripts(packageJson)
124
+ : { findings: [], scriptsChecked: 0 };
125
+ const dependencyAudit = packageJson
126
+ ? auditDependencies(packageJson)
127
+ : { findings: [], dependenciesChecked: 0 };
128
+ const securityAudit = options.securityAudit !== undefined
129
+ ? options.securityAudit
130
+ : await discoverPackage(rootPath)
131
+ ? await buildSecurityAudit(rootPath)
132
+ : null;
133
+ const findings = dedupeFindings([
134
+ ...scriptAudit.findings,
135
+ ...dependencyAudit.findings,
136
+ ...(securityAudit?.findings ?? [])
137
+ ]);
138
+ const fail = findings.filter((finding) => finding.severity === "fail").length;
139
+ const warn = findings.filter((finding) => finding.severity === "warn").length;
140
+ const score = scoreFindings(findings);
141
+ const status = fail > 0
142
+ ? "fail"
143
+ : warn > 0
144
+ ? "warn"
145
+ : "pass";
146
+ return {
147
+ schemaVersion: "1.0.0",
148
+ generatedAt: new Date().toISOString(),
149
+ targetPath: rootPath,
150
+ status,
151
+ exitCode: status === "fail" ? 1 : 0,
152
+ score,
153
+ findingCounts: {
154
+ fail,
155
+ warn,
156
+ total: findings.length
157
+ },
158
+ packageJson: {
159
+ present: packageJson !== null,
160
+ scriptsChecked: scriptAudit.scriptsChecked,
161
+ dependenciesChecked: dependencyAudit.dependenciesChecked
162
+ },
163
+ findings
164
+ };
165
+ }
166
+ export function renderTrustScoreJson(report) {
167
+ return JSON.stringify(report, null, 2);
168
+ }
169
+ export function renderTrustScore(report) {
170
+ const lines = [
171
+ "Doctor Trust Score",
172
+ "==================",
173
+ `Target: ${report.targetPath}`,
174
+ `Status: ${report.status.toUpperCase()}`,
175
+ `Score: ${report.score}/100`,
176
+ `Summary: ${report.findingCounts.fail} fail, ${report.findingCounts.warn} warn, ${report.findingCounts.total} total`
177
+ ];
178
+ if (report.findings.length === 0) {
179
+ lines.push("", "No trust findings.");
180
+ return lines.join("\n");
181
+ }
182
+ const appendSection = (title, findings, marker) => {
183
+ if (findings.length === 0) {
184
+ return;
185
+ }
186
+ lines.push("", title, "--------");
187
+ for (const finding of findings) {
188
+ lines.push(`${marker} ${finding.id}`);
189
+ lines.push(` Message: ${finding.message}`);
190
+ lines.push(` Impact: ${finding.impact}`);
191
+ lines.push(` Suggested fix: ${finding.suggestedFix}`);
192
+ }
193
+ };
194
+ appendSection("Failures", report.findings.filter((finding) => finding.severity === "fail"), "x");
195
+ appendSection("Warnings", report.findings.filter((finding) => finding.severity === "warn"), "!");
196
+ return lines.join("\n");
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",