source dump of claude code
at main 903 lines 28 kB view raw
1import type { Dirent, Stats } from 'fs' 2import { readdir, readFile, stat } from 'fs/promises' 3import * as path from 'path' 4import { z } from 'zod/v4' 5import { errorMessage, getErrnoCode, isENOENT } from '../errors.js' 6import { FRONTMATTER_REGEX } from '../frontmatterParser.js' 7import { jsonParse } from '../slowOperations.js' 8import { parseYaml } from '../yaml.js' 9import { 10 PluginHooksSchema, 11 PluginManifestSchema, 12 PluginMarketplaceEntrySchema, 13 PluginMarketplaceSchema, 14} from './schemas.js' 15 16/** 17 * Fields that belong in marketplace.json entries (PluginMarketplaceEntrySchema) 18 * but not plugin.json (PluginManifestSchema). Plugin authors reasonably copy 19 * one into the other. Surfaced as warnings by `claude plugin validate` since 20 * they're a known confusion point — the load path silently strips all unknown 21 * keys via zod's default behavior, so they're harmless at runtime but worth 22 * flagging to authors. 23 */ 24const MARKETPLACE_ONLY_MANIFEST_FIELDS = new Set([ 25 'category', 26 'source', 27 'tags', 28 'strict', 29 'id', 30]) 31 32export type ValidationResult = { 33 success: boolean 34 errors: ValidationError[] 35 warnings: ValidationWarning[] 36 filePath: string 37 fileType: 'plugin' | 'marketplace' | 'skill' | 'agent' | 'command' | 'hooks' 38} 39 40export type ValidationError = { 41 path: string 42 message: string 43 code?: string 44} 45 46export type ValidationWarning = { 47 path: string 48 message: string 49} 50 51/** 52 * Detect whether a file is a plugin manifest or marketplace manifest 53 */ 54function detectManifestType( 55 filePath: string, 56): 'plugin' | 'marketplace' | 'unknown' { 57 const fileName = path.basename(filePath) 58 const dirName = path.basename(path.dirname(filePath)) 59 60 // Check filename patterns 61 if (fileName === 'plugin.json') return 'plugin' 62 if (fileName === 'marketplace.json') return 'marketplace' 63 64 // Check if it's in .claude-plugin directory 65 if (dirName === '.claude-plugin') { 66 return 'plugin' // Most likely plugin.json 67 } 68 69 return 'unknown' 70} 71 72/** 73 * Format Zod validation errors into a readable format 74 */ 75function formatZodErrors(zodError: z.ZodError): ValidationError[] { 76 return zodError.issues.map(error => ({ 77 path: error.path.join('.') || 'root', 78 message: error.message, 79 code: error.code, 80 })) 81} 82 83/** 84 * Check for parent-directory segments ('..') in a path string. 85 * 86 * For plugin.json component paths this is a security concern (escaping the plugin dir). 87 * For marketplace.json source paths it's almost always a resolution-base misunderstanding: 88 * paths resolve from the marketplace repo root, not from marketplace.json itself, so the 89 * '..' a user added to "climb out of .claude-plugin/" is unnecessary. Callers pass `hint` 90 * to attach the right explanation. 91 */ 92function checkPathTraversal( 93 p: string, 94 field: string, 95 errors: ValidationError[], 96 hint?: string, 97): void { 98 if (p.includes('..')) { 99 errors.push({ 100 path: field, 101 message: hint 102 ? `Path contains "..": ${p}. ${hint}` 103 : `Path contains ".." which could be a path traversal attempt: ${p}`, 104 }) 105 } 106} 107 108// Shown when a marketplace plugin source contains '..'. Most users hit this because 109// they expect paths to resolve relative to marketplace.json (inside .claude-plugin/), 110// but resolution actually starts at the marketplace repo root — see gh-29485. 111// Computes a tailored "use X instead of Y" suggestion from the user's actual path 112// rather than a hardcoded example (review feedback on #20895). 113function marketplaceSourceHint(p: string): string { 114 // Strip leading ../ segments: the '..' a user added to "climb out of 115 // .claude-plugin/" is unnecessary since paths already start at the repo root. 116 // If '..' appears mid-path (rare), fall back to a generic example. 117 const stripped = p.replace(/^(\.\.\/)+/, '') 118 const corrected = stripped !== p ? `./${stripped}` : './plugins/my-plugin' 119 return ( 120 'Plugin source paths are resolved relative to the marketplace root (the directory ' + 121 'containing .claude-plugin/), not relative to marketplace.json. ' + 122 `Use "${corrected}" instead of "${p}".` 123 ) 124} 125 126/** 127 * Validate a plugin manifest file (plugin.json) 128 */ 129export async function validatePluginManifest( 130 filePath: string, 131): Promise<ValidationResult> { 132 const errors: ValidationError[] = [] 133 const warnings: ValidationWarning[] = [] 134 const absolutePath = path.resolve(filePath) 135 136 // Read file content — handle ENOENT / EISDIR / permission errors directly 137 let content: string 138 try { 139 content = await readFile(absolutePath, { encoding: 'utf-8' }) 140 } catch (error: unknown) { 141 const code = getErrnoCode(error) 142 let message: string 143 if (code === 'ENOENT') { 144 message = `File not found: ${absolutePath}` 145 } else if (code === 'EISDIR') { 146 message = `Path is not a file: ${absolutePath}` 147 } else { 148 message = `Failed to read file: ${errorMessage(error)}` 149 } 150 return { 151 success: false, 152 errors: [{ path: 'file', message, code }], 153 warnings: [], 154 filePath: absolutePath, 155 fileType: 'plugin', 156 } 157 } 158 159 let parsed: unknown 160 try { 161 parsed = jsonParse(content) 162 } catch (error) { 163 return { 164 success: false, 165 errors: [ 166 { 167 path: 'json', 168 message: `Invalid JSON syntax: ${errorMessage(error)}`, 169 }, 170 ], 171 warnings: [], 172 filePath: absolutePath, 173 fileType: 'plugin', 174 } 175 } 176 177 // Check for path traversal in the parsed JSON before schema validation 178 // This ensures we catch security issues even if schema validation fails 179 if (parsed && typeof parsed === 'object') { 180 const obj = parsed as Record<string, unknown> 181 182 // Check commands 183 if (obj.commands) { 184 const commands = Array.isArray(obj.commands) 185 ? obj.commands 186 : [obj.commands] 187 commands.forEach((cmd, i) => { 188 if (typeof cmd === 'string') { 189 checkPathTraversal(cmd, `commands[${i}]`, errors) 190 } 191 }) 192 } 193 194 // Check agents 195 if (obj.agents) { 196 const agents = Array.isArray(obj.agents) ? obj.agents : [obj.agents] 197 agents.forEach((agent, i) => { 198 if (typeof agent === 'string') { 199 checkPathTraversal(agent, `agents[${i}]`, errors) 200 } 201 }) 202 } 203 204 // Check skills 205 if (obj.skills) { 206 const skills = Array.isArray(obj.skills) ? obj.skills : [obj.skills] 207 skills.forEach((skill, i) => { 208 if (typeof skill === 'string') { 209 checkPathTraversal(skill, `skills[${i}]`, errors) 210 } 211 }) 212 } 213 } 214 215 // Surface marketplace-only fields as a warning BEFORE validation flags 216 // them. `claude plugin validate` is a developer tool — authors running it 217 // want to know these fields don't belong here. But it's a warning, not an 218 // error: the plugin loads fine at runtime (the base schema strips unknown 219 // keys). We strip them here so the .strict() call below doesn't double- 220 // report them as unrecognized-key errors on top of the targeted warnings. 221 let toValidate = parsed 222 if (typeof parsed === 'object' && parsed !== null) { 223 const obj = parsed as Record<string, unknown> 224 const strayKeys = Object.keys(obj).filter(k => 225 MARKETPLACE_ONLY_MANIFEST_FIELDS.has(k), 226 ) 227 if (strayKeys.length > 0) { 228 const stripped = { ...obj } 229 for (const key of strayKeys) { 230 delete stripped[key] 231 warnings.push({ 232 path: key, 233 message: 234 `Field '${key}' belongs in the marketplace entry (marketplace.json), ` + 235 `not plugin.json. It's harmless here but unused — Claude Code ` + 236 `ignores it at load time.`, 237 }) 238 } 239 toValidate = stripped 240 } 241 } 242 243 // Validate against schema (post-strip, so marketplace fields don't fail it). 244 // We call .strict() locally here even though the base schema is lenient — 245 // the runtime load path silently strips unknown keys for resilience, but 246 // this is a developer tool and authors running it want typo feedback. 247 const result = PluginManifestSchema().strict().safeParse(toValidate) 248 249 if (!result.success) { 250 errors.push(...formatZodErrors(result.error)) 251 } 252 253 // Check for common issues and add warnings 254 if (result.success) { 255 const manifest = result.data 256 257 // Warn if name isn't strict kebab-case. CC's schema only rejects spaces, 258 // but the Claude.ai marketplace sync rejects non-kebab names. Surfacing 259 // this here lets authors catch it in CI before the sync fails on them. 260 if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(manifest.name)) { 261 warnings.push({ 262 path: 'name', 263 message: 264 `Plugin name "${manifest.name}" is not kebab-case. Claude Code accepts ` + 265 `it, but the Claude.ai marketplace sync requires kebab-case ` + 266 `(lowercase letters, digits, and hyphens only, e.g., "my-plugin").`, 267 }) 268 } 269 270 // Warn if no version specified 271 if (!manifest.version) { 272 warnings.push({ 273 path: 'version', 274 message: 275 'No version specified. Consider adding a version following semver (e.g., "1.0.0")', 276 }) 277 } 278 279 // Warn if no description 280 if (!manifest.description) { 281 warnings.push({ 282 path: 'description', 283 message: 284 'No description provided. Adding a description helps users understand what your plugin does', 285 }) 286 } 287 288 // Warn if no author 289 if (!manifest.author) { 290 warnings.push({ 291 path: 'author', 292 message: 293 'No author information provided. Consider adding author details for plugin attribution', 294 }) 295 } 296 } 297 298 return { 299 success: errors.length === 0, 300 errors, 301 warnings, 302 filePath: absolutePath, 303 fileType: 'plugin', 304 } 305} 306 307/** 308 * Validate a marketplace manifest file (marketplace.json) 309 */ 310export async function validateMarketplaceManifest( 311 filePath: string, 312): Promise<ValidationResult> { 313 const errors: ValidationError[] = [] 314 const warnings: ValidationWarning[] = [] 315 const absolutePath = path.resolve(filePath) 316 317 // Read file content — handle ENOENT / EISDIR / permission errors directly 318 let content: string 319 try { 320 content = await readFile(absolutePath, { encoding: 'utf-8' }) 321 } catch (error: unknown) { 322 const code = getErrnoCode(error) 323 let message: string 324 if (code === 'ENOENT') { 325 message = `File not found: ${absolutePath}` 326 } else if (code === 'EISDIR') { 327 message = `Path is not a file: ${absolutePath}` 328 } else { 329 message = `Failed to read file: ${errorMessage(error)}` 330 } 331 return { 332 success: false, 333 errors: [{ path: 'file', message, code }], 334 warnings: [], 335 filePath: absolutePath, 336 fileType: 'marketplace', 337 } 338 } 339 340 let parsed: unknown 341 try { 342 parsed = jsonParse(content) 343 } catch (error) { 344 return { 345 success: false, 346 errors: [ 347 { 348 path: 'json', 349 message: `Invalid JSON syntax: ${errorMessage(error)}`, 350 }, 351 ], 352 warnings: [], 353 filePath: absolutePath, 354 fileType: 'marketplace', 355 } 356 } 357 358 // Check for path traversal in plugin sources before schema validation 359 // This ensures we catch security issues even if schema validation fails 360 if (parsed && typeof parsed === 'object') { 361 const obj = parsed as Record<string, unknown> 362 363 if (Array.isArray(obj.plugins)) { 364 obj.plugins.forEach((plugin: unknown, i: number) => { 365 if (plugin && typeof plugin === 'object' && 'source' in plugin) { 366 const source = (plugin as { source: unknown }).source 367 // Check string sources (relative paths) 368 if (typeof source === 'string') { 369 checkPathTraversal( 370 source, 371 `plugins[${i}].source`, 372 errors, 373 marketplaceSourceHint(source), 374 ) 375 } 376 // Check object-source .path (git-subdir: subdirectory within the 377 // remote repo, sparse-cloned). '..' here is a genuine traversal attempt 378 // within the remote repo tree, not a marketplace-root misunderstanding — 379 // keep the security framing (no marketplaceSourceHint). See #20895 review. 380 if ( 381 source && 382 typeof source === 'object' && 383 'path' in source && 384 typeof (source as { path: unknown }).path === 'string' 385 ) { 386 checkPathTraversal( 387 (source as { path: string }).path, 388 `plugins[${i}].source.path`, 389 errors, 390 ) 391 } 392 } 393 }) 394 } 395 } 396 397 // Validate against schema. 398 // The base schemas are lenient (strip unknown keys) for runtime resilience, 399 // but this is a developer tool — authors want typo feedback. We rebuild the 400 // schema with .strict() here. Note .strict() on the outer object does NOT 401 // propagate into z.array() elements, so we also override the plugins array 402 // with strict entries to catch typos inside individual plugin entries too. 403 const strictMarketplaceSchema = PluginMarketplaceSchema() 404 .extend({ 405 plugins: z.array(PluginMarketplaceEntrySchema().strict()), 406 }) 407 .strict() 408 const result = strictMarketplaceSchema.safeParse(parsed) 409 410 if (!result.success) { 411 errors.push(...formatZodErrors(result.error)) 412 } 413 414 // Check for common issues and add warnings 415 if (result.success) { 416 const marketplace = result.data 417 418 // Warn if no plugins 419 if (!marketplace.plugins || marketplace.plugins.length === 0) { 420 warnings.push({ 421 path: 'plugins', 422 message: 'Marketplace has no plugins defined', 423 }) 424 } 425 426 // Check each plugin entry 427 if (marketplace.plugins) { 428 marketplace.plugins.forEach((plugin, i) => { 429 // Check for duplicate plugin names 430 const duplicates = marketplace.plugins.filter( 431 p => p.name === plugin.name, 432 ) 433 if (duplicates.length > 1) { 434 errors.push({ 435 path: `plugins[${i}].name`, 436 message: `Duplicate plugin name "${plugin.name}" found in marketplace`, 437 }) 438 } 439 }) 440 441 // Version-mismatch check: for local-source entries that declare a 442 // version, compare against the plugin's own plugin.json. At install 443 // time, calculatePluginVersion (pluginVersioning.ts) prefers the 444 // manifest version and silently ignores the entry version — so a 445 // stale entry.version is invisible user confusion (marketplace UI 446 // shows one version, /status shows another after install). 447 // Only local sources: remote sources would need cloning to check. 448 const manifestDir = path.dirname(absolutePath) 449 const marketplaceRoot = 450 path.basename(manifestDir) === '.claude-plugin' 451 ? path.dirname(manifestDir) 452 : manifestDir 453 for (const [i, entry] of marketplace.plugins.entries()) { 454 if ( 455 !entry.version || 456 typeof entry.source !== 'string' || 457 !entry.source.startsWith('./') 458 ) { 459 continue 460 } 461 const pluginJsonPath = path.join( 462 marketplaceRoot, 463 entry.source, 464 '.claude-plugin', 465 'plugin.json', 466 ) 467 let manifestVersion: string | undefined 468 try { 469 const raw = await readFile(pluginJsonPath, { encoding: 'utf-8' }) 470 const parsed = jsonParse(raw) as { version?: unknown } 471 if (typeof parsed.version === 'string') { 472 manifestVersion = parsed.version 473 } 474 } catch { 475 // Missing/unreadable plugin.json is someone else's error to report 476 continue 477 } 478 if (manifestVersion && manifestVersion !== entry.version) { 479 warnings.push({ 480 path: `plugins[${i}].version`, 481 message: 482 `Entry declares version "${entry.version}" but ${entry.source}/.claude-plugin/plugin.json says "${manifestVersion}". ` + 483 `At install time, plugin.json wins (calculatePluginVersion precedence) — the entry version is silently ignored. ` + 484 `Update this entry to "${manifestVersion}" to match.`, 485 }) 486 } 487 } 488 } 489 490 // Warn if no description in metadata 491 if (!marketplace.metadata?.description) { 492 warnings.push({ 493 path: 'metadata.description', 494 message: 495 'No marketplace description provided. Adding a description helps users understand what this marketplace offers', 496 }) 497 } 498 } 499 500 return { 501 success: errors.length === 0, 502 errors, 503 warnings, 504 filePath: absolutePath, 505 fileType: 'marketplace', 506 } 507} 508/** 509 * Validate the YAML frontmatter in a plugin component markdown file. 510 * 511 * The runtime loader (parseFrontmatter) silently drops unparseable YAML to a 512 * debug log and returns an empty object. That's the right resilience choice 513 * for the load path, but authors running `claude plugin validate` want a hard 514 * signal. This re-parses the frontmatter block and surfaces what the loader 515 * would silently swallow. 516 */ 517function validateComponentFile( 518 filePath: string, 519 content: string, 520 fileType: 'skill' | 'agent' | 'command', 521): ValidationResult { 522 const errors: ValidationError[] = [] 523 const warnings: ValidationWarning[] = [] 524 525 const match = content.match(FRONTMATTER_REGEX) 526 if (!match) { 527 warnings.push({ 528 path: 'frontmatter', 529 message: 530 'No frontmatter block found. Add YAML frontmatter between --- delimiters ' + 531 'at the top of the file to set description and other metadata.', 532 }) 533 return { success: true, errors, warnings, filePath, fileType } 534 } 535 536 const frontmatterText = match[1] || '' 537 let parsed: unknown 538 try { 539 parsed = parseYaml(frontmatterText) 540 } catch (e) { 541 errors.push({ 542 path: 'frontmatter', 543 message: 544 `YAML frontmatter failed to parse: ${errorMessage(e)}. ` + 545 `At runtime this ${fileType} loads with empty metadata (all frontmatter ` + 546 `fields silently dropped).`, 547 }) 548 return { success: false, errors, warnings, filePath, fileType } 549 } 550 551 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { 552 errors.push({ 553 path: 'frontmatter', 554 message: 555 'Frontmatter must be a YAML mapping (key: value pairs), got ' + 556 `${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.`, 557 }) 558 return { success: false, errors, warnings, filePath, fileType } 559 } 560 561 const fm = parsed as Record<string, unknown> 562 563 // description: must be scalar. coerceDescriptionToString logs+drops arrays/objects at runtime. 564 if (fm.description !== undefined) { 565 const d = fm.description 566 if ( 567 typeof d !== 'string' && 568 typeof d !== 'number' && 569 typeof d !== 'boolean' && 570 d !== null 571 ) { 572 errors.push({ 573 path: 'description', 574 message: 575 `description must be a string, got ${Array.isArray(d) ? 'array' : typeof d}. ` + 576 `At runtime this value is dropped.`, 577 }) 578 } 579 } else { 580 warnings.push({ 581 path: 'description', 582 message: 583 `No description in frontmatter. A description helps users and Claude ` + 584 `understand when to use this ${fileType}.`, 585 }) 586 } 587 588 // name: if present, must be a string (skills/commands use it as displayName; 589 // plugin agents use it as the agentType stem — non-strings would stringify to garbage) 590 if ( 591 fm.name !== undefined && 592 fm.name !== null && 593 typeof fm.name !== 'string' 594 ) { 595 errors.push({ 596 path: 'name', 597 message: `name must be a string, got ${typeof fm.name}.`, 598 }) 599 } 600 601 // allowed-tools: string or array of strings 602 const at = fm['allowed-tools'] 603 if (at !== undefined && at !== null) { 604 if (typeof at !== 'string' && !Array.isArray(at)) { 605 errors.push({ 606 path: 'allowed-tools', 607 message: `allowed-tools must be a string or array of strings, got ${typeof at}.`, 608 }) 609 } else if (Array.isArray(at) && at.some(t => typeof t !== 'string')) { 610 errors.push({ 611 path: 'allowed-tools', 612 message: 'allowed-tools array must contain only strings.', 613 }) 614 } 615 } 616 617 // shell: 'bash' | 'powershell' (controls !`cmd` block routing) 618 const sh = fm.shell 619 if (sh !== undefined && sh !== null) { 620 if (typeof sh !== 'string') { 621 errors.push({ 622 path: 'shell', 623 message: `shell must be a string, got ${typeof sh}.`, 624 }) 625 } else { 626 // Normalize to match parseShellFrontmatter() runtime behavior — 627 // `shell: PowerShell` should not fail validation but work at runtime. 628 const normalized = sh.trim().toLowerCase() 629 if (normalized !== 'bash' && normalized !== 'powershell') { 630 errors.push({ 631 path: 'shell', 632 message: `shell must be 'bash' or 'powershell', got '${sh}'.`, 633 }) 634 } 635 } 636 } 637 638 return { success: errors.length === 0, errors, warnings, filePath, fileType } 639} 640 641/** 642 * Validate a plugin's hooks.json file. Unlike frontmatter, this one HARD-ERRORS 643 * at runtime (pluginLoader uses .parse() not .safeParse()) — a bad hooks.json 644 * breaks the whole plugin. Surfacing it here is essential. 645 */ 646async function validateHooksJson(filePath: string): Promise<ValidationResult> { 647 let content: string 648 try { 649 content = await readFile(filePath, { encoding: 'utf-8' }) 650 } catch (e: unknown) { 651 const code = getErrnoCode(e) 652 // ENOENT is fine — hooks are optional 653 if (code === 'ENOENT') { 654 return { 655 success: true, 656 errors: [], 657 warnings: [], 658 filePath, 659 fileType: 'hooks', 660 } 661 } 662 return { 663 success: false, 664 errors: [ 665 { path: 'file', message: `Failed to read file: ${errorMessage(e)}` }, 666 ], 667 warnings: [], 668 filePath, 669 fileType: 'hooks', 670 } 671 } 672 673 let parsed: unknown 674 try { 675 parsed = jsonParse(content) 676 } catch (e) { 677 return { 678 success: false, 679 errors: [ 680 { 681 path: 'json', 682 message: 683 `Invalid JSON syntax: ${errorMessage(e)}. ` + 684 `At runtime this breaks the entire plugin load.`, 685 }, 686 ], 687 warnings: [], 688 filePath, 689 fileType: 'hooks', 690 } 691 } 692 693 const result = PluginHooksSchema().safeParse(parsed) 694 if (!result.success) { 695 return { 696 success: false, 697 errors: formatZodErrors(result.error), 698 warnings: [], 699 filePath, 700 fileType: 'hooks', 701 } 702 } 703 704 return { 705 success: true, 706 errors: [], 707 warnings: [], 708 filePath, 709 fileType: 'hooks', 710 } 711} 712 713/** 714 * Recursively collect .md files under a directory. Uses withFileTypes to 715 * avoid a stat per entry. Returns absolute paths so error messages stay 716 * readable. 717 */ 718async function collectMarkdown( 719 dir: string, 720 isSkillsDir: boolean, 721): Promise<string[]> { 722 let entries: Dirent[] 723 try { 724 entries = await readdir(dir, { withFileTypes: true }) 725 } catch (e: unknown) { 726 const code = getErrnoCode(e) 727 if (code === 'ENOENT' || code === 'ENOTDIR') return [] 728 throw e 729 } 730 731 // Skills use <name>/SKILL.md — only descend one level, only collect SKILL.md. 732 // Matches the runtime loader: single .md files in skills/ are NOT loaded, 733 // and subdirectories of a skill dir aren't scanned. Paths are speculative 734 // (the subdir may lack SKILL.md); the caller handles ENOENT. 735 if (isSkillsDir) { 736 return entries 737 .filter(e => e.isDirectory()) 738 .map(e => path.join(dir, e.name, 'SKILL.md')) 739 } 740 741 // Commands/agents: recurse and collect all .md files. 742 const out: string[] = [] 743 for (const entry of entries) { 744 const full = path.join(dir, entry.name) 745 if (entry.isDirectory()) { 746 out.push(...(await collectMarkdown(full, false))) 747 } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { 748 out.push(full) 749 } 750 } 751 return out 752} 753 754/** 755 * Validate the content files inside a plugin directory — skills, agents, 756 * commands, and hooks.json. Scans the default component directories (the 757 * manifest can declare custom paths but the default layout covers the vast 758 * majority of plugins; this is a linter, not a loader). 759 * 760 * Returns one ValidationResult per file that has errors or warnings. A clean 761 * plugin returns an empty array. 762 */ 763export async function validatePluginContents( 764 pluginDir: string, 765): Promise<ValidationResult[]> { 766 const results: ValidationResult[] = [] 767 768 const dirs: Array<['skill' | 'agent' | 'command', string]> = [ 769 ['skill', path.join(pluginDir, 'skills')], 770 ['agent', path.join(pluginDir, 'agents')], 771 ['command', path.join(pluginDir, 'commands')], 772 ] 773 774 for (const [fileType, dir] of dirs) { 775 const files = await collectMarkdown(dir, fileType === 'skill') 776 for (const filePath of files) { 777 let content: string 778 try { 779 content = await readFile(filePath, { encoding: 'utf-8' }) 780 } catch (e: unknown) { 781 // ENOENT is expected for speculative skill paths (subdirs without SKILL.md) 782 if (isENOENT(e)) continue 783 results.push({ 784 success: false, 785 errors: [ 786 { path: 'file', message: `Failed to read: ${errorMessage(e)}` }, 787 ], 788 warnings: [], 789 filePath, 790 fileType, 791 }) 792 continue 793 } 794 const r = validateComponentFile(filePath, content, fileType) 795 if (r.errors.length > 0 || r.warnings.length > 0) { 796 results.push(r) 797 } 798 } 799 } 800 801 const hooksResult = await validateHooksJson( 802 path.join(pluginDir, 'hooks', 'hooks.json'), 803 ) 804 if (hooksResult.errors.length > 0 || hooksResult.warnings.length > 0) { 805 results.push(hooksResult) 806 } 807 808 return results 809} 810 811/** 812 * Validate a manifest file or directory (auto-detects type) 813 */ 814export async function validateManifest( 815 filePath: string, 816): Promise<ValidationResult> { 817 const absolutePath = path.resolve(filePath) 818 819 // Stat path to check if it's a directory — handle ENOENT inline 820 let stats: Stats | null = null 821 try { 822 stats = await stat(absolutePath) 823 } catch (e: unknown) { 824 if (!isENOENT(e)) { 825 throw e 826 } 827 } 828 829 if (stats?.isDirectory()) { 830 // Look for manifest files in .claude-plugin directory 831 // Prefer marketplace.json over plugin.json 832 const marketplacePath = path.join( 833 absolutePath, 834 '.claude-plugin', 835 'marketplace.json', 836 ) 837 const marketplaceResult = await validateMarketplaceManifest(marketplacePath) 838 // Only fall through if the marketplace file was not found (ENOENT) 839 if (marketplaceResult.errors[0]?.code !== 'ENOENT') { 840 return marketplaceResult 841 } 842 843 const pluginPath = path.join(absolutePath, '.claude-plugin', 'plugin.json') 844 const pluginResult = await validatePluginManifest(pluginPath) 845 if (pluginResult.errors[0]?.code !== 'ENOENT') { 846 return pluginResult 847 } 848 849 return { 850 success: false, 851 errors: [ 852 { 853 path: 'directory', 854 message: `No manifest found in directory. Expected .claude-plugin/marketplace.json or .claude-plugin/plugin.json`, 855 }, 856 ], 857 warnings: [], 858 filePath: absolutePath, 859 fileType: 'plugin', 860 } 861 } 862 863 const manifestType = detectManifestType(filePath) 864 865 switch (manifestType) { 866 case 'plugin': 867 return validatePluginManifest(filePath) 868 case 'marketplace': 869 return validateMarketplaceManifest(filePath) 870 case 'unknown': { 871 // Try to parse and guess based on content 872 try { 873 const content = await readFile(absolutePath, { encoding: 'utf-8' }) 874 const parsed = jsonParse(content) as Record<string, unknown> 875 876 // Heuristic: if it has a "plugins" array, it's probably a marketplace 877 if (Array.isArray(parsed.plugins)) { 878 return validateMarketplaceManifest(filePath) 879 } 880 } catch (e: unknown) { 881 const code = getErrnoCode(e) 882 if (code === 'ENOENT') { 883 return { 884 success: false, 885 errors: [ 886 { 887 path: 'file', 888 message: `File not found: ${absolutePath}`, 889 }, 890 ], 891 warnings: [], 892 filePath: absolutePath, 893 fileType: 'plugin', // Default to plugin for error reporting 894 } 895 } 896 // Fall through to default validation for other errors (e.g., JSON parse) 897 } 898 899 // Default: validate as plugin manifest 900 return validatePluginManifest(filePath) 901 } 902 } 903}