904 lines
28 KiB
TypeScript
904 lines
28 KiB
TypeScript
import type { Dirent, Stats } from 'fs'
|
|
import { readdir, readFile, stat } from 'fs/promises'
|
|
import * as path from 'path'
|
|
import { z } from 'zod/v4'
|
|
import { errorMessage, getErrnoCode, isENOENT } from '../errors.js'
|
|
import { FRONTMATTER_REGEX } from '../frontmatterParser.js'
|
|
import { jsonParse } from '../slowOperations.js'
|
|
import { parseYaml } from '../yaml.js'
|
|
import {
|
|
PluginHooksSchema,
|
|
PluginManifestSchema,
|
|
PluginMarketplaceEntrySchema,
|
|
PluginMarketplaceSchema,
|
|
} from './schemas.js'
|
|
|
|
/**
|
|
* Fields that belong in marketplace.json entries (PluginMarketplaceEntrySchema)
|
|
* but not plugin.json (PluginManifestSchema). Plugin authors reasonably copy
|
|
* one into the other. Surfaced as warnings by `claude plugin validate` since
|
|
* they're a known confusion point — the load path silently strips all unknown
|
|
* keys via zod's default behavior, so they're harmless at runtime but worth
|
|
* flagging to authors.
|
|
*/
|
|
const MARKETPLACE_ONLY_MANIFEST_FIELDS = new Set([
|
|
'category',
|
|
'source',
|
|
'tags',
|
|
'strict',
|
|
'id',
|
|
])
|
|
|
|
export type ValidationResult = {
|
|
success: boolean
|
|
errors: ValidationError[]
|
|
warnings: ValidationWarning[]
|
|
filePath: string
|
|
fileType: 'plugin' | 'marketplace' | 'skill' | 'agent' | 'command' | 'hooks'
|
|
}
|
|
|
|
export type ValidationError = {
|
|
path: string
|
|
message: string
|
|
code?: string
|
|
}
|
|
|
|
export type ValidationWarning = {
|
|
path: string
|
|
message: string
|
|
}
|
|
|
|
/**
|
|
* Detect whether a file is a plugin manifest or marketplace manifest
|
|
*/
|
|
function detectManifestType(
|
|
filePath: string,
|
|
): 'plugin' | 'marketplace' | 'unknown' {
|
|
const fileName = path.basename(filePath)
|
|
const dirName = path.basename(path.dirname(filePath))
|
|
|
|
// Check filename patterns
|
|
if (fileName === 'plugin.json') return 'plugin'
|
|
if (fileName === 'marketplace.json') return 'marketplace'
|
|
|
|
// Check if it's in .claude-plugin directory
|
|
if (dirName === '.claude-plugin') {
|
|
return 'plugin' // Most likely plugin.json
|
|
}
|
|
|
|
return 'unknown'
|
|
}
|
|
|
|
/**
|
|
* Format Zod validation errors into a readable format
|
|
*/
|
|
function formatZodErrors(zodError: z.ZodError): ValidationError[] {
|
|
return zodError.issues.map(error => ({
|
|
path: error.path.join('.') || 'root',
|
|
message: error.message,
|
|
code: error.code,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Check for parent-directory segments ('..') in a path string.
|
|
*
|
|
* For plugin.json component paths this is a security concern (escaping the plugin dir).
|
|
* For marketplace.json source paths it's almost always a resolution-base misunderstanding:
|
|
* paths resolve from the marketplace repo root, not from marketplace.json itself, so the
|
|
* '..' a user added to "climb out of .claude-plugin/" is unnecessary. Callers pass `hint`
|
|
* to attach the right explanation.
|
|
*/
|
|
function checkPathTraversal(
|
|
p: string,
|
|
field: string,
|
|
errors: ValidationError[],
|
|
hint?: string,
|
|
): void {
|
|
if (p.includes('..')) {
|
|
errors.push({
|
|
path: field,
|
|
message: hint
|
|
? `Path contains "..": ${p}. ${hint}`
|
|
: `Path contains ".." which could be a path traversal attempt: ${p}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Shown when a marketplace plugin source contains '..'. Most users hit this because
|
|
// they expect paths to resolve relative to marketplace.json (inside .claude-plugin/),
|
|
// but resolution actually starts at the marketplace repo root — see gh-29485.
|
|
// Computes a tailored "use X instead of Y" suggestion from the user's actual path
|
|
// rather than a hardcoded example (review feedback on #20895).
|
|
function marketplaceSourceHint(p: string): string {
|
|
// Strip leading ../ segments: the '..' a user added to "climb out of
|
|
// .claude-plugin/" is unnecessary since paths already start at the repo root.
|
|
// If '..' appears mid-path (rare), fall back to a generic example.
|
|
const stripped = p.replace(/^(\.\.\/)+/, '')
|
|
const corrected = stripped !== p ? `./${stripped}` : './plugins/my-plugin'
|
|
return (
|
|
'Plugin source paths are resolved relative to the marketplace root (the directory ' +
|
|
'containing .claude-plugin/), not relative to marketplace.json. ' +
|
|
`Use "${corrected}" instead of "${p}".`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Validate a plugin manifest file (plugin.json)
|
|
*/
|
|
export async function validatePluginManifest(
|
|
filePath: string,
|
|
): Promise<ValidationResult> {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
const absolutePath = path.resolve(filePath)
|
|
|
|
// Read file content — handle ENOENT / EISDIR / permission errors directly
|
|
let content: string
|
|
try {
|
|
content = await readFile(absolutePath, { encoding: 'utf-8' })
|
|
} catch (error: unknown) {
|
|
const code = getErrnoCode(error)
|
|
let message: string
|
|
if (code === 'ENOENT') {
|
|
message = `File not found: ${absolutePath}`
|
|
} else if (code === 'EISDIR') {
|
|
message = `Path is not a file: ${absolutePath}`
|
|
} else {
|
|
message = `Failed to read file: ${errorMessage(error)}`
|
|
}
|
|
return {
|
|
success: false,
|
|
errors: [{ path: 'file', message, code }],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'plugin',
|
|
}
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = jsonParse(content)
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{
|
|
path: 'json',
|
|
message: `Invalid JSON syntax: ${errorMessage(error)}`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'plugin',
|
|
}
|
|
}
|
|
|
|
// Check for path traversal in the parsed JSON before schema validation
|
|
// This ensures we catch security issues even if schema validation fails
|
|
if (parsed && typeof parsed === 'object') {
|
|
const obj = parsed as Record<string, unknown>
|
|
|
|
// Check commands
|
|
if (obj.commands) {
|
|
const commands = Array.isArray(obj.commands)
|
|
? obj.commands
|
|
: [obj.commands]
|
|
commands.forEach((cmd, i) => {
|
|
if (typeof cmd === 'string') {
|
|
checkPathTraversal(cmd, `commands[${i}]`, errors)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Check agents
|
|
if (obj.agents) {
|
|
const agents = Array.isArray(obj.agents) ? obj.agents : [obj.agents]
|
|
agents.forEach((agent, i) => {
|
|
if (typeof agent === 'string') {
|
|
checkPathTraversal(agent, `agents[${i}]`, errors)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Check skills
|
|
if (obj.skills) {
|
|
const skills = Array.isArray(obj.skills) ? obj.skills : [obj.skills]
|
|
skills.forEach((skill, i) => {
|
|
if (typeof skill === 'string') {
|
|
checkPathTraversal(skill, `skills[${i}]`, errors)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Surface marketplace-only fields as a warning BEFORE validation flags
|
|
// them. `claude plugin validate` is a developer tool — authors running it
|
|
// want to know these fields don't belong here. But it's a warning, not an
|
|
// error: the plugin loads fine at runtime (the base schema strips unknown
|
|
// keys). We strip them here so the .strict() call below doesn't double-
|
|
// report them as unrecognized-key errors on top of the targeted warnings.
|
|
let toValidate = parsed
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
const obj = parsed as Record<string, unknown>
|
|
const strayKeys = Object.keys(obj).filter(k =>
|
|
MARKETPLACE_ONLY_MANIFEST_FIELDS.has(k),
|
|
)
|
|
if (strayKeys.length > 0) {
|
|
const stripped = { ...obj }
|
|
for (const key of strayKeys) {
|
|
delete stripped[key]
|
|
warnings.push({
|
|
path: key,
|
|
message:
|
|
`Field '${key}' belongs in the marketplace entry (marketplace.json), ` +
|
|
`not plugin.json. It's harmless here but unused — Claude Code ` +
|
|
`ignores it at load time.`,
|
|
})
|
|
}
|
|
toValidate = stripped
|
|
}
|
|
}
|
|
|
|
// Validate against schema (post-strip, so marketplace fields don't fail it).
|
|
// We call .strict() locally here even though the base schema is lenient —
|
|
// the runtime load path silently strips unknown keys for resilience, but
|
|
// this is a developer tool and authors running it want typo feedback.
|
|
const result = PluginManifestSchema().strict().safeParse(toValidate)
|
|
|
|
if (!result.success) {
|
|
errors.push(...formatZodErrors(result.error))
|
|
}
|
|
|
|
// Check for common issues and add warnings
|
|
if (result.success) {
|
|
const manifest = result.data
|
|
|
|
// Warn if name isn't strict kebab-case. CC's schema only rejects spaces,
|
|
// but the Claude.ai marketplace sync rejects non-kebab names. Surfacing
|
|
// this here lets authors catch it in CI before the sync fails on them.
|
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(manifest.name)) {
|
|
warnings.push({
|
|
path: 'name',
|
|
message:
|
|
`Plugin name "${manifest.name}" is not kebab-case. Claude Code accepts ` +
|
|
`it, but the Claude.ai marketplace sync requires kebab-case ` +
|
|
`(lowercase letters, digits, and hyphens only, e.g., "my-plugin").`,
|
|
})
|
|
}
|
|
|
|
// Warn if no version specified
|
|
if (!manifest.version) {
|
|
warnings.push({
|
|
path: 'version',
|
|
message:
|
|
'No version specified. Consider adding a version following semver (e.g., "1.0.0")',
|
|
})
|
|
}
|
|
|
|
// Warn if no description
|
|
if (!manifest.description) {
|
|
warnings.push({
|
|
path: 'description',
|
|
message:
|
|
'No description provided. Adding a description helps users understand what your plugin does',
|
|
})
|
|
}
|
|
|
|
// Warn if no author
|
|
if (!manifest.author) {
|
|
warnings.push({
|
|
path: 'author',
|
|
message:
|
|
'No author information provided. Consider adding author details for plugin attribution',
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
filePath: absolutePath,
|
|
fileType: 'plugin',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a marketplace manifest file (marketplace.json)
|
|
*/
|
|
export async function validateMarketplaceManifest(
|
|
filePath: string,
|
|
): Promise<ValidationResult> {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
const absolutePath = path.resolve(filePath)
|
|
|
|
// Read file content — handle ENOENT / EISDIR / permission errors directly
|
|
let content: string
|
|
try {
|
|
content = await readFile(absolutePath, { encoding: 'utf-8' })
|
|
} catch (error: unknown) {
|
|
const code = getErrnoCode(error)
|
|
let message: string
|
|
if (code === 'ENOENT') {
|
|
message = `File not found: ${absolutePath}`
|
|
} else if (code === 'EISDIR') {
|
|
message = `Path is not a file: ${absolutePath}`
|
|
} else {
|
|
message = `Failed to read file: ${errorMessage(error)}`
|
|
}
|
|
return {
|
|
success: false,
|
|
errors: [{ path: 'file', message, code }],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'marketplace',
|
|
}
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = jsonParse(content)
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{
|
|
path: 'json',
|
|
message: `Invalid JSON syntax: ${errorMessage(error)}`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'marketplace',
|
|
}
|
|
}
|
|
|
|
// Check for path traversal in plugin sources before schema validation
|
|
// This ensures we catch security issues even if schema validation fails
|
|
if (parsed && typeof parsed === 'object') {
|
|
const obj = parsed as Record<string, unknown>
|
|
|
|
if (Array.isArray(obj.plugins)) {
|
|
obj.plugins.forEach((plugin: unknown, i: number) => {
|
|
if (plugin && typeof plugin === 'object' && 'source' in plugin) {
|
|
const source = (plugin as { source: unknown }).source
|
|
// Check string sources (relative paths)
|
|
if (typeof source === 'string') {
|
|
checkPathTraversal(
|
|
source,
|
|
`plugins[${i}].source`,
|
|
errors,
|
|
marketplaceSourceHint(source),
|
|
)
|
|
}
|
|
// Check object-source .path (git-subdir: subdirectory within the
|
|
// remote repo, sparse-cloned). '..' here is a genuine traversal attempt
|
|
// within the remote repo tree, not a marketplace-root misunderstanding —
|
|
// keep the security framing (no marketplaceSourceHint). See #20895 review.
|
|
if (
|
|
source &&
|
|
typeof source === 'object' &&
|
|
'path' in source &&
|
|
typeof (source as { path: unknown }).path === 'string'
|
|
) {
|
|
checkPathTraversal(
|
|
(source as { path: string }).path,
|
|
`plugins[${i}].source.path`,
|
|
errors,
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Validate against schema.
|
|
// The base schemas are lenient (strip unknown keys) for runtime resilience,
|
|
// but this is a developer tool — authors want typo feedback. We rebuild the
|
|
// schema with .strict() here. Note .strict() on the outer object does NOT
|
|
// propagate into z.array() elements, so we also override the plugins array
|
|
// with strict entries to catch typos inside individual plugin entries too.
|
|
const strictMarketplaceSchema = PluginMarketplaceSchema()
|
|
.extend({
|
|
plugins: z.array(PluginMarketplaceEntrySchema().strict()),
|
|
})
|
|
.strict()
|
|
const result = strictMarketplaceSchema.safeParse(parsed)
|
|
|
|
if (!result.success) {
|
|
errors.push(...formatZodErrors(result.error))
|
|
}
|
|
|
|
// Check for common issues and add warnings
|
|
if (result.success) {
|
|
const marketplace = result.data
|
|
|
|
// Warn if no plugins
|
|
if (!marketplace.plugins || marketplace.plugins.length === 0) {
|
|
warnings.push({
|
|
path: 'plugins',
|
|
message: 'Marketplace has no plugins defined',
|
|
})
|
|
}
|
|
|
|
// Check each plugin entry
|
|
if (marketplace.plugins) {
|
|
marketplace.plugins.forEach((plugin, i) => {
|
|
// Check for duplicate plugin names
|
|
const duplicates = marketplace.plugins.filter(
|
|
p => p.name === plugin.name,
|
|
)
|
|
if (duplicates.length > 1) {
|
|
errors.push({
|
|
path: `plugins[${i}].name`,
|
|
message: `Duplicate plugin name "${plugin.name}" found in marketplace`,
|
|
})
|
|
}
|
|
})
|
|
|
|
// Version-mismatch check: for local-source entries that declare a
|
|
// version, compare against the plugin's own plugin.json. At install
|
|
// time, calculatePluginVersion (pluginVersioning.ts) prefers the
|
|
// manifest version and silently ignores the entry version — so a
|
|
// stale entry.version is invisible user confusion (marketplace UI
|
|
// shows one version, /status shows another after install).
|
|
// Only local sources: remote sources would need cloning to check.
|
|
const manifestDir = path.dirname(absolutePath)
|
|
const marketplaceRoot =
|
|
path.basename(manifestDir) === '.claude-plugin'
|
|
? path.dirname(manifestDir)
|
|
: manifestDir
|
|
for (const [i, entry] of marketplace.plugins.entries()) {
|
|
if (
|
|
!entry.version ||
|
|
typeof entry.source !== 'string' ||
|
|
!entry.source.startsWith('./')
|
|
) {
|
|
continue
|
|
}
|
|
const pluginJsonPath = path.join(
|
|
marketplaceRoot,
|
|
entry.source,
|
|
'.claude-plugin',
|
|
'plugin.json',
|
|
)
|
|
let manifestVersion: string | undefined
|
|
try {
|
|
const raw = await readFile(pluginJsonPath, { encoding: 'utf-8' })
|
|
const parsed = jsonParse(raw) as { version?: unknown }
|
|
if (typeof parsed.version === 'string') {
|
|
manifestVersion = parsed.version
|
|
}
|
|
} catch {
|
|
// Missing/unreadable plugin.json is someone else's error to report
|
|
continue
|
|
}
|
|
if (manifestVersion && manifestVersion !== entry.version) {
|
|
warnings.push({
|
|
path: `plugins[${i}].version`,
|
|
message:
|
|
`Entry declares version "${entry.version}" but ${entry.source}/.claude-plugin/plugin.json says "${manifestVersion}". ` +
|
|
`At install time, plugin.json wins (calculatePluginVersion precedence) — the entry version is silently ignored. ` +
|
|
`Update this entry to "${manifestVersion}" to match.`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warn if no description in metadata
|
|
if (!marketplace.metadata?.description) {
|
|
warnings.push({
|
|
path: 'metadata.description',
|
|
message:
|
|
'No marketplace description provided. Adding a description helps users understand what this marketplace offers',
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
filePath: absolutePath,
|
|
fileType: 'marketplace',
|
|
}
|
|
}
|
|
/**
|
|
* Validate the YAML frontmatter in a plugin component markdown file.
|
|
*
|
|
* The runtime loader (parseFrontmatter) silently drops unparseable YAML to a
|
|
* debug log and returns an empty object. That's the right resilience choice
|
|
* for the load path, but authors running `claude plugin validate` want a hard
|
|
* signal. This re-parses the frontmatter block and surfaces what the loader
|
|
* would silently swallow.
|
|
*/
|
|
function validateComponentFile(
|
|
filePath: string,
|
|
content: string,
|
|
fileType: 'skill' | 'agent' | 'command',
|
|
): ValidationResult {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
|
|
const match = content.match(FRONTMATTER_REGEX)
|
|
if (!match) {
|
|
warnings.push({
|
|
path: 'frontmatter',
|
|
message:
|
|
'No frontmatter block found. Add YAML frontmatter between --- delimiters ' +
|
|
'at the top of the file to set description and other metadata.',
|
|
})
|
|
return { success: true, errors, warnings, filePath, fileType }
|
|
}
|
|
|
|
const frontmatterText = match[1] || ''
|
|
let parsed: unknown
|
|
try {
|
|
parsed = parseYaml(frontmatterText)
|
|
} catch (e) {
|
|
errors.push({
|
|
path: 'frontmatter',
|
|
message:
|
|
`YAML frontmatter failed to parse: ${errorMessage(e)}. ` +
|
|
`At runtime this ${fileType} loads with empty metadata (all frontmatter ` +
|
|
`fields silently dropped).`,
|
|
})
|
|
return { success: false, errors, warnings, filePath, fileType }
|
|
}
|
|
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
errors.push({
|
|
path: 'frontmatter',
|
|
message:
|
|
'Frontmatter must be a YAML mapping (key: value pairs), got ' +
|
|
`${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.`,
|
|
})
|
|
return { success: false, errors, warnings, filePath, fileType }
|
|
}
|
|
|
|
const fm = parsed as Record<string, unknown>
|
|
|
|
// description: must be scalar. coerceDescriptionToString logs+drops arrays/objects at runtime.
|
|
if (fm.description !== undefined) {
|
|
const d = fm.description
|
|
if (
|
|
typeof d !== 'string' &&
|
|
typeof d !== 'number' &&
|
|
typeof d !== 'boolean' &&
|
|
d !== null
|
|
) {
|
|
errors.push({
|
|
path: 'description',
|
|
message:
|
|
`description must be a string, got ${Array.isArray(d) ? 'array' : typeof d}. ` +
|
|
`At runtime this value is dropped.`,
|
|
})
|
|
}
|
|
} else {
|
|
warnings.push({
|
|
path: 'description',
|
|
message:
|
|
`No description in frontmatter. A description helps users and Claude ` +
|
|
`understand when to use this ${fileType}.`,
|
|
})
|
|
}
|
|
|
|
// name: if present, must be a string (skills/commands use it as displayName;
|
|
// plugin agents use it as the agentType stem — non-strings would stringify to garbage)
|
|
if (
|
|
fm.name !== undefined &&
|
|
fm.name !== null &&
|
|
typeof fm.name !== 'string'
|
|
) {
|
|
errors.push({
|
|
path: 'name',
|
|
message: `name must be a string, got ${typeof fm.name}.`,
|
|
})
|
|
}
|
|
|
|
// allowed-tools: string or array of strings
|
|
const at = fm['allowed-tools']
|
|
if (at !== undefined && at !== null) {
|
|
if (typeof at !== 'string' && !Array.isArray(at)) {
|
|
errors.push({
|
|
path: 'allowed-tools',
|
|
message: `allowed-tools must be a string or array of strings, got ${typeof at}.`,
|
|
})
|
|
} else if (Array.isArray(at) && at.some(t => typeof t !== 'string')) {
|
|
errors.push({
|
|
path: 'allowed-tools',
|
|
message: 'allowed-tools array must contain only strings.',
|
|
})
|
|
}
|
|
}
|
|
|
|
// shell: 'bash' | 'powershell' (controls !`cmd` block routing)
|
|
const sh = fm.shell
|
|
if (sh !== undefined && sh !== null) {
|
|
if (typeof sh !== 'string') {
|
|
errors.push({
|
|
path: 'shell',
|
|
message: `shell must be a string, got ${typeof sh}.`,
|
|
})
|
|
} else {
|
|
// Normalize to match parseShellFrontmatter() runtime behavior —
|
|
// `shell: PowerShell` should not fail validation but work at runtime.
|
|
const normalized = sh.trim().toLowerCase()
|
|
if (normalized !== 'bash' && normalized !== 'powershell') {
|
|
errors.push({
|
|
path: 'shell',
|
|
message: `shell must be 'bash' or 'powershell', got '${sh}'.`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, errors, warnings, filePath, fileType }
|
|
}
|
|
|
|
/**
|
|
* Validate a plugin's hooks.json file. Unlike frontmatter, this one HARD-ERRORS
|
|
* at runtime (pluginLoader uses .parse() not .safeParse()) — a bad hooks.json
|
|
* breaks the whole plugin. Surfacing it here is essential.
|
|
*/
|
|
async function validateHooksJson(filePath: string): Promise<ValidationResult> {
|
|
let content: string
|
|
try {
|
|
content = await readFile(filePath, { encoding: 'utf-8' })
|
|
} catch (e: unknown) {
|
|
const code = getErrnoCode(e)
|
|
// ENOENT is fine — hooks are optional
|
|
if (code === 'ENOENT') {
|
|
return {
|
|
success: true,
|
|
errors: [],
|
|
warnings: [],
|
|
filePath,
|
|
fileType: 'hooks',
|
|
}
|
|
}
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{ path: 'file', message: `Failed to read file: ${errorMessage(e)}` },
|
|
],
|
|
warnings: [],
|
|
filePath,
|
|
fileType: 'hooks',
|
|
}
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = jsonParse(content)
|
|
} catch (e) {
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{
|
|
path: 'json',
|
|
message:
|
|
`Invalid JSON syntax: ${errorMessage(e)}. ` +
|
|
`At runtime this breaks the entire plugin load.`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
filePath,
|
|
fileType: 'hooks',
|
|
}
|
|
}
|
|
|
|
const result = PluginHooksSchema().safeParse(parsed)
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
errors: formatZodErrors(result.error),
|
|
warnings: [],
|
|
filePath,
|
|
fileType: 'hooks',
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
errors: [],
|
|
warnings: [],
|
|
filePath,
|
|
fileType: 'hooks',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively collect .md files under a directory. Uses withFileTypes to
|
|
* avoid a stat per entry. Returns absolute paths so error messages stay
|
|
* readable.
|
|
*/
|
|
async function collectMarkdown(
|
|
dir: string,
|
|
isSkillsDir: boolean,
|
|
): Promise<string[]> {
|
|
let entries: Dirent[]
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true })
|
|
} catch (e: unknown) {
|
|
const code = getErrnoCode(e)
|
|
if (code === 'ENOENT' || code === 'ENOTDIR') return []
|
|
throw e
|
|
}
|
|
|
|
// Skills use <name>/SKILL.md — only descend one level, only collect SKILL.md.
|
|
// Matches the runtime loader: single .md files in skills/ are NOT loaded,
|
|
// and subdirectories of a skill dir aren't scanned. Paths are speculative
|
|
// (the subdir may lack SKILL.md); the caller handles ENOENT.
|
|
if (isSkillsDir) {
|
|
return entries
|
|
.filter(e => e.isDirectory())
|
|
.map(e => path.join(dir, e.name, 'SKILL.md'))
|
|
}
|
|
|
|
// Commands/agents: recurse and collect all .md files.
|
|
const out: string[] = []
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
out.push(...(await collectMarkdown(full, false)))
|
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
out.push(full)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Validate the content files inside a plugin directory — skills, agents,
|
|
* commands, and hooks.json. Scans the default component directories (the
|
|
* manifest can declare custom paths but the default layout covers the vast
|
|
* majority of plugins; this is a linter, not a loader).
|
|
*
|
|
* Returns one ValidationResult per file that has errors or warnings. A clean
|
|
* plugin returns an empty array.
|
|
*/
|
|
export async function validatePluginContents(
|
|
pluginDir: string,
|
|
): Promise<ValidationResult[]> {
|
|
const results: ValidationResult[] = []
|
|
|
|
const dirs: Array<['skill' | 'agent' | 'command', string]> = [
|
|
['skill', path.join(pluginDir, 'skills')],
|
|
['agent', path.join(pluginDir, 'agents')],
|
|
['command', path.join(pluginDir, 'commands')],
|
|
]
|
|
|
|
for (const [fileType, dir] of dirs) {
|
|
const files = await collectMarkdown(dir, fileType === 'skill')
|
|
for (const filePath of files) {
|
|
let content: string
|
|
try {
|
|
content = await readFile(filePath, { encoding: 'utf-8' })
|
|
} catch (e: unknown) {
|
|
// ENOENT is expected for speculative skill paths (subdirs without SKILL.md)
|
|
if (isENOENT(e)) continue
|
|
results.push({
|
|
success: false,
|
|
errors: [
|
|
{ path: 'file', message: `Failed to read: ${errorMessage(e)}` },
|
|
],
|
|
warnings: [],
|
|
filePath,
|
|
fileType,
|
|
})
|
|
continue
|
|
}
|
|
const r = validateComponentFile(filePath, content, fileType)
|
|
if (r.errors.length > 0 || r.warnings.length > 0) {
|
|
results.push(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
const hooksResult = await validateHooksJson(
|
|
path.join(pluginDir, 'hooks', 'hooks.json'),
|
|
)
|
|
if (hooksResult.errors.length > 0 || hooksResult.warnings.length > 0) {
|
|
results.push(hooksResult)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* Validate a manifest file or directory (auto-detects type)
|
|
*/
|
|
export async function validateManifest(
|
|
filePath: string,
|
|
): Promise<ValidationResult> {
|
|
const absolutePath = path.resolve(filePath)
|
|
|
|
// Stat path to check if it's a directory — handle ENOENT inline
|
|
let stats: Stats | null = null
|
|
try {
|
|
stats = await stat(absolutePath)
|
|
} catch (e: unknown) {
|
|
if (!isENOENT(e)) {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (stats?.isDirectory()) {
|
|
// Look for manifest files in .claude-plugin directory
|
|
// Prefer marketplace.json over plugin.json
|
|
const marketplacePath = path.join(
|
|
absolutePath,
|
|
'.claude-plugin',
|
|
'marketplace.json',
|
|
)
|
|
const marketplaceResult = await validateMarketplaceManifest(marketplacePath)
|
|
// Only fall through if the marketplace file was not found (ENOENT)
|
|
if (marketplaceResult.errors[0]?.code !== 'ENOENT') {
|
|
return marketplaceResult
|
|
}
|
|
|
|
const pluginPath = path.join(absolutePath, '.claude-plugin', 'plugin.json')
|
|
const pluginResult = await validatePluginManifest(pluginPath)
|
|
if (pluginResult.errors[0]?.code !== 'ENOENT') {
|
|
return pluginResult
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{
|
|
path: 'directory',
|
|
message: `No manifest found in directory. Expected .claude-plugin/marketplace.json or .claude-plugin/plugin.json`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'plugin',
|
|
}
|
|
}
|
|
|
|
const manifestType = detectManifestType(filePath)
|
|
|
|
switch (manifestType) {
|
|
case 'plugin':
|
|
return validatePluginManifest(filePath)
|
|
case 'marketplace':
|
|
return validateMarketplaceManifest(filePath)
|
|
case 'unknown': {
|
|
// Try to parse and guess based on content
|
|
try {
|
|
const content = await readFile(absolutePath, { encoding: 'utf-8' })
|
|
const parsed = jsonParse(content) as Record<string, unknown>
|
|
|
|
// Heuristic: if it has a "plugins" array, it's probably a marketplace
|
|
if (Array.isArray(parsed.plugins)) {
|
|
return validateMarketplaceManifest(filePath)
|
|
}
|
|
} catch (e: unknown) {
|
|
const code = getErrnoCode(e)
|
|
if (code === 'ENOENT') {
|
|
return {
|
|
success: false,
|
|
errors: [
|
|
{
|
|
path: 'file',
|
|
message: `File not found: ${absolutePath}`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
filePath: absolutePath,
|
|
fileType: 'plugin', // Default to plugin for error reporting
|
|
}
|
|
}
|
|
// Fall through to default validation for other errors (e.g., JSON parse)
|
|
}
|
|
|
|
// Default: validate as plugin manifest
|
|
return validatePluginManifest(filePath)
|
|
}
|
|
}
|
|
}
|