#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; import process from "node:process"; const repoRoot = process.cwd(); const errors = []; const warnings = []; const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; function addError(message) { errors.push(message); } function addWarning(message) { warnings.push(message); } async function pathExists(targetPath) { try { await fs.access(targetPath); return true; } catch { return false; } } async function readJsonFile(filePath, context) { let raw; try { raw = await fs.readFile(filePath, "utf8"); } catch { addError(`${context} is missing: ${filePath}`); return null; } try { return JSON.parse(raw); } catch (error) { addError( `${context} contains invalid JSON (${filePath}): ${error.message}` ); return null; } } function normalizeNewlines(content) { return content.replace(/\r\n/g, "\n"); } function parseFrontmatter(content) { const normalized = normalizeNewlines(content); if (!normalized.startsWith("---\n")) { return null; } const closingIndex = normalized.indexOf("\n---\n", 4); if (closingIndex === -1) { return null; } const frontmatterBlock = normalized.slice(4, closingIndex); const fields = {}; for (const line of frontmatterBlock.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } const separator = line.indexOf(":"); if (separator === -1) { continue; } const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); fields[key] = value; } return fields; } async function walkFiles(dirPath) { const files = []; const stack = [dirPath]; while (stack.length > 0) { const current = stack.pop(); const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(entryPath); } else if (entry.isFile()) { files.push(entryPath); } } } return files; } function isSafeRelativePath(value) { if (typeof value !== "string" || value.length === 0) { return false; } if (value.startsWith("http://") || value.startsWith("https://")) { return true; } if (path.isAbsolute(value)) { return false; } const normalized = path.posix.normalize(value.replace(/\\/g, "/")); return !normalized.startsWith("../") && normalized !== ".."; } function extractPathValues(value) { if (typeof value === "string") { return [value]; } if (Array.isArray(value)) { return value.flatMap((entry) => extractPathValues(entry)); } if (value && typeof value === "object") { const candidates = []; if (typeof value.path === "string") { candidates.push(value.path); } if (typeof value.file === "string") { candidates.push(value.file); } return candidates; } return []; } async function validateReferencedPath( pluginDir, fieldName, pathValue, pluginName ) { if (pathValue.startsWith("http://") || pathValue.startsWith("https://")) { return; } if (!isSafeRelativePath(pathValue)) { addError( `${pluginName}: field "${fieldName}" has invalid path "${pathValue}". Use a relative path without ".." or absolute prefixes.` ); return; } const resolved = path.resolve(pluginDir, pathValue); const exists = await pathExists(resolved); if (!exists) { addError( `${pluginName}: field "${fieldName}" references missing path "${pathValue}".` ); } } async function validateFrontmatterFile( filePath, componentName, requiredKeys, pluginName ) { const content = await fs.readFile(filePath, "utf8"); const parsed = parseFrontmatter(content); const relativeFile = path.relative(repoRoot, filePath); if (!parsed) { addError( `${pluginName}: ${componentName} file missing YAML frontmatter: ${relativeFile}` ); return; } for (const key of requiredKeys) { if (!parsed[key] || parsed[key].length === 0) { addError( `${pluginName}: ${componentName} file missing "${key}" in frontmatter: ${relativeFile}` ); } } } async function validateComponentFrontmatter(pluginDir, pluginName) { const rulesDir = path.join(pluginDir, "rules"); if (await pathExists(rulesDir)) { const files = await walkFiles(rulesDir); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (ext === ".md" || ext === ".mdc" || ext === ".markdown") { await validateFrontmatterFile( file, "rule", ["description"], pluginName ); } } } const skillsDir = path.join(pluginDir, "skills"); if (await pathExists(skillsDir)) { const files = await walkFiles(skillsDir); for (const file of files) { if (path.basename(file) === "SKILL.md") { await validateFrontmatterFile( file, "skill", ["name", "description"], pluginName ); } } } const commandsDir = path.join(pluginDir, "commands"); if (await pathExists(commandsDir)) { const files = await walkFiles(commandsDir); for (const file of files) { const ext = path.extname(file).toLowerCase(); if ( ext === ".md" || ext === ".mdc" || ext === ".markdown" || ext === ".txt" ) { await validateFrontmatterFile( file, "command", ["name", "description"], pluginName ); } } } } function resolveMarketplaceSource(source) { if (typeof source !== "string" || source.length === 0) { return null; } return source; } async function main() { const marketplacePath = path.join( repoRoot, ".cursor-plugin", "marketplace.json" ); const marketplace = await readJsonFile(marketplacePath, "Marketplace manifest"); if (!marketplace) { summarizeAndExit(); return; } if ( typeof marketplace.name !== "string" || !marketplaceNamePattern.test(marketplace.name) ) { addError( 'Marketplace "name" must be lowercase kebab-case and start/end with an alphanumeric character.' ); } if ( !marketplace.owner || typeof marketplace.owner.name !== "string" || marketplace.owner.name.length === 0 ) { addError('Marketplace "owner.name" is required.'); } if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) { addError('Marketplace "plugins" must be a non-empty array.'); summarizeAndExit(); return; } const seenNames = new Set(); for (const [index, entry] of marketplace.plugins.entries()) { const label = `plugins[${index}]`; if (!entry || typeof entry !== "object") { addError(`${label} must be an object.`); continue; } if ( typeof entry.name !== "string" || !pluginNamePattern.test(entry.name) ) { addError( `${label}.name must be lowercase and use only alphanumerics, hyphens, and periods.` ); continue; } if (seenNames.has(entry.name)) { addError(`Duplicate plugin name in marketplace manifest: "${entry.name}"`); } seenNames.add(entry.name); const sourcePath = resolveMarketplaceSource(entry.source); if (!sourcePath) { addError(`${label}.source must be a string path.`); continue; } if (!isSafeRelativePath(sourcePath)) { addError(`${label}.source is not a safe relative path: "${sourcePath}"`); continue; } const pluginDir = path.join(repoRoot, sourcePath); const pluginDirExists = await pathExists(pluginDir); if (!pluginDirExists) { addError(`${label}.source directory does not exist: ${sourcePath}`); continue; } const manifestPath = path.join(pluginDir, ".cursor-plugin", "plugin.json"); const pluginManifest = await readJsonFile( manifestPath, `${entry.name} plugin manifest` ); if (!pluginManifest) { continue; } if ( typeof pluginManifest.name !== "string" || !pluginNamePattern.test(pluginManifest.name) ) { addError( `${entry.name}: "name" in plugin.json must be lowercase and use only alphanumerics, hyphens, and periods.` ); } if (pluginManifest.name && pluginManifest.name !== entry.name) { addError( `${entry.name}: marketplace entry name does not match plugin.json name ("${pluginManifest.name}").` ); } const manifestFields = [ "logo", "rules", "skills", "agents", "commands", "hooks", "mcpServers", ]; for (const field of manifestFields) { const values = extractPathValues(pluginManifest[field]); for (const value of values) { await validateReferencedPath(pluginDir, field, value, entry.name); } } await validateComponentFrontmatter(pluginDir, entry.name); } summarizeAndExit(); } function summarizeAndExit() { if (warnings.length > 0) { console.log("Warnings:"); for (const warning of warnings) { console.log(` - ${warning}`); } console.log(""); } if (errors.length > 0) { console.error("Validation failed:"); for (const error of errors) { console.error(` - ${error}`); } process.exit(1); } console.log("Cursor plugin validation passed."); } await main();