codex-plugin-doctor 0.1.1
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/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +9 -0
- package/dist/core/discover-package.d.ts +2 -0
- package/dist/core/discover-package.js +18 -0
- package/dist/core/runtime-probe.d.ts +5 -0
- package/dist/core/runtime-probe.js +917 -0
- package/dist/core/runtime-transcript.d.ts +5 -0
- package/dist/core/runtime-transcript.js +139 -0
- package/dist/core/validate-plugin.d.ts +2 -0
- package/dist/core/validate-plugin.js +341 -0
- package/dist/domain/types.d.ts +64 -0
- package/dist/domain/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/release/release-notes.d.ts +9 -0
- package/dist/release/release-notes.js +46 -0
- package/dist/reporting/render-json-report.d.ts +7 -0
- package/dist/reporting/render-json-report.js +26 -0
- package/dist/reporting/render-markdown-report.d.ts +4 -0
- package/dist/reporting/render-markdown-report.js +44 -0
- package/dist/reporting/render-text-report.d.ts +4 -0
- package/dist/reporting/render-text-report.js +77 -0
- package/dist/run-cli.d.ts +15 -0
- package/dist/run-cli.js +87 -0
- package/dist/terminal/live-status-renderer.d.ts +9 -0
- package/dist/terminal/live-status-renderer.js +38 -0
- package/dist/terminal/output-policy.d.ts +16 -0
- package/dist/terminal/output-policy.js +50 -0
- package/dist/terminal/spinner-registry.d.ts +8 -0
- package/dist/terminal/spinner-registry.js +30 -0
- package/package.json +61 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
export declare function sanitizeTranscriptValue(value: unknown, pathSegments?: string[]): unknown;
|
|
3
|
+
export declare function formatRequestTranscript(method: string, params: JsonObject | undefined): string;
|
|
4
|
+
export declare function formatResponseTranscript(method: string, message: JsonObject): string;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function isErrorResponse(message) {
|
|
5
|
+
return isPlainObject(message.error);
|
|
6
|
+
}
|
|
7
|
+
function getErrorObject(message) {
|
|
8
|
+
return isErrorResponse(message) ? message.error : null;
|
|
9
|
+
}
|
|
10
|
+
function isSensitiveKey(key) {
|
|
11
|
+
return /(token|secret|password|api[_-]?key|private[_-]?key|sig|signature|auth|session)/i.test(key);
|
|
12
|
+
}
|
|
13
|
+
function looksLikeToken(value) {
|
|
14
|
+
return /(sk-[A-Za-z0-9_-]{8,}|Bearer\s+[A-Za-z0-9._-]{8,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)/.test(value);
|
|
15
|
+
}
|
|
16
|
+
function sanitizeUriQuery(value) {
|
|
17
|
+
try {
|
|
18
|
+
const uri = new URL(value);
|
|
19
|
+
const sanitizedQuery = Object.fromEntries(Array.from(uri.searchParams.entries()).map(([key, queryValue]) => [
|
|
20
|
+
key,
|
|
21
|
+
isSensitiveKey(key) || looksLikeToken(queryValue)
|
|
22
|
+
? "[REDACTED]"
|
|
23
|
+
: queryValue
|
|
24
|
+
]));
|
|
25
|
+
if (Object.keys(sanitizedQuery).length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return sanitizedQuery;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function sanitizeTranscriptValue(value, pathSegments = []) {
|
|
35
|
+
const currentKey = pathSegments[pathSegments.length - 1];
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
if (currentKey === "uri") {
|
|
38
|
+
const query = sanitizeUriQuery(value);
|
|
39
|
+
if (query) {
|
|
40
|
+
return {
|
|
41
|
+
uri: value.split("?")[0],
|
|
42
|
+
query
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (currentKey === "text" ||
|
|
47
|
+
currentKey === "blob" ||
|
|
48
|
+
currentKey === "data" ||
|
|
49
|
+
currentKey === "diff" ||
|
|
50
|
+
currentKey === "arguments" ||
|
|
51
|
+
isSensitiveKey(currentKey ?? "") ||
|
|
52
|
+
pathSegments.includes("arguments") ||
|
|
53
|
+
looksLikeToken(value)) {
|
|
54
|
+
return "[REDACTED]";
|
|
55
|
+
}
|
|
56
|
+
if (value.length > 80) {
|
|
57
|
+
return "[TRUNCATED]";
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return value.map((entry, index) => sanitizeTranscriptValue(entry, [...pathSegments, String(index)]));
|
|
63
|
+
}
|
|
64
|
+
if (isPlainObject(value)) {
|
|
65
|
+
return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [
|
|
66
|
+
key,
|
|
67
|
+
sanitizeTranscriptValue(entryValue, [...pathSegments, key])
|
|
68
|
+
]));
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
export function formatRequestTranscript(method, params) {
|
|
73
|
+
if (!params) {
|
|
74
|
+
return `-> ${method}`;
|
|
75
|
+
}
|
|
76
|
+
return `-> ${method} ${JSON.stringify(sanitizeTranscriptValue(params))}`;
|
|
77
|
+
}
|
|
78
|
+
export function formatResponseTranscript(method, message) {
|
|
79
|
+
const error = getErrorObject(message);
|
|
80
|
+
if (error) {
|
|
81
|
+
const code = typeof error.code === "number" ? error.code : "?";
|
|
82
|
+
const messageText = typeof error.message === "string"
|
|
83
|
+
? sanitizeTranscriptValue(error.message, ["error", "message"])
|
|
84
|
+
: "error";
|
|
85
|
+
return `<- ${method} error ${JSON.stringify({
|
|
86
|
+
code,
|
|
87
|
+
message: messageText
|
|
88
|
+
})}`;
|
|
89
|
+
}
|
|
90
|
+
if (!isPlainObject(message.result)) {
|
|
91
|
+
return `<- ${method} result`;
|
|
92
|
+
}
|
|
93
|
+
const result = message.result;
|
|
94
|
+
switch (method) {
|
|
95
|
+
case "initialize":
|
|
96
|
+
return `<- initialize ${JSON.stringify({
|
|
97
|
+
protocolVersion: result.protocolVersion,
|
|
98
|
+
capabilities: isPlainObject(result.capabilities)
|
|
99
|
+
? Object.keys(result.capabilities)
|
|
100
|
+
: []
|
|
101
|
+
})}`;
|
|
102
|
+
case "tools/list":
|
|
103
|
+
return `<- tools/list ${JSON.stringify({
|
|
104
|
+
tools: Array.isArray(result.tools) ? result.tools.length : 0,
|
|
105
|
+
nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
|
|
106
|
+
})}`;
|
|
107
|
+
case "tools/call":
|
|
108
|
+
return `<- tools/call ${JSON.stringify({
|
|
109
|
+
content: Array.isArray(result.content) ? result.content.length : 0
|
|
110
|
+
})}`;
|
|
111
|
+
case "resources/list":
|
|
112
|
+
return `<- resources/list ${JSON.stringify({
|
|
113
|
+
resources: Array.isArray(result.resources) ? result.resources.length : 0,
|
|
114
|
+
nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
|
|
115
|
+
})}`;
|
|
116
|
+
case "resources/read":
|
|
117
|
+
return `<- resources/read ${JSON.stringify({
|
|
118
|
+
contents: Array.isArray(result.contents) ? result.contents.length : 0
|
|
119
|
+
})}`;
|
|
120
|
+
case "resources/templates/list":
|
|
121
|
+
return `<- resources/templates/list ${JSON.stringify({
|
|
122
|
+
resourceTemplates: Array.isArray(result.resourceTemplates)
|
|
123
|
+
? result.resourceTemplates.length
|
|
124
|
+
: 0,
|
|
125
|
+
nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
|
|
126
|
+
})}`;
|
|
127
|
+
case "prompts/list":
|
|
128
|
+
return `<- prompts/list ${JSON.stringify({
|
|
129
|
+
prompts: Array.isArray(result.prompts) ? result.prompts.length : 0,
|
|
130
|
+
nextCursor: typeof result.nextCursor === "string" ? "[CURSOR]" : undefined
|
|
131
|
+
})}`;
|
|
132
|
+
case "prompts/get":
|
|
133
|
+
return `<- prompts/get ${JSON.stringify({
|
|
134
|
+
messages: Array.isArray(result.messages) ? result.messages.length : 0
|
|
135
|
+
})}`;
|
|
136
|
+
default:
|
|
137
|
+
return `<- ${method}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { discoverPackage } from "./discover-package.js";
|
|
4
|
+
import { probeRuntime } from "./runtime-probe.js";
|
|
5
|
+
function buildFailure(id, message, impact, suggestedFix) {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
severity: "fail",
|
|
9
|
+
message,
|
|
10
|
+
impact,
|
|
11
|
+
suggestedFix
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function buildWarning(id, message, impact, suggestedFix) {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
severity: "warn",
|
|
18
|
+
message,
|
|
19
|
+
impact,
|
|
20
|
+
suggestedFix
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function directoryExists(targetPath) {
|
|
24
|
+
try {
|
|
25
|
+
const details = await stat(targetPath);
|
|
26
|
+
return details.isDirectory();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function fileExists(targetPath) {
|
|
33
|
+
try {
|
|
34
|
+
const details = await stat(targetPath);
|
|
35
|
+
return details.isFile();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isPathWithinRoot(rootPath, candidatePath) {
|
|
45
|
+
const relativePath = path.relative(rootPath, candidatePath);
|
|
46
|
+
return (relativePath === "" ||
|
|
47
|
+
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
48
|
+
}
|
|
49
|
+
function isSensitiveKey(key) {
|
|
50
|
+
return /(token|secret|password|api[_-]?key|private[_-]?key)/i.test(key);
|
|
51
|
+
}
|
|
52
|
+
function looksLikeLiteralSecret(value) {
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (trimmed.length < 12) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (/^\$\{?[A-Z0-9_]+\}?$/i.test(trimmed)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
function parseSkillFrontmatter(content) {
|
|
63
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
64
|
+
if (!match) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const lines = match[1].split(/\r?\n/);
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
70
|
+
const line = lines[index];
|
|
71
|
+
if (!line.trim()) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const separatorIndex = line.indexOf(":");
|
|
75
|
+
if (separatorIndex === -1) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
79
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
80
|
+
if (!key || !rawValue) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const blockScalarMatch = rawValue.match(/^([|>])[+-]?$/);
|
|
84
|
+
if (blockScalarMatch) {
|
|
85
|
+
const blockLines = [];
|
|
86
|
+
let blockIndex = index + 1;
|
|
87
|
+
for (; blockIndex < lines.length; blockIndex += 1) {
|
|
88
|
+
const blockLine = lines[blockIndex];
|
|
89
|
+
if (blockLine.trim() !== "" && !/^\s/.test(blockLine)) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
blockLines.push(blockLine.replace(/^\s{2}/, ""));
|
|
93
|
+
}
|
|
94
|
+
const value = blockScalarMatch[1] === ">"
|
|
95
|
+
? blockLines.join("\n").replace(/\n[ \t]*\n/g, "\n\n").replace(/\n/g, " ").trim()
|
|
96
|
+
: blockLines.join("\n").trim();
|
|
97
|
+
if (value) {
|
|
98
|
+
entries.push([key, value]);
|
|
99
|
+
}
|
|
100
|
+
index = blockIndex - 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const value = rawValue.replace(/^"([\s\S]*)"$/, "$1").replace(/^'([\s\S]*)'$/, "$1");
|
|
104
|
+
if (value) {
|
|
105
|
+
entries.push([key, value]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Object.fromEntries(entries);
|
|
109
|
+
}
|
|
110
|
+
function countMatches(input, pattern) {
|
|
111
|
+
const matches = input.match(pattern);
|
|
112
|
+
return matches ? matches.length : 0;
|
|
113
|
+
}
|
|
114
|
+
function isDescriptionLikelyVerbose(description, mode) {
|
|
115
|
+
const trimmed = description.trim();
|
|
116
|
+
const length = trimmed.length;
|
|
117
|
+
const technicalSignals = countMatches(trimmed, /`[^`]+`/g) +
|
|
118
|
+
countMatches(trimmed, /\b(MCP|SDK|CLI|API|JSON|schema|resource|resources|prompt|prompts|tool|tools|repo|repository|command|commands|connector|metadata|workflow|validation|inputs|outputs|GitHub|GraphQL|PR|review|Cloudflare|Workers|Wrangler|D1|R2|Vectorize|Queues|Workflows|Tunnel|Spectrum|WAF|DDoS|Terraform|Pulumi|Figma|FigJam|design system|component|components|variants|auto-layout|token|tokens|library|libraries|screen|screens|React|WebSocket)\b/gi);
|
|
119
|
+
const productSignals = countMatches(trimmed, /\b(frontend|dashboard|dashboards|website|websites|hero|UI|browser|testing|Stripe|Checkout|PaymentIntents|Connect|billing|subscriptions|payment|payments|marketplace|marketplaces|Jira|Confluence|bug|bugs|issue|issues|ticket|tickets|backlog|Epic|Epics|status|report|reports|project|tasks|meeting|notes|action items|assignees|knowledge|documentation|deployment|authentication|infrastructure|architecture|duplicates|Postgres|database|databases|bills|costs|transfer|egress|query|queries|overfetching|SELECT|optimization|application|GSAP|animation|animations|animate|easing|stagger|timeline|timelines|playback|transforms|will-change|quickTo|video|videos|composition|compositions|title cards|overlays|captions|subtitles|voiceovers|audio|audio-reactive|visuals|scene|scenes|transitions|HTML|text-to-speech|music|highlighting|crossfades|wipes|shader|media|render|preview|transcribe|Canva|presentation|presentations|slide|slides|deck|brief|outline|design|designs|brand|brand kit|social media|Facebook|Instagram|LinkedIn|export-ready|formats|localized|translated|layout)\b/gi);
|
|
120
|
+
const structuredSignals = countMatches(trimmed, /\(\d+\)/g) +
|
|
121
|
+
countMatches(trimmed, /\b(Use when|When an agent needs to|Triggers:)\b/gi);
|
|
122
|
+
const concreteSignals = technicalSignals + productSignals + structuredSignals;
|
|
123
|
+
const vagueSignals = countMatches(trimmed, /\b(general|generally|many different|many|broad|various|different situations|possibilities|ideas|concepts|directions)\b/gi);
|
|
124
|
+
if (mode === "plugin") {
|
|
125
|
+
return length > 240;
|
|
126
|
+
}
|
|
127
|
+
if (length <= 240) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (vagueSignals >= 4) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (vagueSignals >= 2 && concreteSignals < 8) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (concreteSignals >= 8 && vagueSignals <= 2 && length <= 650) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (concreteSignals >= 12 && vagueSignals <= 2 && length <= 780) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (technicalSignals >= 8 && vagueSignals <= 1 && length <= 760) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (length >= 700) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (technicalSignals >= 3 && vagueSignals === 0 && length <= 600) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (technicalSignals >= 8 && vagueSignals <= 1 && length <= 420) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (concreteSignals >= 4 && vagueSignals <= 1 && length <= 520) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (technicalSignals >= 2 && vagueSignals === 0 && length <= 480) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
function validateRequiredManifestFields(discoveredPackage) {
|
|
163
|
+
const findings = [];
|
|
164
|
+
const { manifest } = discoveredPackage;
|
|
165
|
+
if (!manifest.name) {
|
|
166
|
+
findings.push(buildFailure("plugin.manifest.name.missing", "The plugin manifest is missing a `name` field.", "Codex cannot identify the plugin reliably without a stable package name.", "Add a kebab-case `name` field to `.codex-plugin/plugin.json`."));
|
|
167
|
+
}
|
|
168
|
+
if (!manifest.version) {
|
|
169
|
+
findings.push(buildFailure("plugin.manifest.version.missing", "The plugin manifest is missing a `version` field.", "Release and compatibility workflows cannot reason about the package version.", "Add a semantic `version` field to `.codex-plugin/plugin.json`."));
|
|
170
|
+
}
|
|
171
|
+
if (!manifest.description) {
|
|
172
|
+
findings.push(buildFailure("plugin.manifest.description.missing", "The plugin manifest is missing a `description` field.", "The package will be harder to understand and present in Codex surfaces.", "Add a concise `description` field to `.codex-plugin/plugin.json`."));
|
|
173
|
+
}
|
|
174
|
+
if (manifest.description &&
|
|
175
|
+
isDescriptionLikelyVerbose(manifest.description, "plugin")) {
|
|
176
|
+
findings.push(buildWarning("plugin.heuristic.description.too_long", "The plugin manifest description is likely too verbose.", "Overly long metadata increases context cost and can dilute plugin discovery quality.", "Shorten the manifest description to a precise one- or two-sentence summary."));
|
|
177
|
+
}
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
|
180
|
+
async function validateSkillsDirectory(discoveredPackage) {
|
|
181
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
182
|
+
if (!manifest.skills) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const skillsPath = path.resolve(rootPath, manifest.skills);
|
|
186
|
+
if (!isPathWithinRoot(rootPath, skillsPath)) {
|
|
187
|
+
return [
|
|
188
|
+
buildFailure("plugin.security.path_traversal", "The plugin manifest points the skills path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `skills` path inside the plugin root.")
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
const exists = await directoryExists(skillsPath);
|
|
192
|
+
if (exists) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
return [
|
|
196
|
+
buildFailure("plugin.skills.path.missing", "The plugin manifest points to a missing skills directory.", "Codex will not be able to load the packaged skills as expected.", `Create the skills directory at \`${skillsPath}\` or update the manifest path.`)
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
async function validateSkillDefinitions(discoveredPackage) {
|
|
200
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
201
|
+
if (!manifest.skills) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const skillsPath = path.resolve(rootPath, manifest.skills);
|
|
205
|
+
if (!isPathWithinRoot(rootPath, skillsPath)) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const skillsDirectoryExists = await directoryExists(skillsPath);
|
|
209
|
+
if (!skillsDirectoryExists) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
const entries = await readdir(skillsPath, { withFileTypes: true });
|
|
213
|
+
const skillDirectories = entries.filter((entry) => entry.isDirectory());
|
|
214
|
+
const findings = [];
|
|
215
|
+
for (const skillDirectory of skillDirectories) {
|
|
216
|
+
const skillRoot = path.join(skillsPath, skillDirectory.name);
|
|
217
|
+
const skillFilePath = path.join(skillRoot, "SKILL.md");
|
|
218
|
+
const skillFileExists = await fileExists(skillFilePath);
|
|
219
|
+
if (!skillFileExists) {
|
|
220
|
+
findings.push(buildFailure("plugin.skill.skill_md.missing", `The skill \`${skillDirectory.name}\` is missing \`SKILL.md\`.`, "Codex cannot load a skill directory that does not contain the required SKILL.md entrypoint.", `Add \`SKILL.md\` to \`${skillRoot}\` with at least \`name\` and \`description\` frontmatter.`));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const skillContent = await readFile(skillFilePath, "utf8");
|
|
224
|
+
const frontmatter = parseSkillFrontmatter(skillContent);
|
|
225
|
+
if (!frontmatter?.name) {
|
|
226
|
+
findings.push(buildFailure("plugin.skill.name.missing", `The skill \`${skillDirectory.name}\` is missing a \`name\` field in frontmatter.`, "Codex cannot expose skill metadata correctly without a stable skill name.", `Add a \`name\` field to the frontmatter in \`${skillFilePath}\`.`));
|
|
227
|
+
}
|
|
228
|
+
if (!frontmatter?.description) {
|
|
229
|
+
findings.push(buildFailure("plugin.skill.description.missing", `The skill \`${skillDirectory.name}\` is missing a \`description\` field in frontmatter.`, "Codex uses skill descriptions for discovery and implicit matching, so missing descriptions reduce skill usability.", `Add a scoped \`description\` field to the frontmatter in \`${skillFilePath}\`.`));
|
|
230
|
+
}
|
|
231
|
+
if (frontmatter?.description &&
|
|
232
|
+
isDescriptionLikelyVerbose(frontmatter.description, "skill")) {
|
|
233
|
+
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.`));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return findings;
|
|
237
|
+
}
|
|
238
|
+
async function validateMcpConfig(discoveredPackage) {
|
|
239
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
240
|
+
if (!manifest.mcpServers) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
|
|
244
|
+
if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
|
|
245
|
+
return [
|
|
246
|
+
buildFailure("plugin.security.path_traversal", "The plugin manifest points the MCP config path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `mcpServers` path inside the plugin root.")
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
const mcpConfigExists = await fileExists(mcpConfigPath);
|
|
250
|
+
if (!mcpConfigExists) {
|
|
251
|
+
return [
|
|
252
|
+
buildFailure("plugin.mcp.path.missing", "The plugin manifest points to a missing `.mcp.json` file.", "Codex cannot load bundled MCP server definitions if the referenced config file does not exist.", `Create \`${mcpConfigPath}\` or update the manifest \`mcpServers\` path.`)
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
let parsedConfig;
|
|
256
|
+
try {
|
|
257
|
+
parsedConfig = JSON.parse(await readFile(mcpConfigPath, "utf8"));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return [
|
|
261
|
+
buildFailure("plugin.mcp.invalid_json", "The referenced `.mcp.json` file is not valid JSON.", "Codex will not be able to parse bundled MCP server configuration.", `Fix the JSON syntax in \`${mcpConfigPath}\`.`)
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
if (!isPlainObject(parsedConfig)) {
|
|
265
|
+
return [
|
|
266
|
+
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a JSON object.", "Codex expects bundled MCP configuration to be object-shaped so server entries can be resolved reliably.", `Wrap the MCP configuration in a top-level object inside \`${mcpConfigPath}\`.`)
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
const servers = parsedConfig.mcpServers;
|
|
270
|
+
if (!isPlainObject(servers) || Object.keys(servers).length === 0) {
|
|
271
|
+
return [
|
|
272
|
+
buildFailure("plugin.mcp.invalid_shape", "The referenced `.mcp.json` file must contain a non-empty `mcpServers` object.", "Without a valid `mcpServers` object, Codex cannot discover the bundled MCP server definitions.", `Define bundled servers under \`mcpServers\` in \`${mcpConfigPath}\`.`)
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
const findings = [];
|
|
276
|
+
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
|
277
|
+
if (!isPlainObject(serverConfig)) {
|
|
278
|
+
findings.push(buildFailure("plugin.mcp.server.invalid", `The MCP server \`${serverName}\` must be configured as an object.`, "Codex cannot interpret a server entry unless it is represented as an object with server options.", `Change the \`${serverName}\` entry in \`${mcpConfigPath}\` to an object.`));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const command = serverConfig.command;
|
|
282
|
+
const url = serverConfig.url;
|
|
283
|
+
const env = serverConfig.env;
|
|
284
|
+
if (typeof command !== "string" && typeof url !== "string") {
|
|
285
|
+
findings.push(buildFailure("plugin.mcp.server.transport.missing", `The MCP server \`${serverName}\` must define either \`command\` or \`url\`.`, "Codex needs a process command for STDIO servers or a URL for streamable HTTP servers.", `Add either \`command\` or \`url\` to the \`${serverName}\` entry in \`${mcpConfigPath}\`.`));
|
|
286
|
+
}
|
|
287
|
+
if (isPlainObject(env)) {
|
|
288
|
+
for (const [envKey, envValue] of Object.entries(env)) {
|
|
289
|
+
if (isSensitiveKey(envKey) &&
|
|
290
|
+
typeof envValue === "string" &&
|
|
291
|
+
looksLikeLiteralSecret(envValue)) {
|
|
292
|
+
findings.push(buildFailure("plugin.security.hard_coded_secret", `The MCP server \`${serverName}\` contains a hard-coded secret-like env value for \`${envKey}\`.`, "Hard-coded credentials inside plugin bundles increase leakage risk and make secure rotation difficult.", `Replace the literal value for \`${envKey}\` with an environment reference or injected secret outside the package.`));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return findings;
|
|
298
|
+
}
|
|
299
|
+
export async function validatePlugin(targetPath, options = {}) {
|
|
300
|
+
const discoveredPackage = await discoverPackage(targetPath);
|
|
301
|
+
if (!discoveredPackage) {
|
|
302
|
+
return {
|
|
303
|
+
targetPath: path.resolve(targetPath),
|
|
304
|
+
status: "fail",
|
|
305
|
+
exitCode: 1,
|
|
306
|
+
findings: [
|
|
307
|
+
buildFailure("plugin.manifest.missing", "Missing required `.codex-plugin/plugin.json` manifest.", "Codex cannot treat this directory as a plugin package without the required manifest entry point.", "Create `.codex-plugin/plugin.json` with at least `name`, `version`, and `description`.")
|
|
308
|
+
]
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const runtimeResult = options.runtime
|
|
312
|
+
? await probeRuntime(discoveredPackage, {
|
|
313
|
+
transcript: options.runtimeTranscript
|
|
314
|
+
})
|
|
315
|
+
: null;
|
|
316
|
+
const findings = [
|
|
317
|
+
...validateRequiredManifestFields(discoveredPackage),
|
|
318
|
+
...(await validateSkillsDirectory(discoveredPackage)),
|
|
319
|
+
...(await validateSkillDefinitions(discoveredPackage)),
|
|
320
|
+
...(await validateMcpConfig(discoveredPackage)),
|
|
321
|
+
...(runtimeResult ? runtimeResult.findings : [])
|
|
322
|
+
];
|
|
323
|
+
const hasFailures = findings.some((finding) => finding.severity === "fail");
|
|
324
|
+
const hasWarnings = findings.some((finding) => finding.severity === "warn");
|
|
325
|
+
if (!hasFailures && !hasWarnings) {
|
|
326
|
+
return {
|
|
327
|
+
targetPath: discoveredPackage.rootPath,
|
|
328
|
+
status: "pass",
|
|
329
|
+
exitCode: 0,
|
|
330
|
+
findings: [],
|
|
331
|
+
...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {})
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
targetPath: discoveredPackage.rootPath,
|
|
336
|
+
status: hasFailures ? "fail" : "warn",
|
|
337
|
+
exitCode: hasFailures ? 1 : 0,
|
|
338
|
+
findings,
|
|
339
|
+
...(runtimeResult ? { runtimeScorecard: runtimeResult.scorecard } : {})
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type FindingSeverity = "warn" | "fail";
|
|
2
|
+
export interface Finding {
|
|
3
|
+
id: string;
|
|
4
|
+
severity: FindingSeverity;
|
|
5
|
+
message: string;
|
|
6
|
+
impact: string;
|
|
7
|
+
suggestedFix: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CheckResult {
|
|
10
|
+
targetPath: string;
|
|
11
|
+
status: "pass" | "warn" | "fail";
|
|
12
|
+
exitCode: 0 | 1;
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
runtimeScorecard?: RuntimeScorecard;
|
|
15
|
+
}
|
|
16
|
+
export interface CheckOptions {
|
|
17
|
+
runtime?: boolean;
|
|
18
|
+
runtimeTranscript?: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface PluginManifest {
|
|
21
|
+
name?: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
skills?: string;
|
|
25
|
+
mcpServers?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DiscoveredPackage {
|
|
28
|
+
rootPath: string;
|
|
29
|
+
manifestPath: string;
|
|
30
|
+
manifest: PluginManifest;
|
|
31
|
+
}
|
|
32
|
+
export interface JsonReportSummary {
|
|
33
|
+
targetPath: string;
|
|
34
|
+
status: "pass" | "warn" | "fail";
|
|
35
|
+
exitCode: 0 | 1;
|
|
36
|
+
runtimeProbeEnabled: boolean;
|
|
37
|
+
runtimeScorecard?: RuntimeScorecard;
|
|
38
|
+
findingCounts: {
|
|
39
|
+
fail: number;
|
|
40
|
+
warn: number;
|
|
41
|
+
total: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface JsonReport {
|
|
45
|
+
schemaVersion: "1.0.0";
|
|
46
|
+
generatedAt: string;
|
|
47
|
+
summary: JsonReportSummary;
|
|
48
|
+
findings: Finding[];
|
|
49
|
+
}
|
|
50
|
+
export type RuntimeCapabilityStatus = "pass" | "fail" | "warn" | "skipped" | "unsupported";
|
|
51
|
+
export interface RuntimeScorecard {
|
|
52
|
+
initialize: RuntimeCapabilityStatus;
|
|
53
|
+
toolsList: RuntimeCapabilityStatus;
|
|
54
|
+
toolsCall: RuntimeCapabilityStatus;
|
|
55
|
+
resourcesList: RuntimeCapabilityStatus;
|
|
56
|
+
resourceRead: RuntimeCapabilityStatus;
|
|
57
|
+
resourceTemplatesList: RuntimeCapabilityStatus;
|
|
58
|
+
promptsList: RuntimeCapabilityStatus;
|
|
59
|
+
promptGet: RuntimeCapabilityStatus;
|
|
60
|
+
}
|
|
61
|
+
export interface RuntimeProbeResult {
|
|
62
|
+
findings: Finding[];
|
|
63
|
+
scorecard: RuntimeScorecard;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function extractReleaseSection(changelog: string, version: string): string | null;
|
|
2
|
+
export declare function buildReleaseCandidateNotes(input: {
|
|
3
|
+
version: string;
|
|
4
|
+
generatedAt: string;
|
|
5
|
+
validationTarget: string;
|
|
6
|
+
runtimeTarget: string;
|
|
7
|
+
packageFilename: string;
|
|
8
|
+
changelogSection: string | null;
|
|
9
|
+
}): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export function extractReleaseSection(changelog, version) {
|
|
2
|
+
const lines = changelog.split(/\r?\n/);
|
|
3
|
+
const sectionHeader = `## [${version}]`;
|
|
4
|
+
const startIndex = lines.findIndex((line) => line.startsWith(sectionHeader));
|
|
5
|
+
if (startIndex === -1) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
let endIndex = lines.length;
|
|
9
|
+
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
10
|
+
if (lines[index].startsWith("## [")) {
|
|
11
|
+
endIndex = index;
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return lines.slice(startIndex, endIndex).join("\n").trim();
|
|
16
|
+
}
|
|
17
|
+
export function buildReleaseCandidateNotes(input) {
|
|
18
|
+
const { version, generatedAt, validationTarget, runtimeTarget, packageFilename, changelogSection } = input;
|
|
19
|
+
return [
|
|
20
|
+
`# Release Candidate ${version}`,
|
|
21
|
+
"",
|
|
22
|
+
`Generated at: ${generatedAt}`,
|
|
23
|
+
"",
|
|
24
|
+
"## Package Artifact",
|
|
25
|
+
"",
|
|
26
|
+
`- Tarball: \`${packageFilename}\``,
|
|
27
|
+
"",
|
|
28
|
+
"## Validation Targets",
|
|
29
|
+
"",
|
|
30
|
+
`- Static target: \`${validationTarget}\``,
|
|
31
|
+
`- Runtime target: \`${runtimeTarget}\``,
|
|
32
|
+
"",
|
|
33
|
+
"## Included Release Notes",
|
|
34
|
+
"",
|
|
35
|
+
changelogSection ?? "_No changelog section found for this version._",
|
|
36
|
+
"",
|
|
37
|
+
"## Validation Checklist",
|
|
38
|
+
"",
|
|
39
|
+
"- `npm test`",
|
|
40
|
+
"- `npm run build`",
|
|
41
|
+
"- `npm run prepare-release`",
|
|
42
|
+
"- local validation artifacts generated",
|
|
43
|
+
"- tarball generated for inspection",
|
|
44
|
+
""
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CheckResult, JsonReport } from "../domain/types.js";
|
|
2
|
+
export declare function buildJsonReport(result: CheckResult, options: {
|
|
3
|
+
runtimeProbeEnabled: boolean;
|
|
4
|
+
}): JsonReport;
|
|
5
|
+
export declare function renderJsonReport(result: CheckResult, options: {
|
|
6
|
+
runtimeProbeEnabled: boolean;
|
|
7
|
+
}): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function buildJsonReport(result, options) {
|
|
2
|
+
const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
|
|
3
|
+
const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
|
|
4
|
+
return {
|
|
5
|
+
schemaVersion: "1.0.0",
|
|
6
|
+
generatedAt: new Date().toISOString(),
|
|
7
|
+
summary: {
|
|
8
|
+
targetPath: result.targetPath,
|
|
9
|
+
status: result.status,
|
|
10
|
+
exitCode: result.exitCode,
|
|
11
|
+
runtimeProbeEnabled: options.runtimeProbeEnabled,
|
|
12
|
+
...(result.runtimeScorecard
|
|
13
|
+
? { runtimeScorecard: result.runtimeScorecard }
|
|
14
|
+
: {}),
|
|
15
|
+
findingCounts: {
|
|
16
|
+
fail: failCount,
|
|
17
|
+
warn: warnCount,
|
|
18
|
+
total: result.findings.length
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
findings: result.findings
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function renderJsonReport(result, options) {
|
|
25
|
+
return JSON.stringify(buildJsonReport(result, options), null, 2);
|
|
26
|
+
}
|