/ utils / plugins / validatePlugin.ts
validatePlugin.ts
  1  import type { Dirent, Stats } from 'fs'
  2  import { readdir, readFile, stat } from 'fs/promises'
  3  import * as path from 'path'
  4  import { z } from 'zod/v4'
  5  import { errorMessage, getErrnoCode, isENOENT } from '../errors.js'
  6  import { FRONTMATTER_REGEX } from '../frontmatterParser.js'
  7  import { jsonParse } from '../slowOperations.js'
  8  import { parseYaml } from '../yaml.js'
  9  import {
 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   */
 24  const MARKETPLACE_ONLY_MANIFEST_FIELDS = new Set([
 25    'category',
 26    'source',
 27    'tags',
 28    'strict',
 29    'id',
 30  ])
 31  
 32  export type ValidationResult = {
 33    success: boolean
 34    errors: ValidationError[]
 35    warnings: ValidationWarning[]
 36    filePath: string
 37    fileType: 'plugin' | 'marketplace' | 'skill' | 'agent' | 'command' | 'hooks'
 38  }
 39  
 40  export type ValidationError = {
 41    path: string
 42    message: string
 43    code?: string
 44  }
 45  
 46  export type ValidationWarning = {
 47    path: string
 48    message: string
 49  }
 50  
 51  /**
 52   * Detect whether a file is a plugin manifest or marketplace manifest
 53   */
 54  function 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   */
 75  function 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   */
 92  function 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).
113  function 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   */
129  export 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   */
310  export 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   */
517  function 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   */
646  async 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   */
718  async 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   */
763  export 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   */
814  export 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  }