@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.
Files changed (103) hide show
  1. package/README.md +135 -0
  2. package/bin/synapse.js +3 -0
  3. package/dist/commands/eject.d.ts +19 -0
  4. package/dist/commands/eject.d.ts.map +1 -0
  5. package/dist/commands/eject.js +146 -0
  6. package/dist/commands/eject.js.map +1 -0
  7. package/dist/commands/fetch-reference.d.ts +19 -0
  8. package/dist/commands/fetch-reference.d.ts.map +1 -0
  9. package/dist/commands/fetch-reference.js +93 -0
  10. package/dist/commands/fetch-reference.js.map +1 -0
  11. package/dist/commands/format.d.ts +26 -0
  12. package/dist/commands/format.d.ts.map +1 -0
  13. package/dist/commands/format.js +126 -0
  14. package/dist/commands/format.js.map +1 -0
  15. package/dist/commands/generate-pdf.d.ts +19 -0
  16. package/dist/commands/generate-pdf.d.ts.map +1 -0
  17. package/dist/commands/generate-pdf.js +140 -0
  18. package/dist/commands/generate-pdf.js.map +1 -0
  19. package/dist/commands/index.d.ts +17 -0
  20. package/dist/commands/index.d.ts.map +1 -0
  21. package/dist/commands/index.js +26 -0
  22. package/dist/commands/index.js.map +1 -0
  23. package/dist/commands/init.d.ts +58 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +234 -0
  26. package/dist/commands/init.js.map +1 -0
  27. package/dist/commands/migrate.d.ts +29 -0
  28. package/dist/commands/migrate.d.ts.map +1 -0
  29. package/dist/commands/migrate.js +297 -0
  30. package/dist/commands/migrate.js.map +1 -0
  31. package/dist/commands/scaffold.d.ts +24 -0
  32. package/dist/commands/scaffold.d.ts.map +1 -0
  33. package/dist/commands/scaffold.js +244 -0
  34. package/dist/commands/scaffold.js.map +1 -0
  35. package/dist/commands/update.d.ts +25 -0
  36. package/dist/commands/update.d.ts.map +1 -0
  37. package/dist/commands/update.js +253 -0
  38. package/dist/commands/update.js.map +1 -0
  39. package/dist/commands/validate.d.ts +37 -0
  40. package/dist/commands/validate.d.ts.map +1 -0
  41. package/dist/commands/validate.js +526 -0
  42. package/dist/commands/validate.js.map +1 -0
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +277 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/bodyRules.d.ts +70 -0
  48. package/dist/lib/bodyRules.d.ts.map +1 -0
  49. package/dist/lib/bodyRules.js +711 -0
  50. package/dist/lib/bodyRules.js.map +1 -0
  51. package/dist/lib/config.d.ts +49 -0
  52. package/dist/lib/config.d.ts.map +1 -0
  53. package/dist/lib/config.js +91 -0
  54. package/dist/lib/config.js.map +1 -0
  55. package/dist/lib/git.d.ts +99 -0
  56. package/dist/lib/git.d.ts.map +1 -0
  57. package/dist/lib/git.js +266 -0
  58. package/dist/lib/git.js.map +1 -0
  59. package/dist/lib/graph.d.ts +6 -0
  60. package/dist/lib/graph.d.ts.map +1 -0
  61. package/dist/lib/graph.js +6 -0
  62. package/dist/lib/graph.js.map +1 -0
  63. package/dist/lib/homepage.d.ts +10 -0
  64. package/dist/lib/homepage.d.ts.map +1 -0
  65. package/dist/lib/homepage.js +172 -0
  66. package/dist/lib/homepage.js.map +1 -0
  67. package/dist/lib/markdown.d.ts +107 -0
  68. package/dist/lib/markdown.d.ts.map +1 -0
  69. package/dist/lib/markdown.js +318 -0
  70. package/dist/lib/markdown.js.map +1 -0
  71. package/dist/lib/mode-detection.d.ts +10 -0
  72. package/dist/lib/mode-detection.d.ts.map +1 -0
  73. package/dist/lib/mode-detection.js +29 -0
  74. package/dist/lib/mode-detection.js.map +1 -0
  75. package/dist/lib/naming.d.ts +47 -0
  76. package/dist/lib/naming.d.ts.map +1 -0
  77. package/dist/lib/naming.js +403 -0
  78. package/dist/lib/naming.js.map +1 -0
  79. package/dist/lib/schemas.d.ts +38 -0
  80. package/dist/lib/schemas.d.ts.map +1 -0
  81. package/dist/lib/schemas.js +248 -0
  82. package/dist/lib/schemas.js.map +1 -0
  83. package/dist/lib/templateLint.d.ts +21 -0
  84. package/dist/lib/templateLint.d.ts.map +1 -0
  85. package/dist/lib/templateLint.js +243 -0
  86. package/dist/lib/templateLint.js.map +1 -0
  87. package/dist/lib/templates.d.ts +53 -0
  88. package/dist/lib/templates.d.ts.map +1 -0
  89. package/dist/lib/templates.js +128 -0
  90. package/dist/lib/templates.js.map +1 -0
  91. package/dist/lib/tracking.d.ts +52 -0
  92. package/dist/lib/tracking.d.ts.map +1 -0
  93. package/dist/lib/tracking.js +135 -0
  94. package/dist/lib/tracking.js.map +1 -0
  95. package/dist/lib/types.generated.d.ts +54 -0
  96. package/dist/lib/types.generated.d.ts.map +1 -0
  97. package/dist/lib/types.generated.js +144 -0
  98. package/dist/lib/types.generated.js.map +1 -0
  99. package/dist/lib/validate-plugins.d.ts +22 -0
  100. package/dist/lib/validate-plugins.d.ts.map +1 -0
  101. package/dist/lib/validate-plugins.js +851 -0
  102. package/dist/lib/validate-plugins.js.map +1 -0
  103. 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