claudecode-linter 2.1.148 → 2.1.150
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/.claudecode-lint.defaults.yaml +13 -0
- package/README.md +90 -3
- package/contracts/agent-frontmatter.schema.json +141 -1
- package/contracts/command-frontmatter.schema.json +127 -1
- package/contracts/hooks.schema.json +275 -0
- package/contracts/mcp.schema.json +302 -0
- package/contracts/settings.schema.json +285 -2
- package/contracts/skill-frontmatter.schema.json +197 -1
- package/dist/config.js +13 -8
- package/dist/contracts.js +20 -1
- package/dist/formatters/human.js +3 -2
- package/dist/index.js +72 -12
- package/dist/linters/agent-md.js +29 -1
- package/dist/linters/command-md.js +37 -1
- package/dist/linters/hooks-json.js +24 -0
- package/dist/linters/mcp-json.js +24 -0
- package/dist/linters/skill-md.js +41 -0
- package/dist/plugin-schema.js +17 -8
- package/dist/utils/asset-path.js +56 -0
- package/dist/utils/effort.js +29 -0
- package/dist/utils/frontmatter-keys.js +32 -3
- package/dist/utils/frontmatter.js +1 -1
- package/dist/utils/safe-write.js +36 -0
- package/dist/utils/terminal.js +18 -0
- package/package.json +3 -1
package/dist/formatters/human.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { sanitizeForTerminal } from "../utils/terminal.js";
|
|
2
3
|
const SEVERITY_ICONS = {
|
|
3
4
|
error: pc.red("error"),
|
|
4
5
|
warning: pc.yellow("warn "),
|
|
@@ -16,12 +17,12 @@ export function formatHuman(results, quiet) {
|
|
|
16
17
|
if (filtered.length === 0)
|
|
17
18
|
continue;
|
|
18
19
|
lines.push("");
|
|
19
|
-
lines.push(pc.underline(result.file));
|
|
20
|
+
lines.push(pc.underline(sanitizeForTerminal(result.file)));
|
|
20
21
|
for (const d of filtered) {
|
|
21
22
|
const loc = d.line
|
|
22
23
|
? pc.dim(`:${d.line}${d.column ? `:${d.column}` : ""}`)
|
|
23
24
|
: "";
|
|
24
|
-
lines.push(` ${SEVERITY_ICONS[d.severity]} ${d.message} ${pc.dim(d.rule)}${loc}`);
|
|
25
|
+
lines.push(` ${SEVERITY_ICONS[d.severity]} ${sanitizeForTerminal(d.message)} ${pc.dim(d.rule)}${loc}`);
|
|
25
26
|
if (d.severity === "error")
|
|
26
27
|
errorCount++;
|
|
27
28
|
else if (d.severity === "warning")
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, statSync, } from "node:fs";
|
|
3
3
|
import { resolve, relative, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import sade from "sade";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
import { loadConfig, mergeCliRules } from "./config.js";
|
|
8
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
9
|
+
import { writeBlockedReason } from "./utils/safe-write.js";
|
|
10
|
+
import { sanitizeForTerminal } from "./utils/terminal.js";
|
|
8
11
|
import { discoverArtifacts, detectArtifactTypes } from "./discovery.js";
|
|
9
12
|
import { formatHuman } from "./formatters/human.js";
|
|
10
13
|
import { formatJson } from "./formatters/json.js";
|
|
@@ -71,6 +74,12 @@ const ALL_RULES = [
|
|
|
71
74
|
...MONITORS_JSON_RULES,
|
|
72
75
|
...MISPLACED_FILE_RULES,
|
|
73
76
|
];
|
|
77
|
+
/**
|
|
78
|
+
* Hard cap on artifact file size. Real Claude Code artifacts are KB-scale;
|
|
79
|
+
* this only exists to reject pathological / malicious inputs (e.g. a
|
|
80
|
+
* multi-gigabyte file crafted to exhaust memory).
|
|
81
|
+
*/
|
|
82
|
+
const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
|
|
74
83
|
function simpleDiff(oldContent, newContent, filePath) {
|
|
75
84
|
if (oldContent === newContent)
|
|
76
85
|
return "";
|
|
@@ -106,7 +115,21 @@ function simpleDiff(oldContent, newContent, filePath) {
|
|
|
106
115
|
}
|
|
107
116
|
return lines.join("\n");
|
|
108
117
|
}
|
|
109
|
-
|
|
118
|
+
function readPkgVersion() {
|
|
119
|
+
// package.json ships beside dist/ (Node) or beside the executable
|
|
120
|
+
// (bun-compiled single-executable). Try every candidate; fall back to
|
|
121
|
+
// "0.0.0" if none resolve (e.g. an unexpected layout) rather than crashing.
|
|
122
|
+
for (const p of assetCandidates(import.meta.url, ["..", "package.json"])) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(p, "utf8")).version;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// try next candidate
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return "0.0.0";
|
|
131
|
+
}
|
|
132
|
+
const pkgVersion = readPkgVersion();
|
|
110
133
|
sade("claudecode-linter", true)
|
|
111
134
|
.version(pkgVersion)
|
|
112
135
|
.describe("Linter for Claude Code plugin artifacts")
|
|
@@ -133,9 +156,11 @@ sade("claudecode-linter", true)
|
|
|
133
156
|
const fixDryRun = !!opts["fix-dry-run"];
|
|
134
157
|
try {
|
|
135
158
|
if (opts.init !== undefined) {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
const defaultsFile = assetCandidates(import.meta.url, [
|
|
160
|
+
"..",
|
|
161
|
+
".claudecode-lint.defaults.yaml",
|
|
162
|
+
]).find((p) => existsSync(p)) ??
|
|
163
|
+
join(dirname(dirname(fileURLToPath(import.meta.url))), ".claudecode-lint.defaults.yaml");
|
|
139
164
|
const targetDir = typeof opts.init === "string" ? resolve(opts.init) : process.cwd();
|
|
140
165
|
const targetFile = join(targetDir, ".claudecode-lint.yaml");
|
|
141
166
|
if (existsSync(targetFile)) {
|
|
@@ -197,10 +222,33 @@ sade("claudecode-linter", true)
|
|
|
197
222
|
ignore: ignorePatterns,
|
|
198
223
|
});
|
|
199
224
|
if (artifacts.length === 0) {
|
|
200
|
-
process.stderr.write(pc.yellow(`No plugin artifacts found in ${targetPath}\n`));
|
|
225
|
+
process.stderr.write(pc.yellow(`No plugin artifacts found in ${sanitizeForTerminal(targetPath)}\n`));
|
|
201
226
|
continue;
|
|
202
227
|
}
|
|
228
|
+
// All --fix / --format writes for this target must stay inside
|
|
229
|
+
// rootDir. If targetPath is a file, rootDir is its parent dir.
|
|
230
|
+
const resolvedTarget = resolve(targetPath);
|
|
231
|
+
let rootDir = resolvedTarget;
|
|
232
|
+
try {
|
|
233
|
+
if (!statSync(resolvedTarget).isDirectory()) {
|
|
234
|
+
rootDir = dirname(resolvedTarget);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
rootDir = dirname(resolvedTarget);
|
|
239
|
+
}
|
|
203
240
|
for (const artifact of artifacts) {
|
|
241
|
+
let sizeBytes;
|
|
242
|
+
try {
|
|
243
|
+
sizeBytes = statSync(artifact.filePath).size;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
sizeBytes = 0;
|
|
247
|
+
}
|
|
248
|
+
if (sizeBytes > MAX_ARTIFACT_BYTES) {
|
|
249
|
+
process.stderr.write(pc.yellow(`Skipping ${sanitizeForTerminal(artifact.filePath)}: file exceeds ${MAX_ARTIFACT_BYTES}-byte limit (${sizeBytes} bytes)\n`));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
204
252
|
let content = readFileSync(artifact.filePath, "utf-8");
|
|
205
253
|
const relPath = relative(process.cwd(), artifact.filePath);
|
|
206
254
|
if (opts.format) {
|
|
@@ -208,8 +256,14 @@ sade("claudecode-linter", true)
|
|
|
208
256
|
if (fixer) {
|
|
209
257
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
210
258
|
if (fixedContent !== content) {
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
const blocked = writeBlockedReason(artifact.filePath, rootDir);
|
|
260
|
+
if (blocked) {
|
|
261
|
+
process.stderr.write(pc.red(`Refusing to write ${sanitizeForTerminal(artifact.filePath)}: ${blocked}\n`));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
writeFileSync(artifact.filePath, fixedContent);
|
|
265
|
+
formatted.push(relPath);
|
|
266
|
+
}
|
|
213
267
|
}
|
|
214
268
|
}
|
|
215
269
|
continue;
|
|
@@ -221,9 +275,15 @@ sade("claudecode-linter", true)
|
|
|
221
275
|
if (fixer) {
|
|
222
276
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
223
277
|
if (fixedContent !== content) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
const blocked = writeBlockedReason(artifact.filePath, rootDir);
|
|
279
|
+
if (blocked) {
|
|
280
|
+
process.stderr.write(pc.red(`Refusing to write ${sanitizeForTerminal(artifact.filePath)}: ${blocked}\n`));
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
writeFileSync(artifact.filePath, fixedContent);
|
|
284
|
+
content = fixedContent;
|
|
285
|
+
fixed = 1;
|
|
286
|
+
}
|
|
227
287
|
}
|
|
228
288
|
}
|
|
229
289
|
}
|
|
@@ -233,7 +293,7 @@ sade("claudecode-linter", true)
|
|
|
233
293
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
234
294
|
if (fixedContent !== content) {
|
|
235
295
|
const diff = simpleDiff(content, fixedContent, artifact.filePath);
|
|
236
|
-
process.stdout.write(diff + "\n");
|
|
296
|
+
process.stdout.write(sanitizeForTerminal(diff) + "\n");
|
|
237
297
|
}
|
|
238
298
|
}
|
|
239
299
|
}
|
package/dist/linters/agent-md.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
|
|
3
|
+
import { AGENT_MODELS, AGENT_COLORS, TOOLS, PERMISSION_MODES, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
|
|
4
|
+
import { invalidEffortReason } from "../utils/effort.js";
|
|
4
5
|
import { formatAjvError, loadAgentFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
5
6
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
6
7
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
@@ -65,6 +66,9 @@ const RULES = [
|
|
|
65
66
|
{ id: "agent-md/description-routing-guidance", defaultSeverity: "warning" },
|
|
66
67
|
{ id: "agent-md/model-required", defaultSeverity: "error" },
|
|
67
68
|
{ id: "agent-md/model-valid", defaultSeverity: "warning" },
|
|
69
|
+
{ id: "agent-md/permission-mode-valid", defaultSeverity: "warning" },
|
|
70
|
+
{ id: "agent-md/effort-valid", defaultSeverity: "warning" },
|
|
71
|
+
{ id: "agent-md/max-turns-valid", defaultSeverity: "warning" },
|
|
68
72
|
{ id: "agent-md/color-required", defaultSeverity: "warning" },
|
|
69
73
|
{ id: "agent-md/color-valid", defaultSeverity: "warning" },
|
|
70
74
|
{ id: "agent-md/system-prompt-present", defaultSeverity: "error" },
|
|
@@ -158,6 +162,30 @@ export const agentMdLinter = {
|
|
|
158
162
|
!fm.data.model.startsWith("claude-")) {
|
|
159
163
|
push(diag(config, filePath, "agent-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${fm.data.model}")`));
|
|
160
164
|
}
|
|
165
|
+
// permissionMode — typed as a permissive scalar by the Zod schema;
|
|
166
|
+
// at runtime only the PERMISSION_MODES values take effect.
|
|
167
|
+
if ("permissionMode" in fm.data) {
|
|
168
|
+
const pm = fm.data.permissionMode;
|
|
169
|
+
if (typeof pm !== "string" || !PERMISSION_MODES.has(pm)) {
|
|
170
|
+
push(diag(config, filePath, "agent-md/permission-mode-valid", "warning", `"permissionMode" must be one of: ${[...PERMISSION_MODES].join(", ")} (got ${JSON.stringify(pm)})`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// effort — Zod types it as a permissive scalar; the field's describe()
|
|
174
|
+
// string restricts it to a named level or an integer.
|
|
175
|
+
if ("effort" in fm.data) {
|
|
176
|
+
const reason = invalidEffortReason(fm.data.effort);
|
|
177
|
+
if (reason) {
|
|
178
|
+
push(diag(config, filePath, "agent-md/effort-valid", "warning", reason));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// maxTurns — Zod accepts a string / float; only a positive integer
|
|
182
|
+
// is a meaningful turn budget.
|
|
183
|
+
if ("maxTurns" in fm.data) {
|
|
184
|
+
const mt = fm.data.maxTurns;
|
|
185
|
+
if (typeof mt !== "number" || !Number.isInteger(mt) || mt <= 0) {
|
|
186
|
+
push(diag(config, filePath, "agent-md/max-turns-valid", "warning", `"maxTurns" must be a positive integer (got ${JSON.stringify(mt)})`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
161
189
|
// color
|
|
162
190
|
if (!("color" in fm.data) || typeof fm.data.color !== "string") {
|
|
163
191
|
push(diag(config, filePath, "agent-md/color-required", "warning", '"color" is required in frontmatter'));
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { TOOLS } from "../contracts.js";
|
|
1
|
+
import { AGENT_MODELS, TOOLS } from "../contracts.js";
|
|
2
2
|
import { formatAjvError, loadCommandFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
3
3
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
4
|
+
import { invalidEffortReason } from "../utils/effort.js";
|
|
4
5
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
5
6
|
import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
|
|
6
7
|
const RULES = [
|
|
7
8
|
{ id: "command-md/valid-frontmatter", defaultSeverity: "error" },
|
|
8
9
|
{ id: "command-md/schema-valid", defaultSeverity: "error" },
|
|
9
10
|
{ id: "command-md/description-required", defaultSeverity: "error" },
|
|
11
|
+
{ id: "command-md/name-format", defaultSeverity: "warning" },
|
|
12
|
+
{ id: "command-md/model-valid", defaultSeverity: "warning" },
|
|
13
|
+
{ id: "command-md/effort-valid", defaultSeverity: "warning" },
|
|
14
|
+
{ id: "command-md/frontmatter-field-type", defaultSeverity: "warning" },
|
|
10
15
|
{ id: "command-md/allowed-tools-valid", defaultSeverity: "warning" },
|
|
11
16
|
{ id: "command-md/body-present", defaultSeverity: "warning" },
|
|
12
17
|
{ id: "command-md/no-unknown-frontmatter", defaultSeverity: "info" },
|
|
@@ -57,6 +62,37 @@ export const commandMdLinter = {
|
|
|
57
62
|
if (!("description" in fm.data) || typeof fm.data.description !== "string") {
|
|
58
63
|
push(diag(config, filePath, "command-md/description-required", "error", "\"description\" is required in frontmatter"));
|
|
59
64
|
}
|
|
65
|
+
// name — optional display-name override. Zod types it as a permissive
|
|
66
|
+
// scalar; if present it must be a non-empty string.
|
|
67
|
+
if ("name" in fm.data) {
|
|
68
|
+
const name = fm.data.name;
|
|
69
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
70
|
+
push(diag(config, filePath, "command-md/name-format", "warning", `"name" must be a non-empty string (got ${JSON.stringify(name)})`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// model — Zod types it as a permissive scalar; accept a named alias or a
|
|
74
|
+
// versioned claude-* model id. Mirrors agent-md/model-valid.
|
|
75
|
+
if ("model" in fm.data && typeof fm.data.model === "string") {
|
|
76
|
+
const model = fm.data.model;
|
|
77
|
+
if (!AGENT_MODELS.has(model) && !model.startsWith("claude-")) {
|
|
78
|
+
push(diag(config, filePath, "command-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${model}")`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// effort — Zod types it as a permissive scalar; the field's describe()
|
|
82
|
+
// string restricts it to a named level or an integer.
|
|
83
|
+
if ("effort" in fm.data) {
|
|
84
|
+
const reason = invalidEffortReason(fm.data.effort);
|
|
85
|
+
if (reason) {
|
|
86
|
+
push(diag(config, filePath, "command-md/effort-valid", "warning", reason));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// boolean fields — the Zod schema accepts the string "true"; only a real
|
|
90
|
+
// boolean behaves as expected.
|
|
91
|
+
for (const field of ["disable-model-invocation", "user-invocable"]) {
|
|
92
|
+
if (field in fm.data && typeof fm.data[field] !== "boolean") {
|
|
93
|
+
push(diag(config, filePath, "command-md/frontmatter-field-type", "warning", `"${field}" must be a boolean (got ${JSON.stringify(fm.data[field])})`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
60
96
|
// allowed-tools
|
|
61
97
|
if ("allowed-tools" in fm.data) {
|
|
62
98
|
const tools = fm.data["allowed-tools"];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { HOOK_EVENTS, HOOK_TYPES, PROMPT_EVENTS } from "../contracts.js";
|
|
2
|
+
import { formatAjvError, loadHooksJsonSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
2
3
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
3
4
|
const RULES = [
|
|
4
5
|
{ id: "hooks-json/valid-json", defaultSeverity: "error" },
|
|
6
|
+
{ id: "hooks-json/schema-valid", defaultSeverity: "error" },
|
|
5
7
|
{ id: "hooks-json/root-hooks-key", defaultSeverity: "error" },
|
|
6
8
|
{ id: "hooks-json/valid-event-names", defaultSeverity: "error" },
|
|
7
9
|
{ id: "hooks-json/hook-type-required", defaultSeverity: "error" },
|
|
@@ -52,6 +54,28 @@ export const hooksJsonLinter = {
|
|
|
52
54
|
push(diag(config, filePath, "hooks-json/valid-json", "error", "hooks.json must be a JSON object"));
|
|
53
55
|
return diagnostics;
|
|
54
56
|
}
|
|
57
|
+
// schema-valid — structural validation against the JSON Schema
|
|
58
|
+
// auto-extracted from Claude Code's hooks-config Zod validator (the
|
|
59
|
+
// hook-event → matcher-array map, each matcher holding a discriminated
|
|
60
|
+
// union of hook definitions). The hand-written rules below add
|
|
61
|
+
// Claude-Code-specific advice the schema can't express (hardcoded-path
|
|
62
|
+
// warnings, prompt-event-support hints, …). The extracted schema is
|
|
63
|
+
// intentionally permissive about unknown hook fields (Claude Code's hook
|
|
64
|
+
// objects are not .strict()). Skipped silently if the schema bundle isn't
|
|
65
|
+
// shipped with this install.
|
|
66
|
+
if (isRuleEnabled(config, "hooks-json/schema-valid")) {
|
|
67
|
+
const compiled = loadHooksJsonSchema();
|
|
68
|
+
if (compiled) {
|
|
69
|
+
const ok = compiled.validate(parsed);
|
|
70
|
+
if (!ok && compiled.validate.errors) {
|
|
71
|
+
for (const err of summarizeErrors(compiled.validate.errors)) {
|
|
72
|
+
const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
|
|
73
|
+
const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
|
|
74
|
+
push(diag(config, filePath, "hooks-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
55
79
|
const root = parsed;
|
|
56
80
|
// root "hooks" key
|
|
57
81
|
if (!("hooks" in root) || typeof root.hooks !== "object" || root.hooks === null) {
|
package/dist/linters/mcp-json.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { MCP_SERVER_FIELDS } from "../contracts.js";
|
|
3
|
+
import { formatAjvError, loadMcpJsonSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
3
4
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
4
5
|
import { isKebabCase } from "../utils/kebab-case.js";
|
|
5
6
|
// Valid `type` values for a URL-based HTTP MCP server. Claude Code v2.1.146
|
|
@@ -9,6 +10,7 @@ const HTTP_TRANSPORT_TYPES = new Set(["http", "streamable-http"]);
|
|
|
9
10
|
const RULES = [
|
|
10
11
|
{ id: "mcp-json/scope-file-name", defaultSeverity: "warning" },
|
|
11
12
|
{ id: "mcp-json/valid-json", defaultSeverity: "error" },
|
|
13
|
+
{ id: "mcp-json/schema-valid", defaultSeverity: "error" },
|
|
12
14
|
{ id: "mcp-json/servers-required", defaultSeverity: "error" },
|
|
13
15
|
{ id: "mcp-json/servers-object", defaultSeverity: "error" },
|
|
14
16
|
{ id: "mcp-json/server-name-kebab", defaultSeverity: "info" },
|
|
@@ -73,6 +75,28 @@ export const mcpJsonLinter = {
|
|
|
73
75
|
push(diag(config, filePath, "mcp-json/valid-json", "error", "mcp.json must be a JSON object"));
|
|
74
76
|
return diagnostics;
|
|
75
77
|
}
|
|
78
|
+
// schema-valid — structural validation against the JSON Schema
|
|
79
|
+
// auto-extracted from Claude Code's .mcp.json Zod validator (the
|
|
80
|
+
// `mcpServers` record of discriminated transport configs). The hand-written
|
|
81
|
+
// rules below add Claude-Code-specific advice the schema can't express
|
|
82
|
+
// (kebab-case names, ${CLAUDE_PLUGIN_ROOT} hints, …). The extracted schema
|
|
83
|
+
// is intentionally permissive about unknown server fields (Claude Code's
|
|
84
|
+
// server union is not .strict()), so those are NOT reported here —
|
|
85
|
+
// mcp-json/no-unknown-server-fields handles them. Skipped silently if the
|
|
86
|
+
// schema bundle isn't shipped with this install.
|
|
87
|
+
if (isRuleEnabled(config, "mcp-json/schema-valid")) {
|
|
88
|
+
const compiled = loadMcpJsonSchema();
|
|
89
|
+
if (compiled) {
|
|
90
|
+
const ok = compiled.validate(parsed);
|
|
91
|
+
if (!ok && compiled.validate.errors) {
|
|
92
|
+
for (const err of summarizeErrors(compiled.validate.errors)) {
|
|
93
|
+
const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
|
|
94
|
+
const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
|
|
95
|
+
push(diag(config, filePath, "mcp-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
76
100
|
// mcpServers required
|
|
77
101
|
if (!("mcpServers" in parsed)) {
|
|
78
102
|
push(diag(config, filePath, "mcp-json/servers-required", "error", "\"mcpServers\" field is required"));
|
package/dist/linters/skill-md.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { AGENT_MODELS, TOOLS } from "../contracts.js";
|
|
1
2
|
import { formatAjvError, loadSkillFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
2
3
|
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
4
|
+
import { invalidEffortReason } from "../utils/effort.js";
|
|
3
5
|
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
4
6
|
import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
|
|
5
7
|
import { isKebabCase } from "../utils/kebab-case.js";
|
|
@@ -13,6 +15,10 @@ const RULES = [
|
|
|
13
15
|
{ id: "skill-md/description-max-length", defaultSeverity: "error" },
|
|
14
16
|
{ id: "skill-md/description-no-angle-brackets", defaultSeverity: "error" },
|
|
15
17
|
{ id: "skill-md/description-trigger-phrases", defaultSeverity: "warning" },
|
|
18
|
+
{ id: "skill-md/model-valid", defaultSeverity: "warning" },
|
|
19
|
+
{ id: "skill-md/effort-valid", defaultSeverity: "warning" },
|
|
20
|
+
{ id: "skill-md/allowed-tools-valid", defaultSeverity: "warning" },
|
|
21
|
+
{ id: "skill-md/frontmatter-field-type", defaultSeverity: "warning" },
|
|
16
22
|
{ id: "skill-md/no-unknown-frontmatter", defaultSeverity: "info" },
|
|
17
23
|
{ id: "skill-md/body-word-count", defaultSeverity: "info" },
|
|
18
24
|
{ id: "skill-md/body-has-headers", defaultSeverity: "info" },
|
|
@@ -151,6 +157,41 @@ export const skillMdLinter = {
|
|
|
151
157
|
"(e.g., \"Use when…\", \"Trigger on…\", \"when the user asks to…\", or an imperative verb)"));
|
|
152
158
|
}
|
|
153
159
|
}
|
|
160
|
+
// model — Zod types it as a permissive scalar; accept a named alias or a
|
|
161
|
+
// versioned claude-* model id. Mirrors agent-md/model-valid.
|
|
162
|
+
if ("model" in fm.data && typeof fm.data.model === "string") {
|
|
163
|
+
const model = fm.data.model;
|
|
164
|
+
if (!AGENT_MODELS.has(model) && !model.startsWith("claude-")) {
|
|
165
|
+
push(diag(config, filePath, "skill-md/model-valid", "warning", `"model" must be one of: ${[...AGENT_MODELS].join(", ")} or a versioned claude-* model ID (got "${model}")`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// effort — Zod types it as a permissive scalar; the field's describe()
|
|
169
|
+
// string restricts it to a named level or an integer.
|
|
170
|
+
if ("effort" in fm.data) {
|
|
171
|
+
const reason = invalidEffortReason(fm.data.effort);
|
|
172
|
+
if (reason) {
|
|
173
|
+
push(diag(config, filePath, "skill-md/effort-valid", "warning", reason));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// allowed-tools — validate built-in tool names. mcp__* patterns are
|
|
177
|
+
// dynamic (resolved at runtime); accept them. Mirrors command-md.
|
|
178
|
+
if ("allowed-tools" in fm.data) {
|
|
179
|
+
const tools = fm.data["allowed-tools"];
|
|
180
|
+
if (Array.isArray(tools)) {
|
|
181
|
+
for (const t of tools) {
|
|
182
|
+
if (typeof t === "string" && !t.startsWith("mcp__") && !TOOLS.has(t)) {
|
|
183
|
+
push(diag(config, filePath, "skill-md/allowed-tools-valid", "warning", `Unknown tool "${t}" in allowed-tools`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// boolean fields — the Zod schema accepts the string "true"; only a real
|
|
189
|
+
// boolean behaves as expected.
|
|
190
|
+
for (const field of ["disable-model-invocation", "user-invocable"]) {
|
|
191
|
+
if (field in fm.data && typeof fm.data[field] !== "boolean") {
|
|
192
|
+
push(diag(config, filePath, "skill-md/frontmatter-field-type", "warning", `"${field}" must be a boolean (got ${JSON.stringify(fm.data[field])})`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
// Frontmatter keys: only flag cross-artifact misplacement. A key that is
|
|
155
196
|
// valid for a *different* markdown artifact (e.g. agent's "effort" on a
|
|
156
197
|
// skill) gets an info; a key valid for no artifact stays silent.
|
package/dist/plugin-schema.js
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* resolver if the file is being run from an alternate layout (e.g. monorepo).
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
11
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
14
12
|
// biome-ignore lint/style/useImportType: runtime import; types only.
|
|
15
13
|
import * as addFormatsNs from "ajv-formats";
|
|
14
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
16
15
|
// ajv-formats ships as CJS with a default function export. Under Node16
|
|
17
16
|
// ESM resolution we have to reach in for `.default`.
|
|
18
17
|
const addFormats = addFormatsNs.default;
|
|
@@ -29,10 +28,9 @@ function getAjv() {
|
|
|
29
28
|
function loadCompiledSchema(fileName) {
|
|
30
29
|
if (compiledCache.has(fileName))
|
|
31
30
|
return compiledCache.get(fileName) ?? null;
|
|
32
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
31
|
const candidates = [
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
...assetCandidates(import.meta.url, ["..", "contracts", fileName]),
|
|
33
|
+
...assetCandidates(import.meta.url, ["..", "..", "contracts", fileName]),
|
|
36
34
|
];
|
|
37
35
|
let raw = null;
|
|
38
36
|
for (const p of candidates) {
|
|
@@ -52,6 +50,7 @@ function loadCompiledSchema(fileName) {
|
|
|
52
50
|
const compiled = {
|
|
53
51
|
validate: getAjv().compile(wrapped.schema),
|
|
54
52
|
extractedFromVersion: wrapped.extractedFromClaudeCodeVersion,
|
|
53
|
+
knownFields: new Set(Object.keys(wrapped.schema.properties ?? {})),
|
|
55
54
|
};
|
|
56
55
|
compiledCache.set(fileName, compiled);
|
|
57
56
|
return compiled;
|
|
@@ -65,6 +64,12 @@ export function loadMonitorsSchema() {
|
|
|
65
64
|
export function loadSettingsSchema() {
|
|
66
65
|
return loadCompiledSchema("settings.schema.json");
|
|
67
66
|
}
|
|
67
|
+
export function loadMcpJsonSchema() {
|
|
68
|
+
return loadCompiledSchema("mcp.schema.json");
|
|
69
|
+
}
|
|
70
|
+
export function loadHooksJsonSchema() {
|
|
71
|
+
return loadCompiledSchema("hooks.schema.json");
|
|
72
|
+
}
|
|
68
73
|
export function loadSkillFrontmatterSchema() {
|
|
69
74
|
return loadCompiledSchema("skill-frontmatter.schema.json");
|
|
70
75
|
}
|
|
@@ -77,10 +82,14 @@ export function loadCommandFrontmatterSchema() {
|
|
|
77
82
|
export function loadPluginSchema() {
|
|
78
83
|
if (cached)
|
|
79
84
|
return cached;
|
|
80
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
81
85
|
const candidates = [
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
...assetCandidates(import.meta.url, ["..", "contracts", "plugin.schema.json"]),
|
|
87
|
+
...assetCandidates(import.meta.url, [
|
|
88
|
+
"..",
|
|
89
|
+
"..",
|
|
90
|
+
"contracts",
|
|
91
|
+
"plugin.schema.json",
|
|
92
|
+
]),
|
|
84
93
|
];
|
|
85
94
|
let raw = null;
|
|
86
95
|
for (const p of candidates) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve runtime assets (contracts/*.schema.json, .claudecode-lint.defaults.yaml,
|
|
3
|
+
* package.json) that ship alongside the package on disk.
|
|
4
|
+
*
|
|
5
|
+
* Normally these are found relative to `import.meta.url` — the location of the
|
|
6
|
+
* compiled `.js` file inside `dist/`. That works for the Node build and for the
|
|
7
|
+
* published npm package.
|
|
8
|
+
*
|
|
9
|
+
* Inside a `bun build --compile` single-executable, `import.meta.url` points
|
|
10
|
+
* into Bun's virtual embedded filesystem (`/$bunfs/...`), so disk reads of
|
|
11
|
+
* sibling assets fail. To support that variant, we ALSO emit candidates
|
|
12
|
+
* relative to `process.execPath` (the real on-disk path of the running
|
|
13
|
+
* executable). The compiled-binary image ships `contracts/` and
|
|
14
|
+
* `.claudecode-lint.defaults.yaml` next to the executable, so those fallback
|
|
15
|
+
* candidates resolve there.
|
|
16
|
+
*
|
|
17
|
+
* For the Node runtime, the `process.execPath`-relative candidates simply point
|
|
18
|
+
* at the `node` binary's directory and won't match — harmless extra lookups
|
|
19
|
+
* appended AFTER the existing ones, so Node resolution is byte-for-byte
|
|
20
|
+
* unchanged.
|
|
21
|
+
*/
|
|
22
|
+
import { dirname, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
/**
|
|
25
|
+
* Build a list of candidate paths for an asset shipped with the package.
|
|
26
|
+
*
|
|
27
|
+
* @param importMetaUrl `import.meta.url` of the calling module.
|
|
28
|
+
* @param segments Path segments, relative to a base directory, that
|
|
29
|
+
* locate the asset (e.g. `["..", "contracts", "x.json"]`
|
|
30
|
+
* for a module under `dist/`).
|
|
31
|
+
* @returns Ordered candidate paths: `import.meta.url`-relative first (existing
|
|
32
|
+
* behavior), then `process.execPath`-relative fallbacks.
|
|
33
|
+
*/
|
|
34
|
+
export function assetCandidates(importMetaUrl, segments) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
// 1. import.meta.url-relative — the existing, primary resolution.
|
|
37
|
+
const here = dirname(fileURLToPath(importMetaUrl));
|
|
38
|
+
candidates.push(resolve(here, ...segments));
|
|
39
|
+
// 2. process.execPath-relative fallbacks for the compiled single-executable.
|
|
40
|
+
// The binary lives next to `contracts/` and the defaults YAML, so we try
|
|
41
|
+
// both the executable's own directory and one level up (mirroring the
|
|
42
|
+
// dist/ -> package-root step the segments encode).
|
|
43
|
+
try {
|
|
44
|
+
const execDir = dirname(process.execPath);
|
|
45
|
+
// Drop leading ".." segments: assets sit directly beside the executable.
|
|
46
|
+
const beside = segments.filter((s) => s !== "..");
|
|
47
|
+
candidates.push(resolve(execDir, ...beside));
|
|
48
|
+
candidates.push(resolve(execDir, ...segments));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// process.execPath unavailable — ignore.
|
|
52
|
+
}
|
|
53
|
+
// De-duplicate while preserving order.
|
|
54
|
+
return [...new Set(candidates)];
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=asset-path.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EFFORT_LEVELS } from "../contracts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate a frontmatter `effort` value against the Claude Code contract.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's frontmatter Zod schema types `effort` as a permissive scalar
|
|
6
|
+
* (`anyOf[string,number,boolean,null]`), but the field's own `.describe()`
|
|
7
|
+
* string in the bundle reads:
|
|
8
|
+
* "Thinking effort for the model: `low`, `medium`, `high`, `max`, or an integer."
|
|
9
|
+
*
|
|
10
|
+
* So a valid `effort` is either one of the named levels (EFFORT_LEVELS) or an
|
|
11
|
+
* integer. Anything else — a non-integer number, a boolean, an unknown string,
|
|
12
|
+
* null — is reported. Returns a human-readable reason when invalid, or null
|
|
13
|
+
* when the value is acceptable.
|
|
14
|
+
*/
|
|
15
|
+
export function invalidEffortReason(value) {
|
|
16
|
+
const valid = [...EFFORT_LEVELS].join(", ");
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
if (EFFORT_LEVELS.has(value))
|
|
19
|
+
return null;
|
|
20
|
+
return `"effort" string must be one of: ${valid} (got "${value}")`;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "number") {
|
|
23
|
+
if (Number.isInteger(value))
|
|
24
|
+
return null;
|
|
25
|
+
return `"effort" number must be an integer (got ${value})`;
|
|
26
|
+
}
|
|
27
|
+
return `"effort" must be one of: ${valid}, or an integer (got ${JSON.stringify(value)})`;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=effort.js.map
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { SKILL_FRONTMATTER, AGENT_FRONTMATTER, COMMAND_FRONTMATTER, } from "../contracts.js";
|
|
2
|
-
|
|
2
|
+
import { loadSkillFrontmatterSchema, loadAgentFrontmatterSchema, loadCommandFrontmatterSchema, } from "../plugin-schema.js";
|
|
3
|
+
// Census-extraction sets (scripts/extract-contracts.ts). Used only as a
|
|
4
|
+
// fallback when the auto-extracted frontmatter schema is unavailable — the
|
|
5
|
+
// census extractor lags the schema walker (e.g. it was missing `effort`).
|
|
6
|
+
const CENSUS_FALLBACK = {
|
|
3
7
|
skill: SKILL_FRONTMATTER,
|
|
4
8
|
agent: AGENT_FRONTMATTER,
|
|
5
9
|
command: COMMAND_FRONTMATTER,
|
|
6
10
|
};
|
|
11
|
+
const SCHEMA_LOADERS = {
|
|
12
|
+
skill: loadSkillFrontmatterSchema,
|
|
13
|
+
agent: loadAgentFrontmatterSchema,
|
|
14
|
+
command: loadCommandFrontmatterSchema,
|
|
15
|
+
};
|
|
16
|
+
const keyCache = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Known frontmatter keys for an artifact — the union of the auto-extracted
|
|
19
|
+
* schema's property names (authoritative, complete, kept in sync with Claude
|
|
20
|
+
* Code's Zod) and the census-contract set. Union rather than schema-only so a
|
|
21
|
+
* key known to *either* extraction is never mis-flagged: `no-unknown-
|
|
22
|
+
* frontmatter` is advisory and a false positive is the only real harm.
|
|
23
|
+
*/
|
|
24
|
+
function knownKeys(kind) {
|
|
25
|
+
const cached = keyCache.get(kind);
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
const merged = new Set(CENSUS_FALLBACK[kind]);
|
|
29
|
+
const schema = SCHEMA_LOADERS[kind]();
|
|
30
|
+
if (schema)
|
|
31
|
+
for (const k of schema.knownFields)
|
|
32
|
+
merged.add(k);
|
|
33
|
+
keyCache.set(kind, merged);
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
7
36
|
const ARTIFACT_LABEL = {
|
|
8
37
|
skill: "skill",
|
|
9
38
|
agent: "agent",
|
|
@@ -18,13 +47,13 @@ const ARTIFACT_LABEL = {
|
|
|
18
47
|
*/
|
|
19
48
|
export function classifyUnknownFrontmatterKey(key, self, extraKnown = new Set()) {
|
|
20
49
|
// Known for the current artifact — not unknown at all.
|
|
21
|
-
if (
|
|
50
|
+
if (knownKeys(self).has(key) || extraKnown.has(key))
|
|
22
51
|
return null;
|
|
23
52
|
// Valid for some *other* markdown artifact → misplacement.
|
|
24
53
|
for (const kind of ["skill", "agent", "command"]) {
|
|
25
54
|
if (kind === self)
|
|
26
55
|
continue;
|
|
27
|
-
if (
|
|
56
|
+
if (knownKeys(kind).has(key)) {
|
|
28
57
|
return { kind: "owned-by-other", owner: kind };
|
|
29
58
|
}
|
|
30
59
|
}
|
|
@@ -32,7 +32,7 @@ export function parseFrontmatter(content) {
|
|
|
32
32
|
const body = lines.slice(closingIndex + 1).join("\n");
|
|
33
33
|
const bodyStartLine = closingIndex + 2; // 1-based
|
|
34
34
|
try {
|
|
35
|
-
const data = parseYaml(frontmatterRaw);
|
|
35
|
+
const data = parseYaml(frontmatterRaw, { maxAliasCount: 100 });
|
|
36
36
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
37
37
|
return {
|
|
38
38
|
data: {},
|