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.
@@ -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
- const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
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 __filename = fileURLToPath(import.meta.url);
137
- const pkgDir = dirname(dirname(__filename));
138
- const defaultsFile = join(pkgDir, ".claudecode-lint.defaults.yaml");
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
- writeFileSync(artifact.filePath, fixedContent);
212
- formatted.push(relPath);
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
- writeFileSync(artifact.filePath, fixedContent);
225
- content = fixedContent;
226
- fixed = 1;
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
  }
@@ -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) {
@@ -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"));
@@ -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.
@@ -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
- resolve(here, "..", "contracts", fileName),
35
- resolve(here, "..", "..", "contracts", fileName),
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
- resolve(here, "..", "contracts", "plugin.schema.json"),
83
- resolve(here, "..", "..", "contracts", "plugin.schema.json"),
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
- const KEY_SETS = {
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 (KEY_SETS[self].has(key) || extraKnown.has(key))
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 (KEY_SETS[kind].has(key)) {
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: {},