claudecode-linter 2.1.137 → 2.1.138-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.
- package/.claudecode-lint.defaults.yaml +25 -0
- package/README.md +20 -2
- package/contracts/lsp.schema.json +82 -0
- package/contracts/monitors.schema.json +48 -0
- package/contracts/plugin.schema.json +1319 -0
- package/dist/canonical-paths.js +43 -0
- package/dist/contracts.js +7 -4
- package/dist/discovery.js +69 -1
- package/dist/index.js +35 -21
- package/dist/linters/agent-md.js +106 -6
- package/dist/linters/lsp-json.js +94 -0
- package/dist/linters/misplaced-file.js +45 -0
- package/dist/linters/monitors-json.js +85 -0
- package/dist/linters/plugin-json.js +34 -5
- package/dist/plugin-schema.js +222 -0
- package/package.json +9 -3
|
@@ -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.
|
|
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
|
-
|
|
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");
|
|
@@ -172,6 +173,19 @@ function discoverInDirectory(dir) {
|
|
|
172
173
|
for (const f of hooks) {
|
|
173
174
|
artifacts.push({ filePath: f, artifactType: "hooks-json" });
|
|
174
175
|
}
|
|
176
|
+
// .lsp.json (flat record at plugin root; Claude Code reads from <plugin>/.lsp.json)
|
|
177
|
+
const lspDot = join(dir, ".lsp.json");
|
|
178
|
+
if (existsSync(lspDot)) {
|
|
179
|
+
artifacts.push({ filePath: lspDot, artifactType: "lsp-json" });
|
|
180
|
+
}
|
|
181
|
+
// monitors/monitors.json
|
|
182
|
+
const monitors = globSync("monitors/monitors.json", {
|
|
183
|
+
cwd: dir,
|
|
184
|
+
absolute: true,
|
|
185
|
+
});
|
|
186
|
+
for (const f of monitors) {
|
|
187
|
+
artifacts.push({ filePath: f, artifactType: "monitors-json" });
|
|
188
|
+
}
|
|
175
189
|
// Claude config files — settings
|
|
176
190
|
for (const name of ["settings.json", "settings.local.json"]) {
|
|
177
191
|
// Direct in dir (handles both ~/.claude/settings.json and project root)
|
|
@@ -221,8 +235,58 @@ function discoverInDirectory(dir) {
|
|
|
221
235
|
scope: detectScope(claudeMd),
|
|
222
236
|
});
|
|
223
237
|
}
|
|
238
|
+
// Misplaced-file scan: only meaningful inside a plugin tree
|
|
239
|
+
// (we use `.claude-plugin/` as the marker). Outside plugin
|
|
240
|
+
// trees, a stray `plugin.json` / `hooks.json` could legitimately
|
|
241
|
+
// belong to some other tool and we don't want to false-positive.
|
|
242
|
+
if (existsSync(join(dir, ".claude-plugin"))) {
|
|
243
|
+
for (const m of findMisplacedFiles(dir)) {
|
|
244
|
+
artifacts.push(m);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
224
247
|
return artifacts;
|
|
225
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Walk `pluginRoot` looking for files whose basename is reserved
|
|
251
|
+
* for a Claude Code artifact and which Claude Code reads only from
|
|
252
|
+
* a single canonical path. Anything matching a basename but
|
|
253
|
+
* sitting at a non-canonical location is returned as a
|
|
254
|
+
* `misplaced-file` artifact for the misplaced-file linter to flag.
|
|
255
|
+
*
|
|
256
|
+
* Ignores typical noise dirs (`node_modules`, `.git`, `dist`, the
|
|
257
|
+
* plugin install cache's `.in_use` / `.orphaned_at` markers).
|
|
258
|
+
*/
|
|
259
|
+
function findMisplacedFiles(pluginRoot) {
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const entry of CANONICAL_ARTIFACTS) {
|
|
262
|
+
const found = globSync(`**/${entry.basename}`, {
|
|
263
|
+
cwd: pluginRoot,
|
|
264
|
+
absolute: true,
|
|
265
|
+
// dot: walk into dot-directories like `.claude-plugin/`
|
|
266
|
+
// — that's exactly where the most common misplacement
|
|
267
|
+
// (hooks.json under `.claude-plugin/` instead of plugin
|
|
268
|
+
// root's `hooks/`) lives.
|
|
269
|
+
dot: true,
|
|
270
|
+
ignore: [
|
|
271
|
+
"**/node_modules/**",
|
|
272
|
+
"**/.git/**",
|
|
273
|
+
"**/dist/**",
|
|
274
|
+
"**/.in_use/**",
|
|
275
|
+
"**/.orphaned_at/**",
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
for (const filePath of found) {
|
|
279
|
+
const rel = relative(pluginRoot, filePath);
|
|
280
|
+
const isCanonical = entry.expectedPattern
|
|
281
|
+
? minimatch(rel, entry.expectedPattern)
|
|
282
|
+
: rel === entry.expectedPath;
|
|
283
|
+
if (!isCanonical) {
|
|
284
|
+
out.push({ filePath, artifactType: "misplaced-file" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
226
290
|
function classifyFile(filePath) {
|
|
227
291
|
const name = basename(filePath);
|
|
228
292
|
const parent = basename(dirname(filePath));
|
|
@@ -232,6 +296,10 @@ function classifyFile(filePath) {
|
|
|
232
296
|
return "skill-md";
|
|
233
297
|
if (name === "hooks.json" && parent === "hooks")
|
|
234
298
|
return "hooks-json";
|
|
299
|
+
if (name === ".lsp.json")
|
|
300
|
+
return "lsp-json";
|
|
301
|
+
if (name === "monitors.json" && parent === "monitors")
|
|
302
|
+
return "monitors-json";
|
|
235
303
|
if (name.endsWith(".md") && parent === "agents")
|
|
236
304
|
return "agent-md";
|
|
237
305
|
if (name.endsWith(".md") && parent === "commands")
|
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
|
|
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,9 @@ 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 { lspJsonLinter } from "./linters/lsp-json.js";
|
|
20
|
+
import { monitorsJsonLinter } from "./linters/monitors-json.js";
|
|
21
|
+
import { misplacedFileLinter, MISPLACED_FILE_RULES, } from "./linters/misplaced-file.js";
|
|
19
22
|
import { pluginJsonFixer } from "./fixers/plugin-json.js";
|
|
20
23
|
import { frontmatterFixer } from "./fixers/frontmatter.js";
|
|
21
24
|
import { hooksJsonFixer } from "./fixers/hooks-json.js";
|
|
@@ -30,6 +33,8 @@ import { HOOKS_JSON_RULES } from "./linters/hooks-json.js";
|
|
|
30
33
|
import { SETTINGS_JSON_RULES } from "./linters/settings-json.js";
|
|
31
34
|
import { MCP_JSON_RULES } from "./linters/mcp-json.js";
|
|
32
35
|
import { CLAUDE_MD_RULES } from "./linters/claude-md.js";
|
|
36
|
+
import { LSP_JSON_RULES } from "./linters/lsp-json.js";
|
|
37
|
+
import { MONITORS_JSON_RULES } from "./linters/monitors-json.js";
|
|
33
38
|
const LINTERS = {
|
|
34
39
|
"plugin-json": pluginJsonLinter,
|
|
35
40
|
"skill-md": skillMdLinter,
|
|
@@ -39,6 +44,9 @@ const LINTERS = {
|
|
|
39
44
|
"settings-json": settingsJsonLinter,
|
|
40
45
|
"mcp-json": mcpJsonLinter,
|
|
41
46
|
"claude-md": claudeMdLinter,
|
|
47
|
+
"lsp-json": lspJsonLinter,
|
|
48
|
+
"monitors-json": monitorsJsonLinter,
|
|
49
|
+
"misplaced-file": misplacedFileLinter,
|
|
42
50
|
};
|
|
43
51
|
const FIXERS = {
|
|
44
52
|
"plugin-json": pluginJsonFixer,
|
|
@@ -59,6 +67,9 @@ const ALL_RULES = [
|
|
|
59
67
|
...SETTINGS_JSON_RULES,
|
|
60
68
|
...MCP_JSON_RULES,
|
|
61
69
|
...CLAUDE_MD_RULES,
|
|
70
|
+
...LSP_JSON_RULES,
|
|
71
|
+
...MONITORS_JSON_RULES,
|
|
72
|
+
...MISPLACED_FILE_RULES,
|
|
62
73
|
];
|
|
63
74
|
function simpleDiff(oldContent, newContent, filePath) {
|
|
64
75
|
if (oldContent === newContent)
|
|
@@ -95,27 +106,30 @@ function simpleDiff(oldContent, newContent, filePath) {
|
|
|
95
106
|
}
|
|
96
107
|
return lines.join("\n");
|
|
97
108
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
.version(JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version)
|
|
103
|
-
.argument("[paths...]", "Plugin directories or individual files", ["."])
|
|
109
|
+
const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
110
|
+
sade("claudecode-linter", true)
|
|
111
|
+
.version(pkgVersion)
|
|
112
|
+
.describe("Linter for Claude Code plugin artifacts")
|
|
104
113
|
.option("--lint", "Lint artifacts and report issues (default)")
|
|
105
114
|
.option("--fix", "Auto-fix lint violations, then report remaining issues")
|
|
106
115
|
.option("--format", "Format all artifacts for consistent style (no lint output)")
|
|
107
|
-
.option("--output
|
|
108
|
-
.option("--config
|
|
109
|
-
.option("--scope
|
|
110
|
-
.option("--ignore
|
|
116
|
+
.option("--output", "Output format: human | json", "human")
|
|
117
|
+
.option("--config", "Config file path")
|
|
118
|
+
.option("--scope", "Filter by scope: user | project | subdirectory")
|
|
119
|
+
.option("--ignore", "Comma-separated glob patterns to ignore")
|
|
111
120
|
.option("--quiet", "Only show errors")
|
|
112
|
-
.option("--enable
|
|
113
|
-
.option("--disable
|
|
114
|
-
.option("--rule
|
|
121
|
+
.option("--enable", "Comma-separated rule IDs to enable")
|
|
122
|
+
.option("--disable", "Comma-separated rule IDs to disable")
|
|
123
|
+
.option("--rule", "Run only this single rule ID")
|
|
115
124
|
.option("--list-rules", "Print all rules with their default severity and exit")
|
|
116
125
|
.option("--fix-dry-run", "Run fixers but print diff instead of writing")
|
|
117
|
-
.option("--init
|
|
118
|
-
.action(async (
|
|
126
|
+
.option("--init", "Copy default config to path (default: current directory)")
|
|
127
|
+
.action(async (opts) => {
|
|
128
|
+
// sade accepts variadic positional via opts._; default to ["."] when empty.
|
|
129
|
+
// Multi-word flags like --list-rules arrive in kebab-case form on opts.
|
|
130
|
+
const paths = Array.isArray(opts._) && opts._.length > 0 ? opts._ : ["."];
|
|
131
|
+
const listRules = !!opts["list-rules"];
|
|
132
|
+
const fixDryRun = !!opts["fix-dry-run"];
|
|
119
133
|
try {
|
|
120
134
|
if (opts.init !== undefined) {
|
|
121
135
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -131,7 +145,7 @@ program
|
|
|
131
145
|
process.stdout.write(pc.green(`Created ${targetFile}\n`));
|
|
132
146
|
process.exit(0);
|
|
133
147
|
}
|
|
134
|
-
if (
|
|
148
|
+
if (listRules) {
|
|
135
149
|
const sevColor = { error: pc.red, warning: pc.yellow, info: pc.blue };
|
|
136
150
|
for (const rule of ALL_RULES) {
|
|
137
151
|
const color = sevColor[rule.defaultSeverity];
|
|
@@ -197,7 +211,7 @@ program
|
|
|
197
211
|
}
|
|
198
212
|
}
|
|
199
213
|
}
|
|
200
|
-
else if (
|
|
214
|
+
else if (fixDryRun) {
|
|
201
215
|
const fixer = FIXERS[artifact.artifactType];
|
|
202
216
|
if (fixer) {
|
|
203
217
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
@@ -231,7 +245,7 @@ program
|
|
|
231
245
|
}
|
|
232
246
|
process.exit(0);
|
|
233
247
|
}
|
|
234
|
-
if (!
|
|
248
|
+
if (!fixDryRun) {
|
|
235
249
|
const output = opts.output === "json"
|
|
236
250
|
? formatJson(results, !!opts.quiet)
|
|
237
251
|
: formatHuman(results, !!opts.quiet);
|
|
@@ -244,6 +258,6 @@ program
|
|
|
244
258
|
process.stderr.write(pc.red(`Fatal error: ${err.message}\n`));
|
|
245
259
|
process.exit(2);
|
|
246
260
|
}
|
|
247
|
-
})
|
|
248
|
-
|
|
261
|
+
})
|
|
262
|
+
.parse(process.argv);
|
|
249
263
|
//# sourceMappingURL=index.js.map
|
package/dist/linters/agent-md.js
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
|
-
import {
|
|
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: "
|
|
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]
|
|
51
|
-
name.length <
|
|
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
|
|
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", "
|
|
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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lints standalone `.lsp.json` files (a flat map of server-name → LSP server
|
|
3
|
+
* config). Validates against the schema auto-extracted from Claude Code's
|
|
4
|
+
* runtime parser `E.record(E.string(), RSH()).safeParse(content)` — see
|
|
5
|
+
* scripts/extract-plugin-schema.ts → buildLspSchema().
|
|
6
|
+
*
|
|
7
|
+
* The most common mistake (observed in cluster plugin 0.29.0 → 0.30.3) is
|
|
8
|
+
* wrapping the contents under a top-level `lspServers` key. That wrapper only
|
|
9
|
+
* belongs inside plugin.json; a dedicated `.lsp.json` file must be flat.
|
|
10
|
+
* lsp-json/no-lsp-servers-wrapper catches this with a friendlier message
|
|
11
|
+
* than the generic "missing required field command" Ajv would otherwise emit.
|
|
12
|
+
*/
|
|
13
|
+
import { formatAjvError, loadLspSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
14
|
+
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
15
|
+
const RULES = [
|
|
16
|
+
{ id: "lsp-json/valid-json", defaultSeverity: "error" },
|
|
17
|
+
{ id: "lsp-json/no-lsp-servers-wrapper", defaultSeverity: "error" },
|
|
18
|
+
{ id: "lsp-json/schema-valid", defaultSeverity: "error" },
|
|
19
|
+
];
|
|
20
|
+
function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
|
|
21
|
+
if (!isRuleEnabled(config, ruleId))
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
rule: ruleId,
|
|
25
|
+
severity: getRuleSeverity(config, ruleId, defaultSeverity),
|
|
26
|
+
message,
|
|
27
|
+
file: filePath,
|
|
28
|
+
line,
|
|
29
|
+
column,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function findKeyPosition(content, key) {
|
|
33
|
+
const re = new RegExp(`"${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
|
|
34
|
+
const match = re.exec(content);
|
|
35
|
+
if (!match)
|
|
36
|
+
return undefined;
|
|
37
|
+
const before = content.slice(0, match.index);
|
|
38
|
+
const line = before.split("\n").length;
|
|
39
|
+
const lastNl = before.lastIndexOf("\n");
|
|
40
|
+
const column = match.index - lastNl;
|
|
41
|
+
return { line, column };
|
|
42
|
+
}
|
|
43
|
+
export const lspJsonLinter = {
|
|
44
|
+
artifactType: "lsp-json",
|
|
45
|
+
lint(filePath, content, config) {
|
|
46
|
+
const diagnostics = [];
|
|
47
|
+
const push = (d) => {
|
|
48
|
+
if (d)
|
|
49
|
+
diagnostics.push(d);
|
|
50
|
+
};
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(content);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
push(diag(config, filePath, "lsp-json/valid-json", "error", `Invalid JSON: ${e.message}`));
|
|
57
|
+
return diagnostics;
|
|
58
|
+
}
|
|
59
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
60
|
+
push(diag(config, filePath, "lsp-json/valid-json", "error", ".lsp.json must be a JSON object (map of server-name → config)"));
|
|
61
|
+
return diagnostics;
|
|
62
|
+
}
|
|
63
|
+
const obj = parsed;
|
|
64
|
+
// Special-case the cluster-plugin bug: wrapping content under "lspServers".
|
|
65
|
+
// That key is only valid inline in plugin.json. The dedicated file uses a
|
|
66
|
+
// flat map, so an "lspServers" wrapper is silently mis-validated by Claude
|
|
67
|
+
// Code at session start (each server entry fails RSH validation since it
|
|
68
|
+
// looks like one server config with sub-server keys).
|
|
69
|
+
if ("lspServers" in obj) {
|
|
70
|
+
const p = findKeyPosition(content, "lspServers");
|
|
71
|
+
push(diag(config, filePath, "lsp-json/no-lsp-servers-wrapper", "error", '.lsp.json must not have a top-level "lspServers" key — the file is itself the map of server-name → config. The "lspServers" wrapper only belongs in plugin.json under the lspServers field.', p?.line, p?.column));
|
|
72
|
+
}
|
|
73
|
+
// Schema validation — defers to the extracted Zod-equivalent JSON Schema.
|
|
74
|
+
if (isRuleEnabled(config, "lsp-json/schema-valid")) {
|
|
75
|
+
const ctx = loadLspSchema();
|
|
76
|
+
if (ctx) {
|
|
77
|
+
const ok = ctx.validate(parsed);
|
|
78
|
+
if (!ok && ctx.validate.errors) {
|
|
79
|
+
const filtered = summarizeErrors(ctx.validate.errors);
|
|
80
|
+
for (const err of filtered) {
|
|
81
|
+
const firstSeg = err.instancePath
|
|
82
|
+
.split("/")
|
|
83
|
+
.filter(Boolean)[0];
|
|
84
|
+
const p = firstSeg ? findKeyPosition(content, firstSeg) : undefined;
|
|
85
|
+
push(diag(config, filePath, "lsp-json/schema-valid", "error", formatAjvError(err), p?.line, p?.column));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return diagnostics;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
export { RULES as LSP_JSON_RULES };
|
|
94
|
+
//# sourceMappingURL=lsp-json.js.map
|
|
@@ -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
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lints standalone `monitors/monitors.json` files (an array of monitor
|
|
3
|
+
* entries). Validates against the schema auto-extracted from Claude Code's
|
|
4
|
+
* runtime parser `vC8().parse(content)` — see scripts/extract-plugin-schema.ts
|
|
5
|
+
* → buildMonitorsSchema().
|
|
6
|
+
*
|
|
7
|
+
* monitors-json/unique-names mirrors Claude Code's refine() check that
|
|
8
|
+
* `Monitor names must be unique within a plugin`. JSON Schema can't express
|
|
9
|
+
* that constraint natively so we enforce it here.
|
|
10
|
+
*/
|
|
11
|
+
import { formatAjvError, loadMonitorsSchema, summarizeErrors, } from "../plugin-schema.js";
|
|
12
|
+
import { isRuleEnabled, getRuleSeverity } from "../types.js";
|
|
13
|
+
const RULES = [
|
|
14
|
+
{ id: "monitors-json/valid-json", defaultSeverity: "error" },
|
|
15
|
+
{ id: "monitors-json/schema-valid", defaultSeverity: "error" },
|
|
16
|
+
{ id: "monitors-json/unique-names", defaultSeverity: "error" },
|
|
17
|
+
];
|
|
18
|
+
function diag(config, filePath, ruleId, defaultSeverity, message, line, column) {
|
|
19
|
+
if (!isRuleEnabled(config, ruleId))
|
|
20
|
+
return null;
|
|
21
|
+
return {
|
|
22
|
+
rule: ruleId,
|
|
23
|
+
severity: getRuleSeverity(config, ruleId, defaultSeverity),
|
|
24
|
+
message,
|
|
25
|
+
file: filePath,
|
|
26
|
+
line,
|
|
27
|
+
column,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export const monitorsJsonLinter = {
|
|
31
|
+
artifactType: "monitors-json",
|
|
32
|
+
lint(filePath, content, config) {
|
|
33
|
+
const diagnostics = [];
|
|
34
|
+
const push = (d) => {
|
|
35
|
+
if (d)
|
|
36
|
+
diagnostics.push(d);
|
|
37
|
+
};
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(content);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
push(diag(config, filePath, "monitors-json/valid-json", "error", `Invalid JSON: ${e.message}`));
|
|
44
|
+
return diagnostics;
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(parsed)) {
|
|
47
|
+
push(diag(config, filePath, "monitors-json/valid-json", "error", "monitors.json must be a JSON array of monitor entries"));
|
|
48
|
+
return diagnostics;
|
|
49
|
+
}
|
|
50
|
+
// Schema validation
|
|
51
|
+
if (isRuleEnabled(config, "monitors-json/schema-valid")) {
|
|
52
|
+
const ctx = loadMonitorsSchema();
|
|
53
|
+
if (ctx) {
|
|
54
|
+
const ok = ctx.validate(parsed);
|
|
55
|
+
if (!ok && ctx.validate.errors) {
|
|
56
|
+
const filtered = summarizeErrors(ctx.validate.errors);
|
|
57
|
+
for (const err of filtered) {
|
|
58
|
+
push(diag(config, filePath, "monitors-json/schema-valid", "error", formatAjvError(err)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Unique-name refinement — Claude Code's refine() check.
|
|
64
|
+
if (isRuleEnabled(config, "monitors-json/unique-names")) {
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const duplicates = new Set();
|
|
67
|
+
for (const entry of parsed) {
|
|
68
|
+
if (entry &&
|
|
69
|
+
typeof entry === "object" &&
|
|
70
|
+
typeof entry.name === "string") {
|
|
71
|
+
const name = entry.name;
|
|
72
|
+
if (seen.has(name))
|
|
73
|
+
duplicates.add(name);
|
|
74
|
+
seen.add(name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const dup of duplicates) {
|
|
78
|
+
push(diag(config, filePath, "monitors-json/unique-names", "error", `Monitor name "${dup}" is duplicated. Monitor names must be unique within a plugin.`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return diagnostics;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
export { RULES as MONITORS_JSON_RULES };
|
|
85
|
+
//# sourceMappingURL=monitors-json.js.map
|