UNPKG

@capgo/capgo-skills

Version:

55 agent skills for Capacitor mobile development

310 lines (263 loc) • 10.8 kB
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { buildAgentManifests } from './agent-manifests.mjs'; const root = process.cwd(); const skillsDir = path.join(root, 'skills'); function parseFrontmatter(text) { const match = text.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const frontmatter = {}; for (const line of match[1].split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const colon = trimmed.indexOf(':'); if (colon === -1) continue; const key = trimmed.slice(0, colon).trim(); const value = trimmed.slice(colon + 1).trim().replace(/^["']|["']$/g, ''); frontmatter[key] = value; } return frontmatter; } async function pathExists(filePath) { try { await lstat(filePath); return true; } catch { return false; } } function pathIsInside(parent, child) { const relativePath = path.relative(parent, child); return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); } function valuesMatch(left, right) { return JSON.stringify(left) === JSON.stringify(right); } async function validateClaudeMarketplace(skillDirs, errors) { const marketplacePath = path.join(root, '.claude-plugin', 'marketplace.json'); if (!(await pathExists(marketplacePath))) return; let marketplace; try { marketplace = JSON.parse(await readFile(marketplacePath, 'utf8')); } catch { errors.push('claude marketplace: invalid .claude-plugin/marketplace.json'); return; } if (!marketplace.name) errors.push('claude marketplace: missing name'); if (!marketplace.owner?.name) errors.push('claude marketplace: missing owner.name'); if (!Array.isArray(marketplace.plugins)) { errors.push('claude marketplace: plugins must be an array'); return; } const skillSet = new Set(skillDirs); const exposedSkills = new Set(); const pluginsRoot = path.resolve(root, 'plugins'); const resolvedPluginsRoot = await realpath(pluginsRoot).catch(() => pluginsRoot); for (const plugin of marketplace.plugins) { if (!plugin.name) { errors.push('claude marketplace: plugin missing name'); continue; } if (typeof plugin.source !== 'string' || !plugin.source.startsWith('./')) { errors.push(`${plugin.name}: source must be a relative path starting with ./`); continue; } if (plugin.source.split(/[\\/]+/).includes('..')) { errors.push(`${plugin.name}: source must not contain parent-directory traversal`); continue; } const pluginDir = path.resolve(root, plugin.source); if (!pathIsInside(pluginsRoot, pluginDir)) { errors.push(`${plugin.name}: source must resolve under ./plugins`); continue; } const resolvedPluginDir = await realpath(pluginDir).catch(() => null); const pluginJsonPath = path.join(pluginDir, '.claude-plugin', 'plugin.json'); const skillsPath = path.join(pluginDir, 'skills'); if (!resolvedPluginDir) { errors.push(`${plugin.name}: source path does not exist`); continue; } if (!pathIsInside(resolvedPluginsRoot, resolvedPluginDir)) { errors.push(`${plugin.name}: source must resolve under ./plugins`); continue; } if (!(await pathExists(pluginJsonPath))) { errors.push(`${plugin.name}: missing .claude-plugin/plugin.json`); } else { try { const pluginJson = JSON.parse(await readFile(pluginJsonPath, 'utf8')); if (pluginJson.name !== plugin.name) { errors.push(`${plugin.name}: plugin.json name "${pluginJson.name ?? ''}" does not match marketplace entry`); } const sharedFields = ['description', 'version', 'author', 'license', 'keywords', 'category']; for (const field of sharedFields) { if (!valuesMatch(pluginJson[field], plugin[field])) { errors.push(`${plugin.name}: plugin.json ${field} does not match marketplace entry`); } } for (const field of ['homepage', 'repository']) { if (!valuesMatch(pluginJson[field], marketplace[field])) { errors.push(`${plugin.name}: plugin.json ${field} does not match marketplace ${field}`); } } } catch { errors.push(`${plugin.name}: invalid .claude-plugin/plugin.json`); } } let entries = []; try { entries = await readdir(skillsPath, { withFileTypes: true }); } catch { errors.push(`${plugin.name}: missing skills directory`); continue; } for (const entry of entries) { if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; if (!skillSet.has(entry.name)) { errors.push(`${plugin.name}: unknown skill "${entry.name}"`); continue; } const pluginSkillDir = path.join(skillsPath, entry.name); const resolvedSkillDir = await realpath(pluginSkillDir).catch(() => null); const relativeSkillDir = resolvedSkillDir ? path.relative(resolvedPluginDir, resolvedSkillDir) : ''; if (!resolvedSkillDir || relativeSkillDir.startsWith('..') || path.isAbsolute(relativeSkillDir)) { errors.push(`${plugin.name}: skill "${entry.name}" must resolve inside the plugin directory`); continue; } const skillPath = path.join(pluginSkillDir, 'SKILL.md'); if (!(await pathExists(skillPath))) { errors.push(`${plugin.name}: skill "${entry.name}" does not resolve to SKILL.md`); } else { exposedSkills.add(entry.name); } } } for (const skillName of skillDirs) { if (!exposedSkills.has(skillName)) { errors.push(`claude marketplace: skill "${skillName}" is not exposed by any plugin`); } } } async function listFiles(dir, base = dir) { const files = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...(await listFiles(fullPath, base))); else files.push(path.relative(base, fullPath)); } return files.sort(); } async function validateMirroredSkills(errors) { const pluginsRoot = path.join(root, 'plugins'); if (!(await pathExists(pluginsRoot))) return; for (const plugin of await readdir(pluginsRoot, { withFileTypes: true })) { if (!plugin.isDirectory()) continue; const pluginSkillsDir = path.join(pluginsRoot, plugin.name, 'skills'); const entries = await readdir(pluginSkillsDir, { withFileTypes: true }).catch(() => []); for (const entry of entries) { if (!entry.isDirectory()) continue; const mirrorDir = path.join(pluginSkillsDir, entry.name); const canonicalDir = path.join(skillsDir, entry.name); const mirrorRelative = path.relative(root, mirrorDir); if (!(await pathExists(canonicalDir))) continue; const [canonicalFiles, mirrorFiles] = await Promise.all([listFiles(canonicalDir), listFiles(mirrorDir)]); if (!valuesMatch(canonicalFiles, mirrorFiles)) { errors.push(`mirrored skills: ${mirrorRelative} file list differs from skills/${entry.name} (run bun run sync-skills)`); continue; } for (const file of canonicalFiles) { const [canonical, mirror] = await Promise.all([ readFile(path.join(canonicalDir, file)), readFile(path.join(mirrorDir, file)), ]); if (!canonical.equals(mirror)) { errors.push(`mirrored skills: ${mirrorRelative}/${file} must match skills/${entry.name}/${file} byte-for-byte (run bun run sync-skills)`); } } } } } async function validateAgentManifests(errors) { const manifests = await buildAgentManifests(root); for (const [relativePath, expected] of Object.entries(manifests)) { const actual = await readFile(path.join(root, relativePath), 'utf8').catch(() => null); if (actual !== expected) { errors.push(`agent manifests: ${relativePath} is missing or stale (run bun run sync-skills)`); } } } async function main() { const entries = await readdir(skillsDir, { withFileTypes: true }); const skillDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); const errors = []; for (const skillName of skillDirs) { const skillPath = path.join(skillsDir, skillName, 'SKILL.md'); const metadataPath = path.join(skillsDir, skillName, 'metadata.json'); let content; try { content = await readFile(skillPath, 'utf8'); } catch { errors.push(`${skillName}: missing SKILL.md`); continue; } try { const metadata = await readFile(metadataPath, 'utf8'); JSON.parse(metadata); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { errors.push(`${skillName}: missing metadata.json`); } else { errors.push(`${skillName}: invalid metadata.json`); } } const frontmatter = parseFrontmatter(content); if (!frontmatter) { errors.push(`${skillName}: missing YAML frontmatter`); continue; } if (frontmatter.name !== skillName) { errors.push(`${skillName}: name "${frontmatter.name ?? ''}" does not match folder name`); } if (!frontmatter.description) { errors.push(`${skillName}: missing description`); } else if (frontmatter.description.length > 1024) { errors.push(`${skillName}: description exceeds 1024 characters`); } if (!content.includes('## When to Use') && !content.includes('## When to Use This Skill')) { errors.push(`${skillName}: missing usage guidance`); } } await validateClaudeMarketplace(skillDirs, errors); await validateMirroredSkills(errors); await validateAgentManifests(errors); if (errors.length > 0) { console.error('Skill lint failed:'); for (const error of errors) { console.error(`- ${error}`); } process.exit(1); } const shouldRunSkillgrade = process.env.ENABLE_SKILLGRADE === '1'; const hasSkillgradeKey = Boolean(process.env.ANTHROPIC_API_KEY); if (shouldRunSkillgrade && hasSkillgradeKey) { const result = spawnSync('bunx', ['skillgrade', '--ci', '--provider=local', '--smoke'], { cwd: path.join(skillsDir, 'skill-creator'), encoding: 'utf8', stdio: 'pipe', }); if (result.status !== 0) { process.stdout.write(result.stdout || ''); process.stderr.write(result.stderr || ''); process.exit(result.status ?? 1); } } else { console.log('Skipping skillgrade eval: set ENABLE_SKILLGRADE=1 with ANTHROPIC_API_KEY to run it.'); } console.log(`Validated ${skillDirs.length} skills.`); } main().catch((error) => { console.error(error?.stack || String(error)); process.exit(1); });