claudecode-linter 2.1.143 → 2.1.148

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.
@@ -0,0 +1,182 @@
1
+ {
2
+ "extractedFromClaudeCodeVersion": "2.1.146",
3
+ "extractedAt": "2026-05-21T21:04:24.797Z",
4
+ "schema": {
5
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
6
+ "title": "Claude Code SKILL.md frontmatter",
7
+ "type": "object",
8
+ "properties": {
9
+ "name": {
10
+ "description": "Display name. Defaults to the filename without extension."
11
+ },
12
+ "description": {
13
+ "description": "One-line summary shown in listings and the Skill tool."
14
+ },
15
+ "model": {
16
+ "description": "Model override (`haiku`, `sonnet`, `opus`, or a full ID). Use `inherit` to match the parent conversation."
17
+ },
18
+ "allowed-tools": {
19
+ "anyOf": [
20
+ {
21
+ "anyOf": [
22
+ {
23
+ "type": "string"
24
+ },
25
+ {
26
+ "type": "number"
27
+ },
28
+ {
29
+ "type": "boolean"
30
+ },
31
+ {
32
+ "type": "null"
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "string"
40
+ }
41
+ }
42
+ ],
43
+ "description": "Tools available to the model while this file is active. Comma-separated string or YAML list."
44
+ },
45
+ "argument-hint": {
46
+ "description": "Placeholder text shown after the slash command name."
47
+ },
48
+ "arguments": {
49
+ "anyOf": [
50
+ {
51
+ "anyOf": [
52
+ {
53
+ "type": "string"
54
+ },
55
+ {
56
+ "type": "number"
57
+ },
58
+ {
59
+ "type": "boolean"
60
+ },
61
+ {
62
+ "type": "null"
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "type": "array",
68
+ "items": {
69
+ "type": "string"
70
+ }
71
+ }
72
+ ],
73
+ "description": "@internal — typed variant of argument-hint; argument-hint is the documented form"
74
+ },
75
+ "disable-model-invocation": {
76
+ "description": "If true, the model cannot invoke this via the Skill tool; only users can type the slash command."
77
+ },
78
+ "user-invocable": {
79
+ "description": "If false, hides the slash command from users; only the model can invoke it via the Skill tool."
80
+ },
81
+ "effort": {
82
+ "description": "Thinking effort for the model: `low`, `medium`, `high`, `max`, or an integer."
83
+ },
84
+ "shell": {
85
+ "description": "Shell for `!`-command blocks: `bash` or `powershell`. Defaults to bash regardless of platform."
86
+ },
87
+ "version": {
88
+ "description": "@internal — bookkeeping, not surfaced to users"
89
+ },
90
+ "when_to_use": {
91
+ "description": "Guidance for when the model should reach for this skill. Becomes part of the tool description."
92
+ },
93
+ "paths": {
94
+ "anyOf": [
95
+ {
96
+ "anyOf": [
97
+ {
98
+ "type": "string"
99
+ },
100
+ {
101
+ "type": "number"
102
+ },
103
+ {
104
+ "type": "boolean"
105
+ },
106
+ {
107
+ "type": "null"
108
+ }
109
+ ]
110
+ },
111
+ {
112
+ "type": "array",
113
+ "items": {
114
+ "type": "string"
115
+ }
116
+ }
117
+ ],
118
+ "description": "Glob patterns this skill applies to. The skill only loads when the model touches matching files."
119
+ },
120
+ "hooks": {
121
+ "description": "Hooks registered while this skill is active. Same shape as settings.json `hooks`."
122
+ },
123
+ "context": {
124
+ "enum": [
125
+ "inline",
126
+ "fork",
127
+ null
128
+ ],
129
+ "description": "Where the skill runs: `inline` expands into the current conversation; `fork` spawns a subagent."
130
+ },
131
+ "agent": {
132
+ "description": "Agent type to spawn when `context: fork`."
133
+ },
134
+ "fallback": {
135
+ "description": "@internal — interim defense-in-depth for thin-pointer skill stubs. If true, this skill yields to a same-suffix plugin or MCP skill (`<plugin>:<name>` / `<server>:<name>`) when one is loaded. Stubs carrying this should be deleted once their canonical plugin/MCP skill ships, not maintained."
136
+ },
137
+ "created_by": {
138
+ "description": "@internal — provenance marker (e.g. dream-proposal)"
139
+ },
140
+ "improved_by": {
141
+ "description": "@internal — provenance marker (e.g. dream-proposal)"
142
+ },
143
+ "mcpServers": {
144
+ "description": "@internal"
145
+ },
146
+ "lspServers": {
147
+ "description": "@internal"
148
+ },
149
+ "agents": {
150
+ "description": "@internal"
151
+ },
152
+ "outputStyles": {
153
+ "description": "@internal"
154
+ },
155
+ "themes": {
156
+ "description": "@internal"
157
+ },
158
+ "workflows": {
159
+ "description": "@internal"
160
+ },
161
+ "channels": {
162
+ "description": "@internal"
163
+ },
164
+ "monitors": {
165
+ "description": "@internal"
166
+ },
167
+ "settings": {
168
+ "description": "@internal"
169
+ },
170
+ "experimental": {
171
+ "description": "@internal"
172
+ },
173
+ "dependencies": {
174
+ "description": "@internal"
175
+ },
176
+ "metadata": {
177
+ "description": "@internal"
178
+ }
179
+ },
180
+ "description": "Claude Code SKILL.md YAML frontmatter. Validates the structure of known fields; the object is intentionally permissive — unknown frontmatter keys are not schema errors (Claude Code still loads the skill), they are reported by the advisory skill-md/no-unknown-frontmatter rule."
181
+ }
182
+ }
package/dist/contracts.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // Auto-generated from contracts/claude-code-contracts.json
2
- // Claude Code v2.1.143 — extracted 2026-05-16T00:58:12.730Z
2
+ // Claude Code v2.1.148 — extracted 2026-05-22T07:13:45.210Z
3
3
  // Do not edit manually. Run: npm run generate-contracts
4
4
  export const TOOLS = new Set([
5
5
  "Agent",
6
6
  "AskUserQuestion",
7
7
  "Bash",
8
8
  "Config",
9
+ "CronCreate",
10
+ "CronDelete",
11
+ "CronList",
9
12
  "Edit",
10
13
  "EnterPlanMode",
11
14
  "EnterWorktree",
@@ -16,10 +19,13 @@ export const TOOLS = new Set([
16
19
  "LSP",
17
20
  "ListMcpResources",
18
21
  "Mcp",
22
+ "Monitor",
19
23
  "NotebookEdit",
20
24
  "NotebookRead",
25
+ "PushNotification",
21
26
  "Read",
22
27
  "ReadMcpResource",
28
+ "RemoteTrigger",
23
29
  "SendMessage",
24
30
  "Skill",
25
31
  "SubscribeMcpResource",
@@ -164,6 +170,7 @@ export const MCP_SERVER_FIELDS = new Set([
164
170
  "headersHelper",
165
171
  "oauth",
166
172
  "role",
173
+ "timeout",
167
174
  "type",
168
175
  "url",
169
176
  ]);
@@ -316,7 +323,63 @@ export const SETTINGS_USER_FIELDS = new Set([
316
323
  "wslInheritsWindowsSettings",
317
324
  ]);
318
325
  export const SETTINGS_PROJECT_FIELDS = new Set([
326
+ "hooks",
319
327
  "permissions",
328
+ "sandbox",
329
+ ]);
330
+ // Allowed sub-keys of the settings `permissions` object.
331
+ export const PERMISSIONS_FIELDS = new Set([
332
+ "additionalDirectories",
333
+ "allow",
334
+ "ask",
335
+ "defaultMode",
336
+ "deny",
337
+ "disableAutoMode",
338
+ "disableBypassPermissionsMode",
339
+ ]);
340
+ // Allowed sub-keys of the settings `sandbox` object.
341
+ export const SANDBOX_FIELDS = new Set([
342
+ "allowUnsandboxedCommands",
343
+ "autoAllowBashIfSandboxed",
344
+ "bwrapPath",
345
+ "enableWeakerNestedSandbox",
346
+ "enableWeakerNetworkIsolation",
347
+ "enabled",
348
+ "excludedCommands",
349
+ "failIfUnavailable",
350
+ "filesystem",
351
+ "ignoreViolations",
352
+ "network",
353
+ "ripgrep",
354
+ ]);
355
+ // Allowed sub-keys of `sandbox.network` and `sandbox.filesystem`.
356
+ export const SANDBOX_NETWORK_FIELDS = new Set([
357
+ "allowAllUnixSockets",
358
+ "allowLocalBinding",
359
+ "allowMachLookup",
360
+ "allowManagedDomainsOnly",
361
+ "allowUnixSockets",
362
+ "allowedDomains",
363
+ "deniedDomains",
364
+ "httpProxyPort",
365
+ "socksProxyPort",
366
+ "tlsTerminate",
367
+ ]);
368
+ export const SANDBOX_FILESYSTEM_FIELDS = new Set([
369
+ "allowManagedReadPathsOnly",
370
+ "allowRead",
371
+ "allowWrite",
372
+ "denyRead",
373
+ "denyWrite",
374
+ ]);
375
+ // Valid values for `permissions.defaultMode`.
376
+ export const PERMISSION_MODES = new Set([
377
+ "acceptEdits",
378
+ "auto",
379
+ "bypassPermissions",
380
+ "default",
381
+ "dontAsk",
382
+ "plan",
320
383
  ]);
321
384
  // Hand-curated denylist of tools declared in agent frontmatter that never
322
385
  // reach plugin-defined subagents at runtime. Source: tracked upstream bugs
package/dist/discovery.js CHANGED
@@ -79,6 +79,25 @@ export function discoverArtifacts(targetPath, options) {
79
79
  }
80
80
  return artifacts;
81
81
  }
82
+ /**
83
+ * Detect which Claude Code artifact types are present under the given paths.
84
+ * Returns the distinct types, sorted; excludes the `misplaced-file` diagnostic
85
+ * category (it marks a misplacement, not a kind of artifact a project owns).
86
+ * An empty result means the path holds no recognizable Claude Code artifacts.
87
+ *
88
+ * Powers the `--detect` CLI mode: a generic git hook can call it to decide
89
+ * whether a repository is a Claude Code plugin / config tree before linting.
90
+ */
91
+ export function detectArtifactTypes(paths, ignore = []) {
92
+ const found = new Set();
93
+ for (const targetPath of paths) {
94
+ for (const a of discoverArtifacts(targetPath, { ignore })) {
95
+ if (a.artifactType !== "misplaced-file")
96
+ found.add(a.artifactType);
97
+ }
98
+ }
99
+ return [...found].sort();
100
+ }
82
101
  function detectScope(filePath) {
83
102
  const resolved = resolve(filePath);
84
103
  // Inside ~/.claude/ itself (not a subdirectory project)
@@ -253,8 +272,9 @@ function discoverInDirectory(dir) {
253
272
  * sitting at a non-canonical location is returned as a
254
273
  * `misplaced-file` artifact for the misplaced-file linter to flag.
255
274
  *
256
- * Ignores typical noise dirs (`node_modules`, `.git`, `dist`, the
257
- * plugin install cache's `.in_use` / `.orphaned_at` markers).
275
+ * Ignores typical noise dirs (`node_modules`, `.git`, `dist`,
276
+ * `.claude/worktrees/` worktree copies, the plugin install cache's
277
+ * `.in_use` / `.orphaned_at` markers).
258
278
  */
259
279
  function findMisplacedFiles(pluginRoot) {
260
280
  const out = [];
@@ -270,6 +290,10 @@ function findMisplacedFiles(pluginRoot) {
270
290
  ignore: [
271
291
  "**/node_modules/**",
272
292
  "**/.git/**",
293
+ // `.claude/worktrees/` holds transient git-worktree copies
294
+ // (Claude Code's own EnterWorktree). Linting them re-reports
295
+ // every artifact once per worktree — pure noise.
296
+ "**/.claude/worktrees/**",
273
297
  "**/dist/**",
274
298
  "**/.in_use/**",
275
299
  "**/.orphaned_at/**",
@@ -1,6 +1,8 @@
1
1
  import { formatJson } from "../utils/prettier.js";
2
2
  const TOP_LEVEL_KEY_ORDER = [
3
3
  "permissions",
4
+ "sandbox",
5
+ "hooks",
4
6
  "env",
5
7
  "plugins",
6
8
  "skipDangerousModePermissionPrompt",
@@ -30,15 +32,14 @@ export const settingsJsonFixer = {
30
32
  ordered[key] = parsed[key];
31
33
  }
32
34
  }
33
- // Sort permissions.allow and permissions.deny alphabetically
35
+ // Sort the permissions.allow / deny / ask rule arrays alphabetically
34
36
  const permissions = ordered["permissions"];
35
37
  if (typeof permissions === "object" && permissions !== null && !Array.isArray(permissions)) {
36
38
  const perms = permissions;
37
- if (Array.isArray(perms["allow"])) {
38
- perms["allow"] = [...perms["allow"]].sort();
39
- }
40
- if (Array.isArray(perms["deny"])) {
41
- perms["deny"] = [...perms["deny"]].sort();
39
+ for (const list of ["allow", "deny", "ask"]) {
40
+ if (Array.isArray(perms[list])) {
41
+ perms[list] = [...perms[list]].sort();
42
+ }
42
43
  }
43
44
  }
44
45
  return formatJson(JSON.stringify(ordered));
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ 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 { discoverArtifacts } from "./discovery.js";
8
+ import { discoverArtifacts, detectArtifactTypes } from "./discovery.js";
9
9
  import { formatHuman } from "./formatters/human.js";
10
10
  import { formatJson } from "./formatters/json.js";
11
11
  import { pluginJsonLinter } from "./linters/plugin-json.js";
@@ -110,19 +110,20 @@ const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.met
110
110
  sade("claudecode-linter", true)
111
111
  .version(pkgVersion)
112
112
  .describe("Linter for Claude Code plugin artifacts")
113
- .option("--lint", "Lint artifacts and report issues (default)")
114
- .option("--fix", "Auto-fix lint violations, then report remaining issues")
115
- .option("--format", "Format all artifacts for consistent style (no lint output)")
113
+ .option("--lint", "Lint artifacts and report issues (default)", false)
114
+ .option("--fix", "Auto-fix lint violations, then report remaining issues", false)
115
+ .option("--format", "Format all artifacts for consistent style (no lint output)", false)
116
116
  .option("--output", "Output format: human | json", "human")
117
117
  .option("--config", "Config file path")
118
118
  .option("--scope", "Filter by scope: user | project | subdirectory")
119
119
  .option("--ignore", "Comma-separated glob patterns to ignore")
120
- .option("--quiet", "Only show errors")
120
+ .option("--quiet", "Only show errors", false)
121
121
  .option("--enable", "Comma-separated rule IDs to enable")
122
122
  .option("--disable", "Comma-separated rule IDs to disable")
123
123
  .option("--rule", "Run only this single rule ID")
124
- .option("--list-rules", "Print all rules with their default severity and exit")
125
- .option("--fix-dry-run", "Run fixers but print diff instead of writing")
124
+ .option("--list-rules", "Print all rules with their default severity and exit", false)
125
+ .option("--detect", "Print detected Claude Code artifact type(s), one per line; exit 0 if any, 1 if none", false)
126
+ .option("--fix-dry-run", "Run fixers but print diff instead of writing", false)
126
127
  .option("--init", "Copy default config to path (default: current directory)")
127
128
  .action(async (opts) => {
128
129
  // sade accepts variadic positional via opts._; default to ["."] when empty.
@@ -153,6 +154,21 @@ sade("claudecode-linter", true)
153
154
  }
154
155
  process.exit(0);
155
156
  }
157
+ if (opts.detect) {
158
+ const ignoreD = opts.ignore
159
+ ?.split(",")
160
+ .map((p) => p.trim())
161
+ .filter(Boolean) ?? [];
162
+ const types = detectArtifactTypes(paths, ignoreD);
163
+ if (opts.output === "json") {
164
+ process.stdout.write(`${JSON.stringify(types)}\n`);
165
+ }
166
+ else {
167
+ for (const t of types)
168
+ process.stdout.write(`${t}\n`);
169
+ }
170
+ process.exit(types.length > 0 ? 0 : 1);
171
+ }
156
172
  const enableList = opts.enable
157
173
  ? opts.enable
158
174
  .split(",")
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
3
+ import { AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
4
+ import { formatAjvError, loadAgentFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
4
5
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
5
6
  import { parseFrontmatter } from "../utils/frontmatter.js";
7
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
6
8
  function findPluginRoot(agentFilePath) {
7
9
  let dir = dirname(agentFilePath);
8
10
  for (let i = 0; i < 10; i++) {
@@ -56,10 +58,11 @@ function parseToolsField(v) {
56
58
  }
57
59
  const RULES = [
58
60
  { id: "agent-md/valid-frontmatter", defaultSeverity: "error" },
61
+ { id: "agent-md/schema-valid", defaultSeverity: "error" },
59
62
  { id: "agent-md/name-required", defaultSeverity: "error" },
60
63
  { id: "agent-md/name-format", defaultSeverity: "error" },
61
64
  { id: "agent-md/description-required", defaultSeverity: "error" },
62
- { id: "agent-md/description-examples", defaultSeverity: "warning" },
65
+ { id: "agent-md/description-routing-guidance", defaultSeverity: "warning" },
63
66
  { id: "agent-md/model-required", defaultSeverity: "error" },
64
67
  { id: "agent-md/model-valid", defaultSeverity: "warning" },
65
68
  { id: "agent-md/color-required", defaultSeverity: "warning" },
@@ -97,6 +100,26 @@ export const agentMdLinter = {
97
100
  push(diag(config, filePath, "agent-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
98
101
  return diagnostics;
99
102
  }
103
+ // schema-valid — structural validation against the JSON Schema
104
+ // auto-extracted from Claude Code's agent frontmatter Zod validator.
105
+ // The hand-written rules below add Claude-Code-specific advice with
106
+ // friendlier messages (model-valid / color-valid re-check fields the
107
+ // schema also covers — that minimal double-reporting is acceptable).
108
+ // The extracted schema is intentionally permissive about unknown
109
+ // frontmatter keys (Claude Code still loads the agent), so those are
110
+ // NOT reported here — agent-md/no-unknown-frontmatter handles them.
111
+ // Skipped silently if the schema bundle isn't shipped with this install.
112
+ if (isRuleEnabled(config, "agent-md/schema-valid")) {
113
+ const compiled = loadAgentFrontmatterSchema();
114
+ if (compiled) {
115
+ const ok = compiled.validate(fm.data);
116
+ if (!ok && compiled.validate.errors) {
117
+ for (const err of summarizeErrors(compiled.validate.errors)) {
118
+ push(diag(config, filePath, "agent-md/schema-valid", "error", formatAjvError(err)));
119
+ }
120
+ }
121
+ }
122
+ }
100
123
  // name
101
124
  if (!("name" in fm.data) || typeof fm.data.name !== "string") {
102
125
  push(diag(config, filePath, "agent-md/name-required", "error", '"name" is required in frontmatter'));
@@ -115,8 +138,16 @@ export const agentMdLinter = {
115
138
  push(diag(config, filePath, "agent-md/description-required", "error", '"description" is required in frontmatter'));
116
139
  }
117
140
  else {
118
- if (!/<example>/i.test(fm.data.description)) {
119
- push(diag(config, filePath, "agent-md/description-examples", "warning", "Description should include <example> blocks for triggering"));
141
+ // An agent description must convey *when to route to it*. Three
142
+ // forms satisfy that: <example> blocks (the upstream few-shot
143
+ // convention), an explicit routing-triggers block, or prose that
144
+ // states the triggers. Warn only when the description does none.
145
+ const desc = fm.data.description;
146
+ const hasExamples = /<example>/i.test(desc);
147
+ const hasRoutingTriggers = /ROUTING TRIGGERS/i.test(desc);
148
+ const hasTriggerProse = /\b(use (it |this )?(for|when|to)\b|use this (agent|skill)|trigger(s|ed)? (on|by|when)|when the user|for any (task|request|work)|invoke (this|when))/i.test(desc);
149
+ if (!hasExamples && !hasRoutingTriggers && !hasTriggerProse) {
150
+ push(diag(config, filePath, "agent-md/description-routing-guidance", "warning", "Description should convey when to route to this agent — via <example> blocks, a routing-triggers block (<!-- BEGIN ROUTING TRIGGERS -->…<!-- END ROUTING TRIGGERS -->), or prose stating the triggers"));
120
151
  }
121
152
  }
122
153
  // model
@@ -134,11 +165,15 @@ export const agentMdLinter = {
134
165
  else if (!AGENT_COLORS.has(fm.data.color)) {
135
166
  push(diag(config, filePath, "agent-md/color-valid", "warning", `"color" must be one of: ${[...AGENT_COLORS].join(", ")} (got "${fm.data.color}")`));
136
167
  }
137
- // unknown frontmatter fields
138
- const knownFields = new Set([...AGENT_FRONTMATTER, "name", "color"]);
168
+ // Frontmatter keys: only flag cross-artifact misplacement. A key valid
169
+ // for a *different* markdown artifact (e.g. a skill-only key on an
170
+ // agent) gets an info; a key valid for no artifact stays silent.
171
+ // "name"/"color" are agent-valid but absent from the contract set.
172
+ const extraKnown = new Set(["name", "color"]);
139
173
  for (const key of Object.keys(fm.data)) {
140
- if (!knownFields.has(key)) {
141
- push(diag(config, filePath, "agent-md/no-unknown-frontmatter", "info", `Unknown frontmatter field "${key}"`));
174
+ const cls = classifyUnknownFrontmatterKey(key, "agent", extraKnown);
175
+ if (cls?.kind === "owned-by-other" && cls.owner) {
176
+ push(diag(config, filePath, "agent-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on an agent`));
142
177
  }
143
178
  }
144
179
  // system prompt (body)
@@ -50,10 +50,22 @@ export const claudeMdLinter = {
50
50
  push(diag(config, filePath, "claude-md/user-level-concise", "info", `User-level CLAUDE.md is ${lines.length} lines — keep global rules concise, put project-specific content in project CLAUDE.md files`));
51
51
  }
52
52
  if (scope === "project") {
53
- // Project CLAUDE.md should have a project description
54
- const hasProjectOverview = lines.some((l) => /^#+ .*(overview|project|about|description|what this is|introduction|summary)/i.test(l));
53
+ // Project CLAUDE.md should describe the project. This is satisfied by
54
+ // either an explicit overview-style heading OR descriptive prose near
55
+ // the top — a CLAUDE.md that opens with a title followed by a sentence
56
+ // of project description does not need a literal "Overview" heading.
57
+ const hasOverviewHeading = lines.some((l) => /^#+ .*(overview|project|about|description|what this is|introduction|summary)/i.test(l));
58
+ // Descriptive opening prose: a non-heading, non-list, non-blank line
59
+ // of reasonable length appearing before the first H2 section.
60
+ const firstH2 = lines.findIndex((l) => /^## /.test(l));
61
+ const preambleEnd = firstH2 >= 0 ? firstH2 : lines.length;
62
+ const hasOpeningProse = lines.slice(0, preambleEnd).some((l) => {
63
+ const t = l.trim();
64
+ return t.length >= 25 && !t.startsWith("#") && !/^[-*>|]/.test(t);
65
+ });
66
+ const hasProjectOverview = hasOverviewHeading || hasOpeningProse;
55
67
  if (!hasProjectOverview && h2Count > 0) {
56
- push(diag(config, filePath, "claude-md/project-has-overview", "info", "Project CLAUDE.md should include a project overview section"));
68
+ push(diag(config, filePath, "claude-md/project-has-overview", "info", "Project CLAUDE.md should include a project overview — an \"Overview\" section or descriptive opening prose"));
57
69
  }
58
70
  }
59
71
  // detect potential secrets
@@ -1,8 +1,11 @@
1
- import { COMMAND_FRONTMATTER, TOOLS } from "../contracts.js";
1
+ import { TOOLS } from "../contracts.js";
2
+ import { formatAjvError, loadCommandFrontmatterSchema, summarizeErrors, } from "../plugin-schema.js";
2
3
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
3
4
  import { parseFrontmatter } from "../utils/frontmatter.js";
5
+ import { artifactLabel, classifyUnknownFrontmatterKey, } from "../utils/frontmatter-keys.js";
4
6
  const RULES = [
5
7
  { id: "command-md/valid-frontmatter", defaultSeverity: "error" },
8
+ { id: "command-md/schema-valid", defaultSeverity: "error" },
6
9
  { id: "command-md/description-required", defaultSeverity: "error" },
7
10
  { id: "command-md/allowed-tools-valid", defaultSeverity: "warning" },
8
11
  { id: "command-md/body-present", defaultSeverity: "warning" },
@@ -31,6 +34,25 @@ export const commandMdLinter = {
31
34
  push(diag(config, filePath, "command-md/valid-frontmatter", "error", fm.error ?? "Invalid frontmatter"));
32
35
  return diagnostics;
33
36
  }
37
+ // schema-valid — structural validation against the JSON Schema
38
+ // auto-extracted from Claude Code's command frontmatter Zod validator.
39
+ // The hand-written rules below add Claude-Code-specific advice (known
40
+ // tool names, body presence). The extracted schema is intentionally
41
+ // permissive about unknown frontmatter keys (Claude Code still loads the
42
+ // command), so those are NOT reported here — command-md/no-unknown-
43
+ // frontmatter handles them. Skipped silently if the schema bundle isn't
44
+ // shipped with this install.
45
+ if (isRuleEnabled(config, "command-md/schema-valid")) {
46
+ const compiled = loadCommandFrontmatterSchema();
47
+ if (compiled) {
48
+ const ok = compiled.validate(fm.data);
49
+ if (!ok && compiled.validate.errors) {
50
+ for (const err of summarizeErrors(compiled.validate.errors)) {
51
+ push(diag(config, filePath, "command-md/schema-valid", "error", formatAjvError(err)));
52
+ }
53
+ }
54
+ }
55
+ }
34
56
  // description
35
57
  if (!("description" in fm.data) || typeof fm.data.description !== "string") {
36
58
  push(diag(config, filePath, "command-md/description-required", "error", "\"description\" is required in frontmatter"));
@@ -46,11 +68,15 @@ export const commandMdLinter = {
46
68
  }
47
69
  }
48
70
  }
49
- // unknown frontmatter fields
50
- const knownFields = new Set([...COMMAND_FRONTMATTER, "allowed-tools", "argument-hint"]);
71
+ // Frontmatter keys: only flag cross-artifact misplacement. A key valid
72
+ // for a *different* markdown artifact gets an info; a key valid for no
73
+ // artifact stays silent. The hyphenated "allowed-tools"/"argument-hint"
74
+ // are command-valid aliases of the camelCase contract keys.
75
+ const extraKnown = new Set(["allowed-tools", "argument-hint"]);
51
76
  for (const key of Object.keys(fm.data)) {
52
- if (!knownFields.has(key)) {
53
- push(diag(config, filePath, "command-md/no-unknown-frontmatter", "info", `Unknown frontmatter field "${key}"`));
77
+ const cls = classifyUnknownFrontmatterKey(key, "command", extraKnown);
78
+ if (cls?.kind === "owned-by-other" && cls.owner) {
79
+ push(diag(config, filePath, "command-md/no-unknown-frontmatter", "info", `"${key}" is ${artifactLabel(cls.owner)} frontmatter — Claude Code ignores it on a command`));
54
80
  }
55
81
  }
56
82
  // body
@@ -2,6 +2,10 @@ import { basename } from "node:path";
2
2
  import { MCP_SERVER_FIELDS } from "../contracts.js";
3
3
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
4
  import { isKebabCase } from "../utils/kebab-case.js";
5
+ // Valid `type` values for a URL-based HTTP MCP server. Claude Code v2.1.146
6
+ // accepts both — its HTTP transport schema is
7
+ // `enum(["http","streamable-http"]).transform(()=>"http")`.
8
+ const HTTP_TRANSPORT_TYPES = new Set(["http", "streamable-http"]);
5
9
  const RULES = [
6
10
  { id: "mcp-json/scope-file-name", defaultSeverity: "warning" },
7
11
  { id: "mcp-json/valid-json", defaultSeverity: "error" },
@@ -109,8 +113,11 @@ export const mcpJsonLinter = {
109
113
  catch {
110
114
  push(diag(config, filePath, "mcp-json/url-valid", "error", `Server "${name}" has invalid URL: "${url}"`, sp?.line, sp?.column));
111
115
  }
112
- if ("type" in server && server.type !== "http") {
113
- push(diag(config, filePath, "mcp-json/type-matches-transport", "warning", `Server "${name}" has URL but type is "${server.type}" (expected "http")`, sp?.line, sp?.column));
116
+ // A URL-based HTTP server may declare "http" or "streamable-http".
117
+ // Claude Code v2.1.146 treats them identically its HTTP transport
118
+ // schema is `enum(["http","streamable-http"]).transform(()=>"http")`.
119
+ if ("type" in server && !HTTP_TRANSPORT_TYPES.has(server.type)) {
120
+ push(diag(config, filePath, "mcp-json/type-matches-transport", "warning", `Server "${name}" has URL but type is "${server.type}" (expected one of ${[...HTTP_TRANSPORT_TYPES].join(", ")})`, sp?.line, sp?.column));
114
121
  }
115
122
  }
116
123
  // stdio server checks