@millstone/synapse-cli 0.1.0
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/README.md +135 -0
- package/bin/synapse.js +3 -0
- package/dist/commands/eject.d.ts +19 -0
- package/dist/commands/eject.d.ts.map +1 -0
- package/dist/commands/eject.js +146 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/fetch-reference.d.ts +19 -0
- package/dist/commands/fetch-reference.d.ts.map +1 -0
- package/dist/commands/fetch-reference.js +93 -0
- package/dist/commands/fetch-reference.js.map +1 -0
- package/dist/commands/format.d.ts +26 -0
- package/dist/commands/format.d.ts.map +1 -0
- package/dist/commands/format.js +126 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/generate-pdf.d.ts +19 -0
- package/dist/commands/generate-pdf.d.ts.map +1 -0
- package/dist/commands/generate-pdf.js +140 -0
- package/dist/commands/generate-pdf.js.map +1 -0
- package/dist/commands/index.d.ts +17 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/init.d.ts +58 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +234 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/migrate.d.ts +29 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +297 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/scaffold.d.ts +24 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +244 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/update.d.ts +25 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +253 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/validate.d.ts +37 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +526 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +277 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/bodyRules.d.ts +70 -0
- package/dist/lib/bodyRules.d.ts.map +1 -0
- package/dist/lib/bodyRules.js +711 -0
- package/dist/lib/bodyRules.js.map +1 -0
- package/dist/lib/config.d.ts +49 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +91 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/git.d.ts +99 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +266 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/graph.d.ts +6 -0
- package/dist/lib/graph.d.ts.map +1 -0
- package/dist/lib/graph.js +6 -0
- package/dist/lib/graph.js.map +1 -0
- package/dist/lib/homepage.d.ts +10 -0
- package/dist/lib/homepage.d.ts.map +1 -0
- package/dist/lib/homepage.js +172 -0
- package/dist/lib/homepage.js.map +1 -0
- package/dist/lib/markdown.d.ts +107 -0
- package/dist/lib/markdown.d.ts.map +1 -0
- package/dist/lib/markdown.js +318 -0
- package/dist/lib/markdown.js.map +1 -0
- package/dist/lib/mode-detection.d.ts +10 -0
- package/dist/lib/mode-detection.d.ts.map +1 -0
- package/dist/lib/mode-detection.js +29 -0
- package/dist/lib/mode-detection.js.map +1 -0
- package/dist/lib/naming.d.ts +47 -0
- package/dist/lib/naming.d.ts.map +1 -0
- package/dist/lib/naming.js +403 -0
- package/dist/lib/naming.js.map +1 -0
- package/dist/lib/schemas.d.ts +38 -0
- package/dist/lib/schemas.d.ts.map +1 -0
- package/dist/lib/schemas.js +248 -0
- package/dist/lib/schemas.js.map +1 -0
- package/dist/lib/templateLint.d.ts +21 -0
- package/dist/lib/templateLint.d.ts.map +1 -0
- package/dist/lib/templateLint.js +243 -0
- package/dist/lib/templateLint.js.map +1 -0
- package/dist/lib/templates.d.ts +53 -0
- package/dist/lib/templates.d.ts.map +1 -0
- package/dist/lib/templates.js +128 -0
- package/dist/lib/templates.js.map +1 -0
- package/dist/lib/tracking.d.ts +52 -0
- package/dist/lib/tracking.d.ts.map +1 -0
- package/dist/lib/tracking.js +135 -0
- package/dist/lib/tracking.js.map +1 -0
- package/dist/lib/types.generated.d.ts +54 -0
- package/dist/lib/types.generated.d.ts.map +1 -0
- package/dist/lib/types.generated.js +144 -0
- package/dist/lib/types.generated.js.map +1 -0
- package/dist/lib/validate-plugins.d.ts +22 -0
- package/dist/lib/validate-plugins.d.ts.map +1 -0
- package/dist/lib/validate-plugins.js +851 -0
- package/dist/lib/validate-plugins.js.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import glob from "fast-glob";
|
|
4
|
+
import Ajv from "ajv";
|
|
5
|
+
import addFormats from "ajv-formats";
|
|
6
|
+
import { parseDocument } from "./markdown.js";
|
|
7
|
+
const fs = fsExtra;
|
|
8
|
+
// Valid tool names for agents and skills
|
|
9
|
+
const VALID_TOOLS = [
|
|
10
|
+
"Read",
|
|
11
|
+
"Write",
|
|
12
|
+
"Edit",
|
|
13
|
+
"Bash",
|
|
14
|
+
"Grep",
|
|
15
|
+
"Glob",
|
|
16
|
+
"Task",
|
|
17
|
+
"WebFetch",
|
|
18
|
+
"WebSearch",
|
|
19
|
+
"BashOutput",
|
|
20
|
+
"KillShell",
|
|
21
|
+
"Skill",
|
|
22
|
+
"SlashCommand",
|
|
23
|
+
"MultiEdit",
|
|
24
|
+
"LS",
|
|
25
|
+
"TodoWrite",
|
|
26
|
+
"NotebookEdit",
|
|
27
|
+
"AskUserQuestion",
|
|
28
|
+
"ExitPlanMode",
|
|
29
|
+
];
|
|
30
|
+
// Valid hook types
|
|
31
|
+
const VALID_HOOK_TYPES = [
|
|
32
|
+
"pre-commit",
|
|
33
|
+
"post-commit",
|
|
34
|
+
"pre-push",
|
|
35
|
+
"post-push",
|
|
36
|
+
"user-prompt-submit",
|
|
37
|
+
"tool-call",
|
|
38
|
+
"error",
|
|
39
|
+
];
|
|
40
|
+
// Valid model types
|
|
41
|
+
const VALID_MODELS = ["sonnet", "opus", "haiku"];
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Schema Validation Utilities
|
|
44
|
+
// ============================================================================
|
|
45
|
+
async function loadJsonSchema(schemaPath) {
|
|
46
|
+
try {
|
|
47
|
+
const content = await fs.readFile(schemaPath, "utf-8");
|
|
48
|
+
return JSON.parse(content);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new Error(`Failed to load schema from ${schemaPath}: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateAgainstSchema(data, schema, filePath) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
const ajv = new Ajv({ allErrors: true, strict: false, validateSchema: false });
|
|
57
|
+
addFormats(ajv);
|
|
58
|
+
const validate = ajv.compile(schema);
|
|
59
|
+
const valid = validate(data);
|
|
60
|
+
if (!valid && validate.errors) {
|
|
61
|
+
for (const error of validate.errors) {
|
|
62
|
+
const field = error.instancePath
|
|
63
|
+
? error.instancePath.substring(1).replace(/\//g, ".")
|
|
64
|
+
: error.params.missingProperty || "root";
|
|
65
|
+
let message = "";
|
|
66
|
+
switch (error.keyword) {
|
|
67
|
+
case "required":
|
|
68
|
+
message = `Missing required field "${error.params.missingProperty}"`;
|
|
69
|
+
break;
|
|
70
|
+
case "pattern":
|
|
71
|
+
message = `Field "${field}" does not match required pattern ${error.params.pattern}`;
|
|
72
|
+
break;
|
|
73
|
+
case "type":
|
|
74
|
+
message = `Field "${field}" should be ${error.params.type}`;
|
|
75
|
+
break;
|
|
76
|
+
case "minLength":
|
|
77
|
+
message = `Field "${field}" is too short (minimum ${error.params.limit} characters)`;
|
|
78
|
+
break;
|
|
79
|
+
case "maxLength":
|
|
80
|
+
message = `Field "${field}" is too long (maximum ${error.params.limit} characters)`;
|
|
81
|
+
break;
|
|
82
|
+
case "format":
|
|
83
|
+
message = `Field "${field}" has invalid format (expected ${error.params.format})`;
|
|
84
|
+
break;
|
|
85
|
+
case "enum":
|
|
86
|
+
message = `Field "${field}" must be one of: ${error.params.allowedValues.join(", ")}`;
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
message = error.message || "Unknown validation error";
|
|
90
|
+
}
|
|
91
|
+
issues.push({
|
|
92
|
+
type: "error",
|
|
93
|
+
code: `SCHEMA_${error.keyword.toUpperCase()}`,
|
|
94
|
+
message,
|
|
95
|
+
file: filePath,
|
|
96
|
+
field,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return issues;
|
|
101
|
+
}
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Kebab-case Validation
|
|
104
|
+
// ============================================================================
|
|
105
|
+
function isKebabCase(str) {
|
|
106
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(str);
|
|
107
|
+
}
|
|
108
|
+
function toKebabCase(str) {
|
|
109
|
+
return str
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.trim()
|
|
112
|
+
.replace(/[^\w\s-]/g, "")
|
|
113
|
+
.replace(/\s+/g, "-")
|
|
114
|
+
.replace(/_/g, "-")
|
|
115
|
+
.replace(/-+/g, "-")
|
|
116
|
+
.replace(/^-+|-+$/g, "");
|
|
117
|
+
}
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Marketplace Manifest Validation
|
|
120
|
+
// ============================================================================
|
|
121
|
+
async function validateMarketplaceManifest(marketplacePath, schemaDir) {
|
|
122
|
+
const issues = [];
|
|
123
|
+
// Check file exists
|
|
124
|
+
if (!(await fs.pathExists(marketplacePath))) {
|
|
125
|
+
issues.push({
|
|
126
|
+
type: "error",
|
|
127
|
+
code: "MARKETPLACE_NOT_FOUND",
|
|
128
|
+
message: "Marketplace manifest not found",
|
|
129
|
+
file: marketplacePath,
|
|
130
|
+
});
|
|
131
|
+
return issues;
|
|
132
|
+
}
|
|
133
|
+
// Parse JSON
|
|
134
|
+
let manifest;
|
|
135
|
+
try {
|
|
136
|
+
const content = await fs.readFile(marketplacePath, "utf-8");
|
|
137
|
+
manifest = JSON.parse(content);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
issues.push({
|
|
141
|
+
type: "error",
|
|
142
|
+
code: "MARKETPLACE_INVALID_JSON",
|
|
143
|
+
message: `Invalid JSON: ${error}`,
|
|
144
|
+
file: marketplacePath,
|
|
145
|
+
});
|
|
146
|
+
return issues;
|
|
147
|
+
}
|
|
148
|
+
// Schema validation
|
|
149
|
+
const schemaPath = path.join(schemaDir, "marketplace.schema.json");
|
|
150
|
+
if (await fs.pathExists(schemaPath)) {
|
|
151
|
+
const schema = await loadJsonSchema(schemaPath);
|
|
152
|
+
const schemaIssues = validateAgainstSchema(manifest, schema, marketplacePath);
|
|
153
|
+
issues.push(...schemaIssues);
|
|
154
|
+
}
|
|
155
|
+
// Check plugin sources exist
|
|
156
|
+
// Plugin sources are relative to project root (parent of .claude-plugin/)
|
|
157
|
+
const projectRoot = path.dirname(path.dirname(marketplacePath));
|
|
158
|
+
for (const plugin of manifest.plugins || []) {
|
|
159
|
+
const pluginPath = path.join(projectRoot, plugin.source.replace(/^\.\//, ""));
|
|
160
|
+
if (!(await fs.pathExists(pluginPath))) {
|
|
161
|
+
issues.push({
|
|
162
|
+
type: "error",
|
|
163
|
+
code: "PLUGIN_SOURCE_NOT_FOUND",
|
|
164
|
+
message: `Plugin source path does not exist: ${plugin.source}`,
|
|
165
|
+
file: marketplacePath,
|
|
166
|
+
field: `plugins[${manifest.plugins.indexOf(plugin)}].source`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return issues;
|
|
171
|
+
}
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Plugin Manifest Validation
|
|
174
|
+
// ============================================================================
|
|
175
|
+
async function validatePluginManifest(pluginDir, expectedName, schemaDir) {
|
|
176
|
+
const issues = [];
|
|
177
|
+
const manifestPath = path.join(pluginDir, ".claude-plugin/plugin.json");
|
|
178
|
+
// Check file exists
|
|
179
|
+
if (!(await fs.pathExists(manifestPath))) {
|
|
180
|
+
issues.push({
|
|
181
|
+
type: "error",
|
|
182
|
+
code: "PLUGIN_MANIFEST_NOT_FOUND",
|
|
183
|
+
message: "Plugin manifest not found",
|
|
184
|
+
file: manifestPath,
|
|
185
|
+
});
|
|
186
|
+
return issues;
|
|
187
|
+
}
|
|
188
|
+
// Parse JSON
|
|
189
|
+
let manifest;
|
|
190
|
+
try {
|
|
191
|
+
const content = await fs.readFile(manifestPath, "utf-8");
|
|
192
|
+
manifest = JSON.parse(content);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
issues.push({
|
|
196
|
+
type: "error",
|
|
197
|
+
code: "PLUGIN_INVALID_JSON",
|
|
198
|
+
message: `Invalid JSON: ${error}`,
|
|
199
|
+
file: manifestPath,
|
|
200
|
+
});
|
|
201
|
+
return issues;
|
|
202
|
+
}
|
|
203
|
+
// Schema validation
|
|
204
|
+
const schemaPath = path.join(schemaDir, "plugin.schema.json");
|
|
205
|
+
if (await fs.pathExists(schemaPath)) {
|
|
206
|
+
const schema = await loadJsonSchema(schemaPath);
|
|
207
|
+
const schemaIssues = validateAgainstSchema(manifest, schema, manifestPath);
|
|
208
|
+
issues.push(...schemaIssues);
|
|
209
|
+
}
|
|
210
|
+
// Check name matches marketplace entry
|
|
211
|
+
if (manifest.name !== expectedName) {
|
|
212
|
+
issues.push({
|
|
213
|
+
type: "error",
|
|
214
|
+
code: "PLUGIN_NAME_MISMATCH",
|
|
215
|
+
message: `Plugin name "${manifest.name}" does not match marketplace entry "${expectedName}"`,
|
|
216
|
+
file: manifestPath,
|
|
217
|
+
field: "name",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return issues;
|
|
221
|
+
}
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Command Validation
|
|
224
|
+
// ============================================================================
|
|
225
|
+
async function validateCommand(filePath) {
|
|
226
|
+
const issues = [];
|
|
227
|
+
try {
|
|
228
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
229
|
+
const { frontmatter } = parseDocument(content);
|
|
230
|
+
// Check required fields
|
|
231
|
+
if (!frontmatter || typeof frontmatter !== "object") {
|
|
232
|
+
issues.push({
|
|
233
|
+
type: "error",
|
|
234
|
+
code: "COMMAND_NO_FRONTMATTER",
|
|
235
|
+
message: "Command file must have YAML frontmatter",
|
|
236
|
+
file: filePath,
|
|
237
|
+
});
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
// Check description exists
|
|
241
|
+
if (!frontmatter.description) {
|
|
242
|
+
issues.push({
|
|
243
|
+
type: "error",
|
|
244
|
+
code: "COMMAND_MISSING_DESCRIPTION",
|
|
245
|
+
message: 'Missing required frontmatter field "description"',
|
|
246
|
+
file: filePath,
|
|
247
|
+
field: "description",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else if (typeof frontmatter.description !== "string" ||
|
|
251
|
+
frontmatter.description.length === 0) {
|
|
252
|
+
issues.push({
|
|
253
|
+
type: "error",
|
|
254
|
+
code: "COMMAND_INVALID_DESCRIPTION",
|
|
255
|
+
message: "Description must be a non-empty string",
|
|
256
|
+
file: filePath,
|
|
257
|
+
field: "description",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
else if (frontmatter.description.length < 10) {
|
|
261
|
+
issues.push({
|
|
262
|
+
type: "warning",
|
|
263
|
+
code: "COMMAND_SHORT_DESCRIPTION",
|
|
264
|
+
message: `Description is very short (${frontmatter.description.length} chars, recommend at least 10)`,
|
|
265
|
+
file: filePath,
|
|
266
|
+
field: "description",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
issues.push({
|
|
272
|
+
type: "error",
|
|
273
|
+
code: "COMMAND_READ_ERROR",
|
|
274
|
+
message: `Failed to read command file: ${error}`,
|
|
275
|
+
file: filePath,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return issues;
|
|
279
|
+
}
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Agent Validation
|
|
282
|
+
// ============================================================================
|
|
283
|
+
async function validateAgent(filePath) {
|
|
284
|
+
const issues = [];
|
|
285
|
+
try {
|
|
286
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
287
|
+
const { frontmatter } = parseDocument(content);
|
|
288
|
+
if (!frontmatter || typeof frontmatter !== "object") {
|
|
289
|
+
issues.push({
|
|
290
|
+
type: "error",
|
|
291
|
+
code: "AGENT_NO_FRONTMATTER",
|
|
292
|
+
message: "Agent file must have YAML frontmatter",
|
|
293
|
+
file: filePath,
|
|
294
|
+
});
|
|
295
|
+
return issues;
|
|
296
|
+
}
|
|
297
|
+
// Check required fields
|
|
298
|
+
const requiredFields = ["name", "description", "tools"];
|
|
299
|
+
for (const field of requiredFields) {
|
|
300
|
+
if (!frontmatter[field]) {
|
|
301
|
+
issues.push({
|
|
302
|
+
type: "error",
|
|
303
|
+
code: "AGENT_MISSING_FIELD",
|
|
304
|
+
message: `Missing required frontmatter field "${field}"`,
|
|
305
|
+
file: filePath,
|
|
306
|
+
field,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Validate name is kebab-case
|
|
311
|
+
if (frontmatter.name && !isKebabCase(frontmatter.name)) {
|
|
312
|
+
const suggested = toKebabCase(frontmatter.name);
|
|
313
|
+
issues.push({
|
|
314
|
+
type: "error",
|
|
315
|
+
code: "AGENT_INVALID_NAME",
|
|
316
|
+
message: `Agent name must be kebab-case (got: "${frontmatter.name}", expected: "${suggested}")`,
|
|
317
|
+
file: filePath,
|
|
318
|
+
field: "name",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Validate tools
|
|
322
|
+
if (frontmatter.tools) {
|
|
323
|
+
let tools = [];
|
|
324
|
+
if (typeof frontmatter.tools === "string") {
|
|
325
|
+
tools = frontmatter.tools.split(/,\s*/).map((t) => t.trim());
|
|
326
|
+
}
|
|
327
|
+
else if (Array.isArray(frontmatter.tools)) {
|
|
328
|
+
tools = frontmatter.tools;
|
|
329
|
+
}
|
|
330
|
+
for (const tool of tools) {
|
|
331
|
+
if (!VALID_TOOLS.includes(tool) && tool !== "*") {
|
|
332
|
+
issues.push({
|
|
333
|
+
type: "warning",
|
|
334
|
+
code: "AGENT_UNKNOWN_TOOL",
|
|
335
|
+
message: `Unknown tool "${tool}" (valid tools: ${VALID_TOOLS.join(", ")}, or "*")`,
|
|
336
|
+
file: filePath,
|
|
337
|
+
field: "tools",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Validate model if present
|
|
343
|
+
if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) {
|
|
344
|
+
issues.push({
|
|
345
|
+
type: "error",
|
|
346
|
+
code: "AGENT_INVALID_MODEL",
|
|
347
|
+
message: `Invalid model "${frontmatter.model}" (valid: ${VALID_MODELS.join(", ")})`,
|
|
348
|
+
file: filePath,
|
|
349
|
+
field: "model",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
// Check filename matches agent name
|
|
353
|
+
const filename = path.basename(filePath, ".md");
|
|
354
|
+
if (frontmatter.name && filename !== frontmatter.name) {
|
|
355
|
+
issues.push({
|
|
356
|
+
type: "warning",
|
|
357
|
+
code: "AGENT_FILENAME_MISMATCH",
|
|
358
|
+
message: `Filename "${filename}.md" does not match agent name "${frontmatter.name}"`,
|
|
359
|
+
file: filePath,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
issues.push({
|
|
365
|
+
type: "error",
|
|
366
|
+
code: "AGENT_READ_ERROR",
|
|
367
|
+
message: `Failed to read agent file: ${error}`,
|
|
368
|
+
file: filePath,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return issues;
|
|
372
|
+
}
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Skill Validation
|
|
375
|
+
// ============================================================================
|
|
376
|
+
async function validateSkill(filePath) {
|
|
377
|
+
const issues = [];
|
|
378
|
+
// Check filename is SKILL.md
|
|
379
|
+
const filename = path.basename(filePath);
|
|
380
|
+
if (filename !== "SKILL.md") {
|
|
381
|
+
issues.push({
|
|
382
|
+
type: "error",
|
|
383
|
+
code: "SKILL_INVALID_FILENAME",
|
|
384
|
+
message: `Skill file must be named "SKILL.md" (found: "${filename}")`,
|
|
385
|
+
file: filePath,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
390
|
+
const { frontmatter } = parseDocument(content);
|
|
391
|
+
if (!frontmatter || typeof frontmatter !== "object") {
|
|
392
|
+
issues.push({
|
|
393
|
+
type: "error",
|
|
394
|
+
code: "SKILL_NO_FRONTMATTER",
|
|
395
|
+
message: "Skill file must have YAML frontmatter",
|
|
396
|
+
file: filePath,
|
|
397
|
+
});
|
|
398
|
+
return issues;
|
|
399
|
+
}
|
|
400
|
+
// Check required fields
|
|
401
|
+
const requiredFields = ["name", "description"];
|
|
402
|
+
for (const field of requiredFields) {
|
|
403
|
+
if (!frontmatter[field]) {
|
|
404
|
+
issues.push({
|
|
405
|
+
type: "error",
|
|
406
|
+
code: "SKILL_MISSING_FIELD",
|
|
407
|
+
message: `Missing required frontmatter field "${field}"`,
|
|
408
|
+
file: filePath,
|
|
409
|
+
field,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Validate name is kebab-case
|
|
414
|
+
if (frontmatter.name && !isKebabCase(frontmatter.name)) {
|
|
415
|
+
const suggested = toKebabCase(frontmatter.name);
|
|
416
|
+
issues.push({
|
|
417
|
+
type: "error",
|
|
418
|
+
code: "SKILL_INVALID_NAME",
|
|
419
|
+
message: `Skill name must be kebab-case (got: "${frontmatter.name}", expected: "${suggested}")`,
|
|
420
|
+
file: filePath,
|
|
421
|
+
field: "name",
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// Check parent directory name matches skill name
|
|
425
|
+
const parentDir = path.basename(path.dirname(filePath));
|
|
426
|
+
if (frontmatter.name && parentDir !== frontmatter.name) {
|
|
427
|
+
issues.push({
|
|
428
|
+
type: "warning",
|
|
429
|
+
code: "SKILL_DIR_MISMATCH",
|
|
430
|
+
message: `Parent directory "${parentDir}" does not match skill name "${frontmatter.name}"`,
|
|
431
|
+
file: filePath,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// Validate allowed-tools if present
|
|
435
|
+
if (frontmatter["allowed-tools"]) {
|
|
436
|
+
const toolsStr = typeof frontmatter["allowed-tools"] === "string"
|
|
437
|
+
? frontmatter["allowed-tools"]
|
|
438
|
+
: "";
|
|
439
|
+
const tools = toolsStr.split(/,\s*/).map((t) => t.trim());
|
|
440
|
+
for (const tool of tools) {
|
|
441
|
+
if (!VALID_TOOLS.includes(tool) && tool !== "*") {
|
|
442
|
+
issues.push({
|
|
443
|
+
type: "warning",
|
|
444
|
+
code: "SKILL_UNKNOWN_TOOL",
|
|
445
|
+
message: `Unknown tool "${tool}" (valid tools: ${VALID_TOOLS.join(", ")}, or "*")`,
|
|
446
|
+
file: filePath,
|
|
447
|
+
field: "allowed-tools",
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
issues.push({
|
|
455
|
+
type: "error",
|
|
456
|
+
code: "SKILL_READ_ERROR",
|
|
457
|
+
message: `Failed to read skill file: ${error}`,
|
|
458
|
+
file: filePath,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return issues;
|
|
462
|
+
}
|
|
463
|
+
// ============================================================================
|
|
464
|
+
// Hooks Validation
|
|
465
|
+
// ============================================================================
|
|
466
|
+
async function validateHooks(filePath) {
|
|
467
|
+
const issues = [];
|
|
468
|
+
try {
|
|
469
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
470
|
+
let hookConfig;
|
|
471
|
+
try {
|
|
472
|
+
hookConfig = JSON.parse(content);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
issues.push({
|
|
476
|
+
type: "error",
|
|
477
|
+
code: "HOOK_INVALID_JSON",
|
|
478
|
+
message: `Invalid JSON: ${error}`,
|
|
479
|
+
file: filePath,
|
|
480
|
+
});
|
|
481
|
+
return issues;
|
|
482
|
+
}
|
|
483
|
+
// Check required fields
|
|
484
|
+
const requiredFields = ["hook", "name", "description", "command"];
|
|
485
|
+
for (const field of requiredFields) {
|
|
486
|
+
if (!hookConfig[field]) {
|
|
487
|
+
issues.push({
|
|
488
|
+
type: "error",
|
|
489
|
+
code: "HOOK_MISSING_FIELD",
|
|
490
|
+
message: `Missing required field "${field}"`,
|
|
491
|
+
file: filePath,
|
|
492
|
+
field,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Validate hook type
|
|
497
|
+
if (hookConfig.hook && !VALID_HOOK_TYPES.includes(hookConfig.hook)) {
|
|
498
|
+
issues.push({
|
|
499
|
+
type: "error",
|
|
500
|
+
code: "HOOK_INVALID_TYPE",
|
|
501
|
+
message: `Invalid hook type "${hookConfig.hook}" (valid: ${VALID_HOOK_TYPES.join(", ")})`,
|
|
502
|
+
file: filePath,
|
|
503
|
+
field: "hook",
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// Validate command is non-empty
|
|
507
|
+
if (hookConfig.command &&
|
|
508
|
+
(typeof hookConfig.command !== "string" || hookConfig.command.trim() === "")) {
|
|
509
|
+
issues.push({
|
|
510
|
+
type: "error",
|
|
511
|
+
code: "HOOK_INVALID_COMMAND",
|
|
512
|
+
message: "Command must be a non-empty string",
|
|
513
|
+
file: filePath,
|
|
514
|
+
field: "command",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
// Validate install structure if present
|
|
518
|
+
if (hookConfig.install) {
|
|
519
|
+
if (typeof hookConfig.install !== "object") {
|
|
520
|
+
issues.push({
|
|
521
|
+
type: "error",
|
|
522
|
+
code: "HOOK_INVALID_INSTALL",
|
|
523
|
+
message: "Install field must be an object",
|
|
524
|
+
file: filePath,
|
|
525
|
+
field: "install",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Validate settings.timeout if present
|
|
530
|
+
if (hookConfig.settings?.timeout !== undefined) {
|
|
531
|
+
const timeout = hookConfig.settings.timeout;
|
|
532
|
+
if (typeof timeout !== "number" || timeout <= 0) {
|
|
533
|
+
issues.push({
|
|
534
|
+
type: "error",
|
|
535
|
+
code: "HOOK_INVALID_TIMEOUT",
|
|
536
|
+
message: "Timeout must be a positive number",
|
|
537
|
+
file: filePath,
|
|
538
|
+
field: "settings.timeout",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Validate blocking is boolean if present
|
|
543
|
+
if (hookConfig.blocking !== undefined &&
|
|
544
|
+
typeof hookConfig.blocking !== "boolean") {
|
|
545
|
+
issues.push({
|
|
546
|
+
type: "error",
|
|
547
|
+
code: "HOOK_INVALID_BLOCKING",
|
|
548
|
+
message: "Blocking must be a boolean",
|
|
549
|
+
file: filePath,
|
|
550
|
+
field: "blocking",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
issues.push({
|
|
556
|
+
type: "error",
|
|
557
|
+
code: "HOOK_READ_ERROR",
|
|
558
|
+
message: `Failed to read hooks file: ${error}`,
|
|
559
|
+
file: filePath,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return issues;
|
|
563
|
+
}
|
|
564
|
+
// ============================================================================
|
|
565
|
+
// MCP Config Validation
|
|
566
|
+
// ============================================================================
|
|
567
|
+
async function validateMcpConfig(filePath) {
|
|
568
|
+
const issues = [];
|
|
569
|
+
try {
|
|
570
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
571
|
+
let mcpConfig;
|
|
572
|
+
try {
|
|
573
|
+
mcpConfig = JSON.parse(content);
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
issues.push({
|
|
577
|
+
type: "error",
|
|
578
|
+
code: "MCP_INVALID_JSON",
|
|
579
|
+
message: `Invalid JSON: ${error}`,
|
|
580
|
+
file: filePath,
|
|
581
|
+
});
|
|
582
|
+
return issues;
|
|
583
|
+
}
|
|
584
|
+
// Check top-level mcpServers object exists
|
|
585
|
+
if (!mcpConfig.mcpServers) {
|
|
586
|
+
issues.push({
|
|
587
|
+
type: "error",
|
|
588
|
+
code: "MCP_MISSING_SERVERS",
|
|
589
|
+
message: 'Missing required top-level field "mcpServers"',
|
|
590
|
+
file: filePath,
|
|
591
|
+
field: "mcpServers",
|
|
592
|
+
});
|
|
593
|
+
return issues;
|
|
594
|
+
}
|
|
595
|
+
if (typeof mcpConfig.mcpServers !== "object") {
|
|
596
|
+
issues.push({
|
|
597
|
+
type: "error",
|
|
598
|
+
code: "MCP_INVALID_SERVERS",
|
|
599
|
+
message: "mcpServers must be an object",
|
|
600
|
+
file: filePath,
|
|
601
|
+
field: "mcpServers",
|
|
602
|
+
});
|
|
603
|
+
return issues;
|
|
604
|
+
}
|
|
605
|
+
// Validate each server
|
|
606
|
+
for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
|
|
607
|
+
const server = serverConfig;
|
|
608
|
+
// Check server name is kebab-case
|
|
609
|
+
if (!isKebabCase(serverName)) {
|
|
610
|
+
issues.push({
|
|
611
|
+
type: "warning",
|
|
612
|
+
code: "MCP_SERVER_NAME_NOT_KEBAB",
|
|
613
|
+
message: `Server name "${serverName}" should be kebab-case`,
|
|
614
|
+
file: filePath,
|
|
615
|
+
field: `mcpServers.${serverName}`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// Check required fields
|
|
619
|
+
if (!server.command) {
|
|
620
|
+
issues.push({
|
|
621
|
+
type: "error",
|
|
622
|
+
code: "MCP_MISSING_COMMAND",
|
|
623
|
+
message: `Server "${serverName}" missing required field "command"`,
|
|
624
|
+
file: filePath,
|
|
625
|
+
field: `mcpServers.${serverName}.command`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (!server.args) {
|
|
629
|
+
issues.push({
|
|
630
|
+
type: "error",
|
|
631
|
+
code: "MCP_MISSING_ARGS",
|
|
632
|
+
message: `Server "${serverName}" missing required field "args"`,
|
|
633
|
+
file: filePath,
|
|
634
|
+
field: `mcpServers.${serverName}.args`,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
// Validate command is string
|
|
638
|
+
if (server.command && typeof server.command !== "string") {
|
|
639
|
+
issues.push({
|
|
640
|
+
type: "error",
|
|
641
|
+
code: "MCP_INVALID_COMMAND",
|
|
642
|
+
message: `Server "${serverName}" command must be a string`,
|
|
643
|
+
file: filePath,
|
|
644
|
+
field: `mcpServers.${serverName}.command`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
// Validate args is array
|
|
648
|
+
if (server.args && !Array.isArray(server.args)) {
|
|
649
|
+
issues.push({
|
|
650
|
+
type: "error",
|
|
651
|
+
code: "MCP_INVALID_ARGS",
|
|
652
|
+
message: `Server "${serverName}" args must be an array`,
|
|
653
|
+
file: filePath,
|
|
654
|
+
field: `mcpServers.${serverName}.args`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
// Warn if metadata is missing
|
|
658
|
+
if (!server.metadata) {
|
|
659
|
+
issues.push({
|
|
660
|
+
type: "warning",
|
|
661
|
+
code: "MCP_MISSING_METADATA",
|
|
662
|
+
message: `Server "${serverName}" should include metadata (name, description, version)`,
|
|
663
|
+
file: filePath,
|
|
664
|
+
field: `mcpServers.${serverName}.metadata`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
issues.push({
|
|
671
|
+
type: "error",
|
|
672
|
+
code: "MCP_READ_ERROR",
|
|
673
|
+
message: `Failed to read MCP config: ${error}`,
|
|
674
|
+
file: filePath,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return issues;
|
|
678
|
+
}
|
|
679
|
+
// ============================================================================
|
|
680
|
+
// Plugin Structure Validation
|
|
681
|
+
// ============================================================================
|
|
682
|
+
async function validatePluginStructure(pluginDir) {
|
|
683
|
+
const issues = [];
|
|
684
|
+
// Check .claude-plugin directory exists
|
|
685
|
+
const claudePluginDir = path.join(pluginDir, ".claude-plugin");
|
|
686
|
+
if (!(await fs.pathExists(claudePluginDir))) {
|
|
687
|
+
issues.push({
|
|
688
|
+
type: "error",
|
|
689
|
+
code: "PLUGIN_MISSING_DIR",
|
|
690
|
+
message: "Plugin must have .claude-plugin directory",
|
|
691
|
+
file: pluginDir,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
// Check component directories are at plugin root, not inside .claude-plugin
|
|
695
|
+
const componentDirs = ["commands", "agents", "skills", "hooks"];
|
|
696
|
+
for (const dir of componentDirs) {
|
|
697
|
+
const wrongPath = path.join(claudePluginDir, dir);
|
|
698
|
+
if (await fs.pathExists(wrongPath)) {
|
|
699
|
+
issues.push({
|
|
700
|
+
type: "error",
|
|
701
|
+
code: "PLUGIN_COMPONENT_WRONG_LOCATION",
|
|
702
|
+
message: `Component directory "${dir}/" must be at plugin root, not inside .claude-plugin/`,
|
|
703
|
+
file: wrongPath,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return issues;
|
|
708
|
+
}
|
|
709
|
+
// ============================================================================
|
|
710
|
+
// Main Validation Entry Point
|
|
711
|
+
// ============================================================================
|
|
712
|
+
export async function validatePluginMarketplace(rootDir, schemaDir) {
|
|
713
|
+
const allIssues = [];
|
|
714
|
+
const componentsValidated = {
|
|
715
|
+
commands: 0,
|
|
716
|
+
agents: 0,
|
|
717
|
+
skills: 0,
|
|
718
|
+
hooks: 0,
|
|
719
|
+
mcpServers: 0,
|
|
720
|
+
};
|
|
721
|
+
// Determine schema directory
|
|
722
|
+
const schemasDir = schemaDir ||
|
|
723
|
+
path.join(rootDir, "schemas/plugins") ||
|
|
724
|
+
path.resolve(process.cwd(), "schemas/plugins");
|
|
725
|
+
// Validate marketplace manifest
|
|
726
|
+
const marketplacePath = path.join(rootDir, ".claude-plugin/marketplace.json");
|
|
727
|
+
const marketplaceIssues = await validateMarketplaceManifest(marketplacePath, schemasDir);
|
|
728
|
+
allIssues.push(...marketplaceIssues);
|
|
729
|
+
// If marketplace is invalid, return early
|
|
730
|
+
if (!await fs.pathExists(marketplacePath)) {
|
|
731
|
+
return {
|
|
732
|
+
success: false,
|
|
733
|
+
issues: allIssues,
|
|
734
|
+
pluginsValidated: 0,
|
|
735
|
+
componentsValidated,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
// Load marketplace to get plugin list
|
|
739
|
+
let marketplace;
|
|
740
|
+
try {
|
|
741
|
+
const content = await fs.readFile(marketplacePath, "utf-8");
|
|
742
|
+
marketplace = JSON.parse(content);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
return {
|
|
746
|
+
success: false,
|
|
747
|
+
issues: allIssues,
|
|
748
|
+
pluginsValidated: 0,
|
|
749
|
+
componentsValidated,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
// Validate each plugin
|
|
753
|
+
let pluginsValidated = 0;
|
|
754
|
+
for (const plugin of marketplace.plugins || []) {
|
|
755
|
+
const pluginPath = path.join(rootDir, plugin.source.replace(/^\.\//, ""));
|
|
756
|
+
if (!(await fs.pathExists(pluginPath))) {
|
|
757
|
+
continue; // Already reported in marketplace validation
|
|
758
|
+
}
|
|
759
|
+
pluginsValidated++;
|
|
760
|
+
// Validate plugin structure
|
|
761
|
+
const structureIssues = await validatePluginStructure(pluginPath);
|
|
762
|
+
allIssues.push(...structureIssues);
|
|
763
|
+
// Validate plugin manifest
|
|
764
|
+
const manifestIssues = await validatePluginManifest(pluginPath, plugin.name, schemasDir);
|
|
765
|
+
allIssues.push(...manifestIssues);
|
|
766
|
+
// Validate commands
|
|
767
|
+
const commandsDir = path.join(pluginPath, "commands");
|
|
768
|
+
if (await fs.pathExists(commandsDir)) {
|
|
769
|
+
const commandFiles = await glob("**/*.md", { cwd: commandsDir });
|
|
770
|
+
for (const file of commandFiles) {
|
|
771
|
+
const filePath = path.join(commandsDir, file);
|
|
772
|
+
const commandIssues = await validateCommand(filePath);
|
|
773
|
+
allIssues.push(...commandIssues);
|
|
774
|
+
componentsValidated.commands++;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Validate agents
|
|
778
|
+
const agentsDir = path.join(pluginPath, "agents");
|
|
779
|
+
if (await fs.pathExists(agentsDir)) {
|
|
780
|
+
const agentFiles = await glob("**/*.md", { cwd: agentsDir });
|
|
781
|
+
for (const file of agentFiles) {
|
|
782
|
+
const filePath = path.join(agentsDir, file);
|
|
783
|
+
const agentIssues = await validateAgent(filePath);
|
|
784
|
+
allIssues.push(...agentIssues);
|
|
785
|
+
componentsValidated.agents++;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Validate skills
|
|
789
|
+
const skillsDir = path.join(pluginPath, "skills");
|
|
790
|
+
if (await fs.pathExists(skillsDir)) {
|
|
791
|
+
// Find all markdown files in skills directories
|
|
792
|
+
const allSkillFiles = await glob("**/*.md", { cwd: skillsDir });
|
|
793
|
+
const validSkillFiles = await glob("**/SKILL.md", { cwd: skillsDir });
|
|
794
|
+
// Report incorrectly named skill files
|
|
795
|
+
for (const file of allSkillFiles) {
|
|
796
|
+
const filename = path.basename(file);
|
|
797
|
+
if (filename !== "SKILL.md") {
|
|
798
|
+
allIssues.push({
|
|
799
|
+
type: "error",
|
|
800
|
+
code: "SKILL_INVALID_FILENAME",
|
|
801
|
+
message: `Skill file must be named "SKILL.md" (found: "${filename}")`,
|
|
802
|
+
file: path.join(skillsDir, file),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Validate properly named skill files
|
|
807
|
+
for (const file of validSkillFiles) {
|
|
808
|
+
const filePath = path.join(skillsDir, file);
|
|
809
|
+
const skillIssues = await validateSkill(filePath);
|
|
810
|
+
allIssues.push(...skillIssues);
|
|
811
|
+
componentsValidated.skills++;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Validate hooks
|
|
815
|
+
const hooksDir = path.join(pluginPath, "hooks");
|
|
816
|
+
if (await fs.pathExists(hooksDir)) {
|
|
817
|
+
const hookFiles = await glob("**/*.json", { cwd: hooksDir });
|
|
818
|
+
for (const file of hookFiles) {
|
|
819
|
+
const filePath = path.join(hooksDir, file);
|
|
820
|
+
const hookIssues = await validateHooks(filePath);
|
|
821
|
+
allIssues.push(...hookIssues);
|
|
822
|
+
componentsValidated.hooks++;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Validate MCP config
|
|
826
|
+
const mcpPath = path.join(pluginPath, ".mcp.json");
|
|
827
|
+
if (await fs.pathExists(mcpPath)) {
|
|
828
|
+
const mcpIssues = await validateMcpConfig(mcpPath);
|
|
829
|
+
allIssues.push(...mcpIssues);
|
|
830
|
+
// Count servers
|
|
831
|
+
try {
|
|
832
|
+
const content = await fs.readFile(mcpPath, "utf-8");
|
|
833
|
+
const mcpConfig = JSON.parse(content);
|
|
834
|
+
if (mcpConfig.mcpServers) {
|
|
835
|
+
componentsValidated.mcpServers += Object.keys(mcpConfig.mcpServers).length;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
// Already reported in validation
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const success = allIssues.filter((i) => i.type === "error").length === 0;
|
|
844
|
+
return {
|
|
845
|
+
success,
|
|
846
|
+
issues: allIssues,
|
|
847
|
+
pluginsValidated,
|
|
848
|
+
componentsValidated,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
//# sourceMappingURL=validate-plugins.js.map
|