claudecode-linter 2.1.144 → 2.1.148-patch.2

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,15 +1,27 @@
1
1
  import { basename } from "node:path";
2
- import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS } from "../contracts.js";
2
+ import { SETTINGS_USER_FIELDS, SETTINGS_PROJECT_FIELDS, TOOLS, PERMISSIONS_FIELDS, PERMISSION_MODES, SANDBOX_FIELDS, SANDBOX_NETWORK_FIELDS, SANDBOX_FILESYSTEM_FIELDS, } from "../contracts.js";
3
+ import { formatAjvError, loadSettingsSchema, summarizeErrors, } from "../plugin-schema.js";
3
4
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
5
  const RULES = [
5
6
  { id: "settings-json/valid-json", defaultSeverity: "error" },
7
+ { id: "settings-json/schema-valid", defaultSeverity: "error" },
6
8
  { id: "settings-json/scope-file-name", defaultSeverity: "error" },
7
9
  { id: "settings-json/scope-field", defaultSeverity: "warning" },
8
10
  { id: "settings-json/no-unknown-fields", defaultSeverity: "warning" },
9
11
  { id: "settings-json/permissions-object", defaultSeverity: "error" },
12
+ { id: "settings-json/permissions-unknown-field", defaultSeverity: "warning" },
13
+ { id: "settings-json/permissions-default-mode", defaultSeverity: "warning" },
14
+ { id: "settings-json/permissions-disable-bypass", defaultSeverity: "warning" },
15
+ { id: "settings-json/permissions-field-type", defaultSeverity: "warning" },
10
16
  { id: "settings-json/allow-array", defaultSeverity: "error" },
11
17
  { id: "settings-json/allow-known-tools", defaultSeverity: "warning" },
12
18
  { id: "settings-json/deny-array", defaultSeverity: "error" },
19
+ { id: "settings-json/ask-array", defaultSeverity: "error" },
20
+ { id: "settings-json/permission-rule-syntax", defaultSeverity: "error" },
21
+ { id: "settings-json/permission-rule-pattern", defaultSeverity: "error" },
22
+ { id: "settings-json/sandbox-object", defaultSeverity: "error" },
23
+ { id: "settings-json/sandbox-unknown-field", defaultSeverity: "warning" },
24
+ { id: "settings-json/sandbox-field-type", defaultSeverity: "warning" },
13
25
  { id: "settings-json/env-object", defaultSeverity: "error" },
14
26
  { id: "settings-json/env-string-values", defaultSeverity: "warning" },
15
27
  { id: "settings-json/plugins-object", defaultSeverity: "error" },
@@ -17,6 +29,154 @@ const RULES = [
17
29
  { id: "settings-json/plugins-format", defaultSeverity: "warning" },
18
30
  { id: "settings-json/skip-prompt-boolean", defaultSeverity: "error" },
19
31
  ];
32
+ // Expected type for each known sandbox sub-key, and for the nested
33
+ // network/filesystem objects. Mirrors Claude Code's Zod schema.
34
+ const SANDBOX_FIELD_TYPES = {
35
+ enabled: "boolean",
36
+ failIfUnavailable: "boolean",
37
+ autoAllowBashIfSandboxed: "boolean",
38
+ allowUnsandboxedCommands: "boolean",
39
+ enableWeakerNestedSandbox: "boolean",
40
+ enableWeakerNetworkIsolation: "boolean",
41
+ excludedCommands: "string-array",
42
+ network: "object",
43
+ filesystem: "object",
44
+ ripgrep: "object",
45
+ ignoreViolations: "object",
46
+ bwrapPath: "string",
47
+ };
48
+ const NETWORK_FIELD_TYPES = {
49
+ allowedDomains: "string-array",
50
+ deniedDomains: "string-array",
51
+ allowManagedDomainsOnly: "boolean",
52
+ allowUnixSockets: "string-array",
53
+ allowAllUnixSockets: "boolean",
54
+ allowLocalBinding: "boolean",
55
+ allowMachLookup: "string-array",
56
+ httpProxyPort: "number",
57
+ socksProxyPort: "number",
58
+ tlsTerminate: "object",
59
+ };
60
+ const FILESYSTEM_FIELD_TYPES = {
61
+ allowWrite: "string-array",
62
+ denyWrite: "string-array",
63
+ denyRead: "string-array",
64
+ allowRead: "string-array",
65
+ allowManagedReadPathsOnly: "boolean",
66
+ };
67
+ // sandbox.network.tlsTerminate — a small nested object of optional paths.
68
+ const TLS_TERMINATE_FIELD_TYPES = {
69
+ caCertPath: "string",
70
+ caKeyPath: "string",
71
+ };
72
+ // Tool classes that drive per-rule pattern validation (Claude Code's `Fx$`).
73
+ const FILE_PATTERN_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "NotebookRead", "NotebookEdit"]);
74
+ const BASH_PREFIX_TOOLS = new Set(["Bash"]);
75
+ function isStringArray(v) {
76
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
77
+ }
78
+ function isPlainObject(v) {
79
+ return typeof v === "object" && v !== null && !Array.isArray(v);
80
+ }
81
+ function typeMatches(expected, val) {
82
+ switch (expected) {
83
+ case "boolean": return typeof val === "boolean";
84
+ case "string": return typeof val === "string";
85
+ case "number": return typeof val === "number";
86
+ case "string-array": return isStringArray(val);
87
+ case "object": return isPlainObject(val);
88
+ }
89
+ }
90
+ function articleType(expected) {
91
+ switch (expected) {
92
+ case "boolean": return "a boolean";
93
+ case "string": return "a string";
94
+ case "number": return "a number";
95
+ case "string-array": return "an array of strings";
96
+ case "object": return "an object";
97
+ }
98
+ }
99
+ /**
100
+ * Lightweight permission-rule syntax check, mirroring the syntactic rejections
101
+ * of Claude Code's runtime rule validator (the per-tool pattern grammar is
102
+ * deliberately not reproduced). Returns an error description, or null if the
103
+ * rule is syntactically well-formed.
104
+ */
105
+ function ruleSyntaxError(rule) {
106
+ if (rule.trim() === "")
107
+ return "permission rule cannot be empty";
108
+ const opens = (rule.match(/\(/g) ?? []).length;
109
+ const closes = (rule.match(/\)/g) ?? []).length;
110
+ if (opens !== closes)
111
+ return "mismatched parentheses";
112
+ if (/\(\s*\)/.test(rule)) {
113
+ const before = rule.slice(0, rule.indexOf("(")).trim();
114
+ return before === ""
115
+ ? "empty parentheses with no tool name"
116
+ : `empty parentheses — use "${before}" alone, or specify a pattern`;
117
+ }
118
+ const parenIdx = rule.indexOf("(");
119
+ const toolName = (parenIdx === -1 ? rule : rule.slice(0, parenIdx)).trim();
120
+ if (toolName === "")
121
+ return "tool name cannot be empty";
122
+ if (rule.startsWith("mcp__")) {
123
+ return parenIdx === -1
124
+ ? null
125
+ : "MCP rules do not support patterns in parentheses";
126
+ }
127
+ if (!toolName.includes("_") && toolName[0] !== toolName[0].toUpperCase()) {
128
+ return "tool names must start with an uppercase letter";
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Split a syntactically valid rule into its tool name and parenthesized
134
+ * pattern. `content` is null for a bare tool or a `()` / `(*)` wildcard.
135
+ */
136
+ function parseRule(rule) {
137
+ const open = rule.indexOf("(");
138
+ if (open === -1)
139
+ return { toolName: rule.trim(), content: null };
140
+ const inner = rule.slice(open + 1, rule.lastIndexOf(")"));
141
+ return {
142
+ toolName: rule.slice(0, open).trim(),
143
+ content: inner === "" || inner === "*" ? null : inner,
144
+ };
145
+ }
146
+ /**
147
+ * Per-tool pattern validation, mirroring Claude Code's `Fx$` rule table:
148
+ * WebSearch/WebFetch custom validators, the Bash `:*` prefix marker, and the
149
+ * file-pattern tools that reject `:*`. Returns an error description or null.
150
+ */
151
+ function ruleContentError(toolName, content) {
152
+ if (toolName === "WebSearch") {
153
+ if (content.includes("*") || content.includes("?")) {
154
+ return "WebSearch rules do not support wildcards (* or ?)";
155
+ }
156
+ }
157
+ else if (toolName === "WebFetch") {
158
+ if (content.includes("://") || content.startsWith("http")) {
159
+ return "WebFetch rules use \"domain:hostname\" format, not URLs";
160
+ }
161
+ if (!content.startsWith("domain:")) {
162
+ return "WebFetch rules must use the \"domain:\" prefix";
163
+ }
164
+ }
165
+ else if (BASH_PREFIX_TOOLS.has(toolName)) {
166
+ if (content.includes(":*") && !content.endsWith(":*")) {
167
+ return "the \":*\" prefix marker must be at the end of the pattern";
168
+ }
169
+ if (content === ":*") {
170
+ return "the command prefix before \":*\" cannot be empty";
171
+ }
172
+ }
173
+ else if (FILE_PATTERN_TOOLS.has(toolName)) {
174
+ if (content.includes(":*")) {
175
+ return "the \":*\" syntax is Bash-only — use glob patterns (* or **) for files";
176
+ }
177
+ }
178
+ return null;
179
+ }
20
180
  function findKeyPosition(content, key) {
21
181
  const re = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
22
182
  const match = re.exec(content);
@@ -60,6 +220,28 @@ export const settingsJsonLinter = {
60
220
  push(diag(config, filePath, "settings-json/valid-json", "error", "settings.json must be a JSON object"));
61
221
  return diagnostics;
62
222
  }
223
+ // schema-valid — defer to the JSON Schema auto-extracted from Claude Code's
224
+ // settings validator for the ~113 structurally-unchecked top-level fields.
225
+ // The hand-written rules below cover Claude-Code-specific advice the schema
226
+ // can't express (permission-rule syntax, scope semantics, …) with friendlier
227
+ // messages. The extracted schema is intentionally permissive at the top
228
+ // level (Claude Code uses .passthrough()), so unknown keys are NOT reported
229
+ // here — settings-json/no-unknown-fields handles those. Skipped silently if
230
+ // the schema bundle isn't shipped with this install.
231
+ if (isRuleEnabled(config, "settings-json/schema-valid")) {
232
+ const compiled = loadSettingsSchema();
233
+ if (compiled) {
234
+ const ok = compiled.validate(parsed);
235
+ if (!ok && compiled.validate.errors) {
236
+ const filtered = summarizeErrors(compiled.validate.errors);
237
+ for (const err of filtered) {
238
+ const firstSeg = err.instancePath.split("/").filter(Boolean)[0];
239
+ const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
240
+ push(diag(config, filePath, "settings-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
241
+ }
242
+ }
243
+ }
244
+ }
63
245
  // Scope-aware: settings.json (non-local) should only be at user level
64
246
  if (!isLocal && scope && scope !== "user") {
65
247
  push(diag(config, filePath, "settings-json/scope-file-name", "error", `"settings.json" should only exist at user level (~/.claude/). Use "settings.local.json" for project-level settings`));
@@ -78,46 +260,151 @@ export const settingsJsonLinter = {
78
260
  }
79
261
  }
80
262
  }
263
+ // Validate the sub-keys of a nested object against a known-field set and a
264
+ // type map (used for sandbox.network and sandbox.filesystem).
265
+ const checkNested = (obj, prefix, known, types) => {
266
+ for (const [key, val] of Object.entries(obj)) {
267
+ const kp = findKeyPosition(content, key);
268
+ if (!known.has(key)) {
269
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "${prefix}.${key}"`, kp?.line, kp?.column));
270
+ continue;
271
+ }
272
+ const expected = types[key];
273
+ if (expected && !typeMatches(expected, val)) {
274
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"${prefix}.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
275
+ }
276
+ }
277
+ };
81
278
  // permissions
82
279
  if ("permissions" in parsed) {
83
280
  const perms = parsed.permissions;
84
281
  const pp = findKeyPosition(content, "permissions");
85
- if (typeof perms !== "object" || perms === null || Array.isArray(perms)) {
282
+ if (!isPlainObject(perms)) {
86
283
  push(diag(config, filePath, "settings-json/permissions-object", "error", "\"permissions\" must be an object", pp?.line, pp?.column));
87
284
  }
88
285
  else {
89
286
  const p = perms;
90
- // allow list
91
- if ("allow" in p) {
92
- const ap = findKeyPosition(content, "allow");
93
- if (!Array.isArray(p.allow)) {
94
- push(diag(config, filePath, "settings-json/allow-array", "error", "\"permissions.allow\" must be an array of strings", ap?.line, ap?.column));
287
+ // unknown permissions sub-keys
288
+ for (const key of Object.keys(p)) {
289
+ if (!PERMISSIONS_FIELDS.has(key)) {
290
+ const kp = findKeyPosition(content, key);
291
+ push(diag(config, filePath, "settings-json/permissions-unknown-field", "warning", `Unknown field "permissions.${key}"`, kp?.line, kp?.column));
95
292
  }
96
- else {
97
- for (const entry of p.allow) {
98
- if (typeof entry !== "string") {
99
- push(diag(config, filePath, "settings-json/allow-array", "error", `"permissions.allow" entries must be strings (got ${typeof entry})`, ap?.line, ap?.column));
293
+ }
294
+ // allow / deny / ask — each an array of permission-rule strings
295
+ const checkRuleArray = (name, arrayRuleId) => {
296
+ if (!(name in p))
297
+ return;
298
+ const kp = findKeyPosition(content, name);
299
+ if (!Array.isArray(p[name])) {
300
+ push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" must be an array of strings`, kp?.line, kp?.column));
301
+ return;
302
+ }
303
+ for (const entry of p[name]) {
304
+ if (typeof entry !== "string") {
305
+ push(diag(config, filePath, arrayRuleId, "error", `"permissions.${name}" entries must be strings (got ${typeof entry})`, kp?.line, kp?.column));
306
+ continue;
307
+ }
308
+ // Syntax first — a malformed rule string is rejected at runtime.
309
+ const syntaxErr = ruleSyntaxError(entry);
310
+ if (syntaxErr) {
311
+ push(diag(config, filePath, "settings-json/permission-rule-syntax", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${syntaxErr}`, kp?.line, kp?.column));
312
+ continue;
313
+ }
314
+ // Per-tool pattern grammar (Bash :* placement, WebFetch domain:, …).
315
+ const { toolName, content: ruleContent } = parseRule(entry);
316
+ if (ruleContent !== null) {
317
+ const patternErr = ruleContentError(toolName, ruleContent);
318
+ if (patternErr) {
319
+ push(diag(config, filePath, "settings-json/permission-rule-pattern", "error", `Invalid permission rule "${entry}" in permissions.${name}: ${patternErr}`, kp?.line, kp?.column));
100
320
  continue;
101
321
  }
102
- // Extract base tool name from scoped pattern like "Bash(cmd:*)"
103
- const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
104
- if (toolMatch) {
105
- const toolName = toolMatch[1];
106
- if (!TOOLS.has(toolName)) {
107
- // Allow MCP tool patterns (mcp__*)
108
- if (!entry.startsWith("mcp__")) {
109
- push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolName}" in permissions.allow`, ap?.line, ap?.column));
110
- }
111
- }
112
- }
113
322
  }
323
+ // Rules are "Tool" or "Tool(specifier)". Warn on a base tool name
324
+ // Claude Code does not expose (mcp__* is dynamic).
325
+ const toolMatch = entry.match(/^([A-Za-z]+)(\(.*\))?$/);
326
+ if (toolMatch && !TOOLS.has(toolMatch[1]) && !entry.startsWith("mcp__")) {
327
+ push(diag(config, filePath, "settings-json/allow-known-tools", "warning", `Unknown tool "${toolMatch[1]}" in permissions.${name}`, kp?.line, kp?.column));
328
+ }
329
+ }
330
+ };
331
+ checkRuleArray("allow", "settings-json/allow-array");
332
+ checkRuleArray("deny", "settings-json/deny-array");
333
+ checkRuleArray("ask", "settings-json/ask-array");
334
+ // defaultMode — must be a known permission mode
335
+ if ("defaultMode" in p) {
336
+ const mp = findKeyPosition(content, "defaultMode");
337
+ if (typeof p.defaultMode !== "string" || !PERMISSION_MODES.has(p.defaultMode)) {
338
+ push(diag(config, filePath, "settings-json/permissions-default-mode", "warning", `"permissions.defaultMode" should be one of: ${[...PERMISSION_MODES].join(", ")}`, mp?.line, mp?.column));
114
339
  }
115
340
  }
116
- // deny list
117
- if ("deny" in p) {
118
- const dp = findKeyPosition(content, "deny");
119
- if (!Array.isArray(p.deny)) {
120
- push(diag(config, filePath, "settings-json/deny-array", "error", "\"permissions.deny\" must be an array of strings", dp?.line, dp?.column));
341
+ // disableBypassPermissionsMode — the only accepted value is "disable"
342
+ if ("disableBypassPermissionsMode" in p && p.disableBypassPermissionsMode !== "disable") {
343
+ const dp = findKeyPosition(content, "disableBypassPermissionsMode");
344
+ push(diag(config, filePath, "settings-json/permissions-disable-bypass", "warning", "\"permissions.disableBypassPermissionsMode\" only accepts the string \"disable\"", dp?.line, dp?.column));
345
+ }
346
+ // additionalDirectories — array of strings
347
+ if ("additionalDirectories" in p && !isStringArray(p.additionalDirectories)) {
348
+ const ap = findKeyPosition(content, "additionalDirectories");
349
+ push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.additionalDirectories\" should be an array of strings", ap?.line, ap?.column));
350
+ }
351
+ // disableAutoMode — boolean
352
+ if ("disableAutoMode" in p && typeof p.disableAutoMode !== "boolean") {
353
+ const ap = findKeyPosition(content, "disableAutoMode");
354
+ push(diag(config, filePath, "settings-json/permissions-field-type", "warning", "\"permissions.disableAutoMode\" should be a boolean", ap?.line, ap?.column));
355
+ }
356
+ }
357
+ }
358
+ // sandbox
359
+ if ("sandbox" in parsed) {
360
+ const sandbox = parsed.sandbox;
361
+ const sp = findKeyPosition(content, "sandbox");
362
+ if (!isPlainObject(sandbox)) {
363
+ push(diag(config, filePath, "settings-json/sandbox-object", "error", "\"sandbox\" must be an object", sp?.line, sp?.column));
364
+ }
365
+ else {
366
+ for (const [key, val] of Object.entries(sandbox)) {
367
+ const kp = findKeyPosition(content, key);
368
+ if (!SANDBOX_FIELDS.has(key)) {
369
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.${key}"`, kp?.line, kp?.column));
370
+ continue;
371
+ }
372
+ const expected = SANDBOX_FIELD_TYPES[key];
373
+ if (expected && !typeMatches(expected, val)) {
374
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.${key}" should be ${articleType(expected)}`, kp?.line, kp?.column));
375
+ continue;
376
+ }
377
+ // Nested object validation
378
+ if (key === "network" && isPlainObject(val)) {
379
+ checkNested(val, "sandbox.network", SANDBOX_NETWORK_FIELDS, NETWORK_FIELD_TYPES);
380
+ if (isPlainObject(val.tlsTerminate)) {
381
+ checkNested(val.tlsTerminate, "sandbox.network.tlsTerminate", new Set(Object.keys(TLS_TERMINATE_FIELD_TYPES)), TLS_TERMINATE_FIELD_TYPES);
382
+ }
383
+ }
384
+ else if (key === "filesystem" && isPlainObject(val)) {
385
+ checkNested(val, "sandbox.filesystem", SANDBOX_FILESYSTEM_FIELDS, FILESYSTEM_FIELD_TYPES);
386
+ }
387
+ else if (key === "ripgrep" && isPlainObject(val)) {
388
+ for (const rk of Object.keys(val)) {
389
+ if (rk !== "command" && rk !== "args") {
390
+ const rp = findKeyPosition(content, rk);
391
+ push(diag(config, filePath, "settings-json/sandbox-unknown-field", "warning", `Unknown field "sandbox.ripgrep.${rk}"`, rp?.line, rp?.column));
392
+ }
393
+ }
394
+ if (typeof val.command !== "string") {
395
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.command\" is required and must be a string", kp?.line, kp?.column));
396
+ }
397
+ if ("args" in val && !isStringArray(val.args)) {
398
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", "\"sandbox.ripgrep.args\" should be an array of strings", kp?.line, kp?.column));
399
+ }
400
+ }
401
+ else if (key === "ignoreViolations" && isPlainObject(val)) {
402
+ for (const [vk, vv] of Object.entries(val)) {
403
+ if (!isStringArray(vv)) {
404
+ const vp = findKeyPosition(content, vk);
405
+ push(diag(config, filePath, "settings-json/sandbox-field-type", "warning", `"sandbox.ignoreViolations.${vk}" should be an array of strings`, vp?.line, vp?.column));
406
+ }
407
+ }
121
408
  }
122
409
  }
123
410
  }
@@ -126,7 +413,7 @@ export const settingsJsonLinter = {
126
413
  if ("env" in parsed) {
127
414
  const env = parsed.env;
128
415
  const envp = findKeyPosition(content, "env");
129
- if (typeof env !== "object" || env === null || Array.isArray(env)) {
416
+ if (!isPlainObject(env)) {
130
417
  push(diag(config, filePath, "settings-json/env-object", "error", "\"env\" must be an object of string key-value pairs", envp?.line, envp?.column));
131
418
  }
132
419
  else {
@@ -142,7 +429,7 @@ export const settingsJsonLinter = {
142
429
  if ("enabledPlugins" in parsed) {
143
430
  const plugins = parsed.enabledPlugins;
144
431
  const plp = findKeyPosition(content, "enabledPlugins");
145
- if (typeof plugins !== "object" || plugins === null || Array.isArray(plugins)) {
432
+ if (!isPlainObject(plugins)) {
146
433
  push(diag(config, filePath, "settings-json/plugins-object", "error", "\"enabledPlugins\" must be an object", plp?.line, plp?.column));
147
434
  }
148
435
  else {
@@ -1,9 +1,13 @@
1
- import { SKILL_FRONTMATTER } from "../contracts.js";
1
+ import { AGENT_MODELS, TOOLS } from "../contracts.js";
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";
6
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
4
7
  import { isKebabCase } from "../utils/kebab-case.js";
5
8
  const RULES = [
6
9
  { id: "skill-md/valid-frontmatter", defaultSeverity: "error" },
10
+ { id: "skill-md/schema-valid", defaultSeverity: "error" },
7
11
  { id: "skill-md/name-required", defaultSeverity: "error" },
8
12
  { id: "skill-md/name-kebab-case", defaultSeverity: "error" },
9
13
  { id: "skill-md/name-max-length", defaultSeverity: "error" },
@@ -11,10 +15,76 @@ const RULES = [
11
15
  { id: "skill-md/description-max-length", defaultSeverity: "error" },
12
16
  { id: "skill-md/description-no-angle-brackets", defaultSeverity: "error" },
13
17
  { id: "skill-md/description-trigger-phrases", defaultSeverity: "warning" },
14
- { id: "skill-md/no-unknown-frontmatter", defaultSeverity: "warning" },
15
- { id: "skill-md/body-word-count", 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" },
22
+ { id: "skill-md/no-unknown-frontmatter", defaultSeverity: "info" },
23
+ { id: "skill-md/body-word-count", defaultSeverity: "info" },
16
24
  { id: "skill-md/body-has-headers", defaultSeverity: "info" },
17
25
  ];
26
+ /**
27
+ * Decide whether a SKILL.md `description` communicates *when* the skill
28
+ * applies. A description that gives Claude Code any concrete applicability
29
+ * cue is fine — only purely declarative descriptions (a flat statement of
30
+ * what the skill is, with no routing signal at all) should be flagged.
31
+ *
32
+ * Recognized signals, in rough order of how common they are in real skills:
33
+ * - explicit trigger sections: "Trigger on …", "Trigger when/whenever …",
34
+ * "Triggers: …", "TRIGGER when:", routing-marker comments
35
+ * - "use when / use for / use whenever / use this skill when / use to"
36
+ * - "should be used when …", "applies when …", "invoke when …"
37
+ * - "when the user asks/wants/mentions/needs …" and bare "when <verb>ing"
38
+ * - imperative-verb openers ("Diagnose …", "Deploy …", "Author …") — an
39
+ * imperative description states the task the skill performs, which is
40
+ * itself an applicability cue
41
+ * - gerund openers ("Building …", "Searching …")
42
+ */
43
+ function hasTriggerSignal(desc) {
44
+ const d = desc.trim();
45
+ if (!d)
46
+ return false;
47
+ // Routing-marker comments authors drop in to delimit trigger lists.
48
+ if (/BEGIN ROUTING TRIGGERS|ROUTING TRIGGERS/i.test(d))
49
+ return true;
50
+ // Explicit trigger / use-when phrasing anywhere in the description.
51
+ // "Trigger" only counts as a routing cue when used as a directive verb
52
+ // ("Trigger on …", "Trigger when/whenever/if …", "Triggers: …", "TRIGGER
53
+ // when:") — not when "trigger" merely appears as a noun (e.g. the phrase
54
+ // "no trigger phrases").
55
+ const triggerPhrases = /\btrigger(s)?\b\s*(on|when|whenever|if|upon|for)\b|\btriggers?\s*:|\buse (this skill |it )?(when|whenever|for|to|on|if)\b|\bshould be used (when|whenever|for|to|if)\b|\b(applies|invoke|reach for|call this|run this|use this) (when|whenever|if|to|for)\b|\bwhen (the user|you|asked|working|debugging|diagnosing|investigating|reviewing|writing|building|creating|editing|setting up|deploying|the task|a request|a question|you need|there|something|anything)\b|\bwhen [a-z]+ing\b|\bfor (debugging|diagnosing|tasks|requests|questions|when|any)\b/i;
56
+ if (triggerPhrases.test(d))
57
+ return true;
58
+ // First "sentence-ish" chunk — used to detect imperative / gerund openers.
59
+ const opener = d.split(/[.\n—:(]/, 1)[0].trim();
60
+ const firstWord = opener.split(/\s+/, 1)[0].replace(/[^A-Za-z-]/g, "");
61
+ // Gerund opener ("Building a …", "Searching the …").
62
+ if (/^[A-Z][a-z]+ing\b/.test(opener))
63
+ return true;
64
+ // Imperative-verb opener. A skill description that starts with a bare
65
+ // verb is describing the action the skill performs, which signals when
66
+ // to route to it. Accept a curated set of common imperative verbs so we
67
+ // don't accidentally pass a noun-opener declarative description.
68
+ const imperativeVerbs = new Set([
69
+ "add", "analyze", "audit", "author", "automate", "build", "check",
70
+ "clean", "configure", "convert", "create", "debug", "delete", "deploy",
71
+ "diagnose", "drive", "edit", "enforce", "ensure", "estimate",
72
+ "evaluate", "execute", "extract", "fetch", "find", "fix", "format",
73
+ "generate", "guide", "handle", "help", "identify", "implement",
74
+ "initialize", "inspect", "install", "investigate", "lint", "list",
75
+ "manage", "migrate", "mint", "monitor", "open", "optimize", "parse",
76
+ "perform", "pin", "plan", "prepare", "preview", "produce", "profile",
77
+ "publish", "pull", "push", "query", "reclaim", "refactor", "render",
78
+ "report", "resolve", "restore", "review", "rollout", "run", "scaffold",
79
+ "scan", "search", "set", "ship", "size", "summarize", "sweep", "sync",
80
+ "test", "trace", "track", "transform", "transition", "translate",
81
+ "troubleshoot", "tune", "update", "upgrade", "validate", "verify",
82
+ "watch", "write",
83
+ ]);
84
+ if (imperativeVerbs.has(firstWord.toLowerCase()))
85
+ return true;
86
+ return false;
87
+ }
18
88
  function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
19
89
  if (!isRuleEnabled(config, ruleId))
20
90
  return null;
@@ -38,6 +108,25 @@ export const skillMdLinter = {
38
108
  push(diag(config, filePath, "skill-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
39
109
  return diagnostics;
40
110
  }
111
+ // schema-valid — structural validation against the JSON Schema
112
+ // auto-extracted from Claude Code's skill frontmatter Zod validator. The
113
+ // hand-written rules below add Claude-Code-specific advice the schema
114
+ // can't express (kebab-case names, trigger phrasing, …). The extracted
115
+ // schema is intentionally permissive about unknown frontmatter keys
116
+ // (Claude Code still loads the skill), so those are NOT reported here —
117
+ // skill-md/no-unknown-frontmatter handles them. Skipped silently if the
118
+ // schema bundle isn't shipped with this install.
119
+ if (isRuleEnabled(config, "skill-md/schema-valid")) {
120
+ const compiled = loadSkillFrontmatterSchema();
121
+ if (compiled) {
122
+ const ok = compiled.validate(fm.data);
123
+ if (!ok && compiled.validate.errors) {
124
+ for (const err of summarizeErrors(compiled.validate.errors)) {
125
+ push(diag(config, filePath, "skill-md/schema-valid", "error", formatAjvError(err)));
126
+ }
127
+ }
128
+ }
129
+ }
41
130
  // name
42
131
  if (!("name" in fm.data) || typeof fm.data.name !== "string") {
43
132
  push(diag(config, filePath, "skill-md/name-required", "error", "\"name\" is required in frontmatter"));
@@ -63,25 +152,67 @@ export const skillMdLinter = {
63
152
  if (/<|>/.test(desc)) {
64
153
  push(diag(config, filePath, "skill-md/description-no-angle-brackets", "error", "\"description\" must not contain angle brackets (< or >)"));
65
154
  }
66
- if (!/when the user|should be used when/i.test(desc)) {
67
- push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description should contain trigger phrases (e.g., \"when the user asks to...\")"));
155
+ if (!hasTriggerSignal(desc)) {
156
+ push(diag(config, filePath, "skill-md/description-trigger-phrases", "warning", "Description has no applicability signal say when the skill applies " +
157
+ "(e.g., \"Use when…\", \"Trigger on…\", \"when the user asks to…\", or an imperative verb)"));
158
+ }
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])})`));
68
193
  }
69
194
  }
70
- // unknown frontmatter keys
195
+ // Frontmatter keys: only flag cross-artifact misplacement. A key that is
196
+ // valid for a *different* markdown artifact (e.g. agent's "effort" on a
197
+ // skill) gets an info; a key valid for no artifact stays silent.
71
198
  for (const key of Object.keys(fm.data)) {
72
- if (!SKILL_FRONTMATTER.has(key)) {
73
- push(diag(config, filePath, "skill-md/no-unknown-frontmatter", "warning", `Unknown frontmatter key "${key}" (known: ${[...SKILL_FRONTMATTER].join(", ")})`));
199
+ const cls = classifyUnknownFrontmatterKey(key, "skill");
200
+ if (cls?.kind === "owned-by-other" && cls.owner) {
201
+ push(diag(config, filePath, "skill-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on a skill`));
74
202
  }
75
203
  }
76
204
  // body checks
77
205
  const body = fm.body.trim();
78
206
  if (body) {
207
+ // Word-count is a soft style hint: a concise, well-scoped skill body is
208
+ // legitimate. Only flag genuinely thin bodies (< 150 words) or very
209
+ // large ones (> 5000) that likely belong split into references/.
79
210
  const words = body.split(/\s+/).length;
80
- if (words < 500) {
81
- push(diag(config, filePath, "skill-md/body-word-count", "warning", `Body has ${words} words (recommended: 500-5000)`, fm.bodyStartLine));
211
+ if (words < 150) {
212
+ push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider adding more detail (recommended: 150-5000)`, fm.bodyStartLine));
82
213
  }
83
214
  else if (words > 5000) {
84
- push(diag(config, filePath, "skill-md/body-word-count", "warning", `Body has ${words} words — consider moving detail to references/ (recommended: 500-5000)`, fm.bodyStartLine));
215
+ push(diag(config, filePath, "skill-md/body-word-count", "info", `Body has ${words} words — consider moving detail to references/ (recommended: 150-5000)`, fm.bodyStartLine));
85
216
  }
86
217
  if (!/^##\s/m.test(body)) {
87
218
  push(diag(config, filePath, "skill-md/body-has-headers", "info", "Body should use H2 (##) sections for organization", fm.bodyStartLine));
@@ -52,6 +52,7 @@ function loadCompiledSchema(fileName) {
52
52
  const compiled = {
53
53
  validate: getAjv().compile(wrapped.schema),
54
54
  extractedFromVersion: wrapped.extractedFromClaudeCodeVersion,
55
+ knownFields: new Set(Object.keys(wrapped.schema.properties ?? {})),
55
56
  };
56
57
  compiledCache.set(fileName, compiled);
57
58
  return compiled;
@@ -62,6 +63,24 @@ export function loadLspSchema() {
62
63
  export function loadMonitorsSchema() {
63
64
  return loadCompiledSchema("monitors.schema.json");
64
65
  }
66
+ export function loadSettingsSchema() {
67
+ return loadCompiledSchema("settings.schema.json");
68
+ }
69
+ export function loadMcpJsonSchema() {
70
+ return loadCompiledSchema("mcp.schema.json");
71
+ }
72
+ export function loadHooksJsonSchema() {
73
+ return loadCompiledSchema("hooks.schema.json");
74
+ }
75
+ export function loadSkillFrontmatterSchema() {
76
+ return loadCompiledSchema("skill-frontmatter.schema.json");
77
+ }
78
+ export function loadAgentFrontmatterSchema() {
79
+ return loadCompiledSchema("agent-frontmatter.schema.json");
80
+ }
81
+ export function loadCommandFrontmatterSchema() {
82
+ return loadCompiledSchema("command-frontmatter.schema.json");
83
+ }
65
84
  export function loadPluginSchema() {
66
85
  if (cached)
67
86
  return cached;