claudecode-linter 2.1.136 → 2.1.138-patch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,3 +110,10 @@ rules:
110
110
  claude-md/no-absolute-paths: info
111
111
  claude-md/no-todo-markers: info
112
112
  claude-md/no-trailing-whitespace: info
113
+
114
+ # ── Misplaced files ──────────────────────────────────────
115
+ # Reserved artifact basenames (plugin.json, hooks.json,
116
+ # marketplace.json, SKILL.md) found at non-canonical paths inside
117
+ # a plugin tree. Claude Code silently ignores them — easy to make,
118
+ # hard to debug.
119
+ misplaced-file/canonical-location: warning
@@ -0,0 +1,43 @@
1
+ export const CANONICAL_ARTIFACTS = [
2
+ {
3
+ basename: "plugin.json",
4
+ expectedPath: ".claude-plugin/plugin.json",
5
+ description: "plugin manifest",
6
+ },
7
+ {
8
+ basename: "marketplace.json",
9
+ expectedPath: ".claude-plugin/marketplace.json",
10
+ description: "marketplace manifest",
11
+ },
12
+ {
13
+ basename: "hooks.json",
14
+ expectedPath: "hooks/hooks.json",
15
+ description: "plugin hooks config",
16
+ },
17
+ {
18
+ basename: "SKILL.md",
19
+ expectedPattern: "skills/*/SKILL.md",
20
+ description: "skill manifest",
21
+ },
22
+ {
23
+ basename: ".mcp.json",
24
+ expectedPath: ".mcp.json",
25
+ description: "plugin MCP server definitions",
26
+ },
27
+ {
28
+ basename: ".lsp.json",
29
+ expectedPath: ".lsp.json",
30
+ description: "plugin LSP server configurations",
31
+ },
32
+ {
33
+ basename: "monitors.json",
34
+ expectedPath: "monitors/monitors.json",
35
+ description: "plugin background-monitor declarations",
36
+ },
37
+ {
38
+ basename: "settings.json",
39
+ expectedPath: "settings.json",
40
+ description: "plugin default settings",
41
+ },
42
+ ];
43
+ //# sourceMappingURL=canonical-paths.js.map
package/dist/contracts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Auto-generated from contracts/claude-code-contracts.json
2
- // Claude Code v2.1.136 — extracted 2026-05-08T18:40:06.818Z
2
+ // Claude Code v2.1.138 — extracted 2026-05-09T06:52:55.909Z
3
3
  // Do not edit manually. Run: npm run generate-contracts
4
4
  export const TOOLS = new Set([
5
5
  "Agent",
@@ -311,7 +311,10 @@ export const SETTINGS_USER_FIELDS = new Set([
311
311
  "worktree",
312
312
  "wslInheritsWindowsSettings",
313
313
  ]);
314
- export const SETTINGS_PROJECT_FIELDS = new Set([
315
- "permissions",
316
- ]);
314
+ export const SETTINGS_PROJECT_FIELDS = new Set(["permissions"]);
315
+ // Hand-curated denylist of tools declared in agent frontmatter that never
316
+ // reach plugin-defined subagents at runtime. Source: tracked upstream bugs
317
+ // https://github.com/anthropics/claude-code/issues/52055
318
+ // https://github.com/anthropics/claude-code/issues/52004
319
+ export const PLUGIN_SUBAGENT_BLOCKED_TOOLS = new Set(["Glob", "Grep"]);
317
320
  //# sourceMappingURL=contracts.js.map
package/dist/discovery.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { statSync, existsSync, readFileSync } from "node:fs";
2
- import { basename, dirname, resolve, join } from "node:path";
2
+ import { basename, dirname, resolve, join, relative } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { globSync } from "tinyglobby";
5
5
  import { minimatch } from "minimatch";
6
+ import { CANONICAL_ARTIFACTS } from "./canonical-paths.js";
6
7
  const CLAUDE_USER_DIR = join(homedir(), ".claude");
7
8
  function loadIgnoreFile(dir) {
8
9
  const ignoreFile = join(dir, ".claudecode-lint-ignore");
@@ -221,8 +222,58 @@ function discoverInDirectory(dir) {
221
222
  scope: detectScope(claudeMd),
222
223
  });
223
224
  }
225
+ // Misplaced-file scan: only meaningful inside a plugin tree
226
+ // (we use `.claude-plugin/` as the marker). Outside plugin
227
+ // trees, a stray `plugin.json` / `hooks.json` could legitimately
228
+ // belong to some other tool and we don't want to false-positive.
229
+ if (existsSync(join(dir, ".claude-plugin"))) {
230
+ for (const m of findMisplacedFiles(dir)) {
231
+ artifacts.push(m);
232
+ }
233
+ }
224
234
  return artifacts;
225
235
  }
236
+ /**
237
+ * Walk `pluginRoot` looking for files whose basename is reserved
238
+ * for a Claude Code artifact and which Claude Code reads only from
239
+ * a single canonical path. Anything matching a basename but
240
+ * sitting at a non-canonical location is returned as a
241
+ * `misplaced-file` artifact for the misplaced-file linter to flag.
242
+ *
243
+ * Ignores typical noise dirs (`node_modules`, `.git`, `dist`, the
244
+ * plugin install cache's `.in_use` / `.orphaned_at` markers).
245
+ */
246
+ function findMisplacedFiles(pluginRoot) {
247
+ const out = [];
248
+ for (const entry of CANONICAL_ARTIFACTS) {
249
+ const found = globSync(`**/${entry.basename}`, {
250
+ cwd: pluginRoot,
251
+ absolute: true,
252
+ // dot: walk into dot-directories like `.claude-plugin/`
253
+ // — that's exactly where the most common misplacement
254
+ // (hooks.json under `.claude-plugin/` instead of plugin
255
+ // root's `hooks/`) lives.
256
+ dot: true,
257
+ ignore: [
258
+ "**/node_modules/**",
259
+ "**/.git/**",
260
+ "**/dist/**",
261
+ "**/.in_use/**",
262
+ "**/.orphaned_at/**",
263
+ ],
264
+ });
265
+ for (const filePath of found) {
266
+ const rel = relative(pluginRoot, filePath);
267
+ const isCanonical = entry.expectedPattern
268
+ ? minimatch(rel, entry.expectedPattern)
269
+ : rel === entry.expectedPath;
270
+ if (!isCanonical) {
271
+ out.push({ filePath, artifactType: "misplaced-file" });
272
+ }
273
+ }
274
+ }
275
+ return out;
276
+ }
226
277
  function classifyFile(filePath) {
227
278
  const name = basename(filePath);
228
279
  const parent = basename(dirname(filePath));
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
3
3
  import { resolve, relative, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { Command } from "commander";
5
+ import sade from "sade";
6
6
  import pc from "picocolors";
7
7
  import { loadConfig, mergeCliRules } from "./config.js";
8
8
  import { discoverArtifacts } from "./discovery.js";
@@ -16,6 +16,7 @@ import { hooksJsonLinter } from "./linters/hooks-json.js";
16
16
  import { settingsJsonLinter } from "./linters/settings-json.js";
17
17
  import { mcpJsonLinter } from "./linters/mcp-json.js";
18
18
  import { claudeMdLinter } from "./linters/claude-md.js";
19
+ import { misplacedFileLinter, MISPLACED_FILE_RULES, } from "./linters/misplaced-file.js";
19
20
  import { pluginJsonFixer } from "./fixers/plugin-json.js";
20
21
  import { frontmatterFixer } from "./fixers/frontmatter.js";
21
22
  import { hooksJsonFixer } from "./fixers/hooks-json.js";
@@ -39,6 +40,7 @@ const LINTERS = {
39
40
  "settings-json": settingsJsonLinter,
40
41
  "mcp-json": mcpJsonLinter,
41
42
  "claude-md": claudeMdLinter,
43
+ "misplaced-file": misplacedFileLinter,
42
44
  };
43
45
  const FIXERS = {
44
46
  "plugin-json": pluginJsonFixer,
@@ -59,6 +61,7 @@ const ALL_RULES = [
59
61
  ...SETTINGS_JSON_RULES,
60
62
  ...MCP_JSON_RULES,
61
63
  ...CLAUDE_MD_RULES,
64
+ ...MISPLACED_FILE_RULES,
62
65
  ];
63
66
  function simpleDiff(oldContent, newContent, filePath) {
64
67
  if (oldContent === newContent)
@@ -95,27 +98,30 @@ function simpleDiff(oldContent, newContent, filePath) {
95
98
  }
96
99
  return lines.join("\n");
97
100
  }
98
- const program = new Command();
99
- program
100
- .name("claudecode-linter")
101
- .description("Linter for Claude Code plugin artifacts")
102
- .version(JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version)
103
- .argument("[paths...]", "Plugin directories or individual files", ["."])
101
+ const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
102
+ sade("claudecode-linter", true)
103
+ .version(pkgVersion)
104
+ .describe("Linter for Claude Code plugin artifacts")
104
105
  .option("--lint", "Lint artifacts and report issues (default)")
105
106
  .option("--fix", "Auto-fix lint violations, then report remaining issues")
106
107
  .option("--format", "Format all artifacts for consistent style (no lint output)")
107
- .option("--output <type>", "Output format: human | json", "human")
108
- .option("--config <path>", "Config file path")
109
- .option("--scope <scope>", "Filter by scope: user | project | subdirectory")
110
- .option("--ignore <patterns>", "Comma-separated glob patterns to ignore")
108
+ .option("--output", "Output format: human | json", "human")
109
+ .option("--config", "Config file path")
110
+ .option("--scope", "Filter by scope: user | project | subdirectory")
111
+ .option("--ignore", "Comma-separated glob patterns to ignore")
111
112
  .option("--quiet", "Only show errors")
112
- .option("--enable <rules>", "Comma-separated rule IDs to enable")
113
- .option("--disable <rules>", "Comma-separated rule IDs to disable")
114
- .option("--rule <rule>", "Run only this single rule ID")
113
+ .option("--enable", "Comma-separated rule IDs to enable")
114
+ .option("--disable", "Comma-separated rule IDs to disable")
115
+ .option("--rule", "Run only this single rule ID")
115
116
  .option("--list-rules", "Print all rules with their default severity and exit")
116
117
  .option("--fix-dry-run", "Run fixers but print diff instead of writing")
117
- .option("--init [path]", "Copy default config to path (default: current directory)")
118
- .action(async (paths, opts) => {
118
+ .option("--init", "Copy default config to path (default: current directory)")
119
+ .action(async (opts) => {
120
+ // sade accepts variadic positional via opts._; default to ["."] when empty.
121
+ // Multi-word flags like --list-rules arrive in kebab-case form on opts.
122
+ const paths = Array.isArray(opts._) && opts._.length > 0 ? opts._ : ["."];
123
+ const listRules = !!opts["list-rules"];
124
+ const fixDryRun = !!opts["fix-dry-run"];
119
125
  try {
120
126
  if (opts.init !== undefined) {
121
127
  const __filename = fileURLToPath(import.meta.url);
@@ -131,7 +137,7 @@ program
131
137
  process.stdout.write(pc.green(`Created ${targetFile}\n`));
132
138
  process.exit(0);
133
139
  }
134
- if (opts.listRules) {
140
+ if (listRules) {
135
141
  const sevColor = { error: pc.red, warning: pc.yellow, info: pc.blue };
136
142
  for (const rule of ALL_RULES) {
137
143
  const color = sevColor[rule.defaultSeverity];
@@ -197,7 +203,7 @@ program
197
203
  }
198
204
  }
199
205
  }
200
- else if (opts.fixDryRun) {
206
+ else if (fixDryRun) {
201
207
  const fixer = FIXERS[artifact.artifactType];
202
208
  if (fixer) {
203
209
  const fixedContent = await fixer.fix(artifact.filePath, content, config);
@@ -231,7 +237,7 @@ program
231
237
  }
232
238
  process.exit(0);
233
239
  }
234
- if (!opts.fixDryRun) {
240
+ if (!fixDryRun) {
235
241
  const output = opts.output === "json"
236
242
  ? formatJson(results, !!opts.quiet)
237
243
  : formatHuman(results, !!opts.quiet);
@@ -244,6 +250,6 @@ program
244
250
  process.stderr.write(pc.red(`Fatal error: ${err.message}\n`));
245
251
  process.exit(2);
246
252
  }
247
- });
248
- program.parse();
253
+ })
254
+ .parse(process.argv);
249
255
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,59 @@
1
- import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS } from "../contracts.js";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { AGENT_FRONTMATTER, AGENT_MODELS, AGENT_COLORS, TOOLS, PLUGIN_SUBAGENT_BLOCKED_TOOLS, } from "../contracts.js";
2
4
  import { isRuleEnabled, getRuleSeverity } from "../types.js";
3
5
  import { parseFrontmatter } from "../utils/frontmatter.js";
6
+ function findPluginRoot(agentFilePath) {
7
+ let dir = dirname(agentFilePath);
8
+ for (let i = 0; i < 10; i++) {
9
+ if (existsSync(join(dir, ".claude-plugin", "plugin.json")))
10
+ return dir;
11
+ const parent = dirname(dir);
12
+ if (parent === dir)
13
+ break;
14
+ dir = parent;
15
+ }
16
+ return null;
17
+ }
18
+ function loadJson(p) {
19
+ try {
20
+ return JSON.parse(readFileSync(p, "utf-8"));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function loadPluginName(pluginRoot) {
27
+ const j = loadJson(join(pluginRoot, ".claude-plugin", "plugin.json"));
28
+ if (j && typeof j === "object" && j !== null && "name" in j) {
29
+ const n = j.name;
30
+ return typeof n === "string" ? n : null;
31
+ }
32
+ return null;
33
+ }
34
+ function loadMcpServerNames(pluginRoot) {
35
+ const j = loadJson(join(pluginRoot, ".mcp.json"));
36
+ const out = new Set();
37
+ if (j &&
38
+ typeof j === "object" &&
39
+ j !== null &&
40
+ "mcpServers" in j &&
41
+ typeof j.mcpServers === "object" &&
42
+ j.mcpServers !== null) {
43
+ for (const k of Object.keys(j.mcpServers)) {
44
+ out.add(k);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ function parseToolsField(v) {
50
+ if (typeof v !== "string")
51
+ return [];
52
+ return v
53
+ .split(",")
54
+ .map((s) => s.trim())
55
+ .filter(Boolean);
56
+ }
4
57
  const RULES = [
5
58
  { id: "agent-md/valid-frontmatter", defaultSeverity: "error" },
6
59
  { id: "agent-md/name-required", defaultSeverity: "error" },
@@ -9,12 +62,15 @@ const RULES = [
9
62
  { id: "agent-md/description-examples", defaultSeverity: "warning" },
10
63
  { id: "agent-md/model-required", defaultSeverity: "error" },
11
64
  { id: "agent-md/model-valid", defaultSeverity: "warning" },
12
- { id: "agent-md/color-required", defaultSeverity: "error" },
65
+ { id: "agent-md/color-required", defaultSeverity: "warning" },
13
66
  { id: "agent-md/color-valid", defaultSeverity: "warning" },
14
67
  { id: "agent-md/system-prompt-present", defaultSeverity: "error" },
15
68
  { id: "agent-md/system-prompt-length", defaultSeverity: "warning" },
16
69
  { id: "agent-md/system-prompt-second-person", defaultSeverity: "info" },
17
70
  { id: "agent-md/no-unknown-frontmatter", defaultSeverity: "info" },
71
+ { id: "agent-md/known-tools", defaultSeverity: "warning" },
72
+ { id: "agent-md/mcp-tools-resolve", defaultSeverity: "error" },
73
+ { id: "agent-md/plugin-subagent-blocked-tools", defaultSeverity: "warning" },
18
74
  ];
19
75
  function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
20
76
  if (!isRuleEnabled(config, ruleId))
@@ -47,10 +103,10 @@ export const agentMdLinter = {
47
103
  }
48
104
  else {
49
105
  const name = fm.data.name;
50
- if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name) ||
51
- name.length < 3 ||
106
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]?$/.test(name) ||
107
+ name.length < 2 ||
52
108
  name.length > 50) {
53
- push(diag(config, filePath, "agent-md/name-format", "error", `"name" must be 3-50 chars, lowercase alphanumeric + hyphens (got "${name}")`));
109
+ push(diag(config, filePath, "agent-md/name-format", "error", `"name" must be 2-50 chars, lowercase alphanumeric + hyphens (got "${name}")`));
54
110
  }
55
111
  }
56
112
  // description
@@ -73,7 +129,7 @@ export const agentMdLinter = {
73
129
  }
74
130
  // color
75
131
  if (!("color" in fm.data) || typeof fm.data.color !== "string") {
76
- push(diag(config, filePath, "agent-md/color-required", "error", '"color" is required in frontmatter'));
132
+ push(diag(config, filePath, "agent-md/color-required", "warning", '"color" is required in frontmatter'));
77
133
  }
78
134
  else if (!AGENT_COLORS.has(fm.data.color)) {
79
135
  push(diag(config, filePath, "agent-md/color-valid", "warning", `"color" must be one of: ${[...AGENT_COLORS].join(", ")} (got "${fm.data.color}")`));
@@ -98,6 +154,50 @@ export const agentMdLinter = {
98
154
  push(diag(config, filePath, "agent-md/system-prompt-second-person", "info", 'System prompt should use second person ("You are...", "You will...")', fm.bodyStartLine));
99
155
  }
100
156
  }
157
+ // tools
158
+ const declaredTools = parseToolsField(fm.data.tools);
159
+ if (declaredTools.length > 0) {
160
+ const pluginRoot = findPluginRoot(filePath);
161
+ const pluginName = pluginRoot ? loadPluginName(pluginRoot) : null;
162
+ const mcpServers = pluginRoot
163
+ ? loadMcpServerNames(pluginRoot)
164
+ : new Set();
165
+ for (const t of declaredTools) {
166
+ if (t.startsWith("mcp__")) {
167
+ // Two valid shapes:
168
+ // mcp__plugin_<plugin>_<server>__<tool> plugin-namespaced
169
+ // mcp__<server>__<tool> user-config (non-plugin)
170
+ const ns = t.match(/^mcp__plugin_([a-z0-9-]+)_([a-z0-9-]+)__(.+)$/);
171
+ const bare = t.match(/^mcp__([a-z0-9-]+)__(.+)$/);
172
+ if (ns) {
173
+ const declaredPlugin = ns[1];
174
+ const declaredServer = ns[2];
175
+ if (pluginName && declaredPlugin !== pluginName) {
176
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" references plugin "${declaredPlugin}", but this plugin is named "${pluginName}".`));
177
+ }
178
+ if (pluginRoot &&
179
+ mcpServers.size > 0 &&
180
+ !mcpServers.has(declaredServer)) {
181
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" references MCP server "${declaredServer}", but .mcp.json declares: [${[...mcpServers].join(", ")}].`));
182
+ }
183
+ }
184
+ else if (bare && pluginRoot) {
185
+ const server = bare[1];
186
+ const rest = bare[2];
187
+ if (mcpServers.has(server)) {
188
+ push(diag(config, filePath, "agent-md/mcp-tools-resolve", "error", `Tool "${t}" uses bare "mcp__<server>__<tool>" form, but this is a plugin (${pluginName ?? "unknown name"}). Plugin agents must use the namespaced form: "mcp__plugin_${pluginName ?? "<plugin>"}_${server}__${rest}".`));
189
+ }
190
+ }
191
+ continue;
192
+ }
193
+ if (!TOOLS.has(t)) {
194
+ push(diag(config, filePath, "agent-md/known-tools", "warning", `Unknown built-in tool "${t}" in tools: field. Valid: ${[...TOOLS].sort().join(", ")}.`));
195
+ }
196
+ if (pluginRoot && PLUGIN_SUBAGENT_BLOCKED_TOOLS.has(t)) {
197
+ push(diag(config, filePath, "agent-md/plugin-subagent-blocked-tools", "warning", `Tool "${t}" is declared but does NOT reach plugin subagents at runtime — known Claude Code bug (https://github.com/anthropics/claude-code/issues/52055). The agent will silently lack this tool. For local file ops, use Bash with rg/find, or dispatch the built-in Explore subagent.`));
198
+ }
199
+ }
200
+ }
101
201
  return diagnostics;
102
202
  },
103
203
  };
@@ -0,0 +1,45 @@
1
+ import { basename } from "node:path";
2
+ import { CANONICAL_ARTIFACTS } from "../canonical-paths.js";
3
+ import { isRuleEnabled, getRuleSeverity } from "../types.js";
4
+ export const MISPLACED_FILE_RULES = [
5
+ { id: "misplaced-file/canonical-location", defaultSeverity: "warning" },
6
+ ];
7
+ /**
8
+ * Flag files whose basename is reserved for a Claude Code artifact
9
+ * but which sit at a non-canonical path. Claude Code reads each
10
+ * artifact only from its canonical location and silently ignores
11
+ * copies elsewhere, so this class of mistake is easy to make and
12
+ * hard to debug — `/reload-plugins` reports `0 hooks` (or skills,
13
+ * etc.) with no other signal.
14
+ *
15
+ * Discovery (see discovery.ts) populates this linter's inputs by
16
+ * walking plugin trees and matching basenames; this linter just
17
+ * emits one diagnostic per misplaced file with the expected
18
+ * location.
19
+ */
20
+ export const misplacedFileLinter = {
21
+ artifactType: "misplaced-file",
22
+ lint(filePath, _content, config) {
23
+ const ruleId = "misplaced-file/canonical-location";
24
+ if (!isRuleEnabled(config, ruleId))
25
+ return [];
26
+ const base = basename(filePath);
27
+ const entry = CANONICAL_ARTIFACTS.find((a) => a.basename === base);
28
+ if (!entry)
29
+ return [];
30
+ const expected = entry.expectedPath ?? entry.expectedPattern ?? "";
31
+ return [
32
+ {
33
+ rule: ruleId,
34
+ severity: getRuleSeverity(config, ruleId, "warning"),
35
+ message: `${base} is at a non-canonical path; ` +
36
+ `Claude Code reads ${entry.description} only from ` +
37
+ `\`${expected}\` (relative to plugin root) and ` +
38
+ `silently ignores copies elsewhere. ` +
39
+ `Move the file there or rename it.`,
40
+ file: filePath,
41
+ },
42
+ ];
43
+ },
44
+ };
45
+ //# sourceMappingURL=misplaced-file.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudecode-linter",
3
- "version": "2.1.136",
3
+ "version": "2.1.138-patch.1",
4
4
  "description": "Standalone linter for Claude Code plugins and configuration files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,10 +45,10 @@
45
45
  "node": ">=18"
46
46
  },
47
47
  "dependencies": {
48
- "commander": "^14.0.3",
49
48
  "minimatch": "^10.2.4",
50
49
  "picocolors": "^1.1.1",
51
50
  "prettier": "^3.8.1",
51
+ "sade": "^1.8.1",
52
52
  "semver": "^7.7.4",
53
53
  "tinyglobby": "^0.2.15",
54
54
  "yaml": "^2.8.3"