#!/usr/bin/env bun /** * Registry Validator Script (TypeScript/Bun version) * Validates that all paths in registry.json point to actual files * Exit codes: * 0 = All paths valid * 1 = Missing files found * 2 = Registry parse error or missing dependencies */ import { existsSync, readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { globSync } from 'glob'; // Colors const colors = { red: '\x1b[0;31m', green: '\x1b[0;32m', yellow: '\x1b[1;33m', blue: '\x1b[0;34m', cyan: '\x1b[0;36m', bold: '\x1b[1m', reset: '\x1b[0m', }; // Configuration const REGISTRY_FILE = 'registry.json'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); // Counters let TOTAL_PATHS = 0; let VALID_PATHS = 0; let MISSING_PATHS = 0; let ORPHANED_FILES = 0; let MISSING_DEPENDENCIES = 0; // Arrays to store results const MISSING_FILES: string[] = []; const ORPHANED_COMPONENTS: string[] = []; const MISSING_DEPS: string[] = []; // CLI flags let VERBOSE = false; let FIX_MODE = false; // Types interface Component { id: string; name: string; type: string; path: string; dependencies?: string[]; [key: string]: any; } interface Registry { version: string; schema_version: string; repository: string; categories: Record; components: { agents?: Component[]; subagents?: Component[]; commands?: Component[]; tools?: Component[]; plugins?: Component[]; contexts?: Component[]; config?: Component[]; skills?: Component[]; }; } // Utility Functions function printHeader(): void { console.log(`${colors.cyan}${colors.bold}`); console.log('╔════════════════════════════════════════════════════════════════╗'); console.log('║ ║'); console.log('║ Registry Validator v2.0.0 (TypeScript) ║'); console.log('║ ║'); console.log('╚════════════════════════════════════════════════════════════════╝'); console.log(`${colors.reset}`); } function printSuccess(msg: string): void { console.log(`${colors.green}✓${colors.reset} ${msg}`); } function printError(msg: string): void { console.log(`${colors.red}✗${colors.reset} ${msg}`); } function printWarning(msg: string): void { console.log(`${colors.yellow}⚠${colors.reset} ${msg}`); } function printInfo(msg: string): void { console.log(`${colors.blue}ℹ${colors.reset} ${msg}`); } function usage(): void { console.log('Usage: bun run scripts/registry/validate-registry.ts [OPTIONS]'); console.log(''); console.log('Options:'); console.log(' -v, --verbose Show detailed validation output'); console.log(' -f, --fix Suggest fixes for missing files'); console.log(' -h, --help Show this help message'); console.log(''); console.log('Exit codes:'); console.log(' 0 = All paths valid'); console.log(' 1 = Missing files found'); console.log(' 2 = Registry parse error or missing dependencies'); process.exit(0); } // Registry Validation function validateRegistryFile(): Registry { const registryPath = join(REPO_ROOT, REGISTRY_FILE); if (!existsSync(registryPath)) { printError(`Registry file not found: ${REGISTRY_FILE}`); process.exit(2); } try { const content = readFileSync(registryPath, 'utf-8'); const registry = JSON.parse(content) as Registry; printSuccess('Registry file is valid JSON'); return registry; } catch (error) { printError('Registry file is not valid JSON'); console.error(error); process.exit(2); } } function validateComponentPaths( category: keyof Registry['components'], categoryDisplay: string, registry: Registry ): void { console.error(`Checking ${categoryDisplay}...`); const components = registry.components[category]; if (!components || components.length === 0) { console.error(`No ${categoryDisplay} found`); return; } for (const component of components) { const { id, path, name } = component; TOTAL_PATHS++; const fullPath = join(REPO_ROOT, path); if (existsSync(fullPath)) { VALID_PATHS++; if (VERBOSE) { printSuccess(`${categoryDisplay}: ${name} (${id})`); } } else { MISSING_PATHS++; MISSING_FILES.push(`${category}:${id}|${name}|${path}`); printError(`${categoryDisplay}: ${name} (${id}) - File not found: ${path}`); if (FIX_MODE) { suggestFix(path, id); } } } } function suggestFix(missingPath: string, componentId: string): void { const dir = dirname(missingPath); const baseDir = dir.split('/').slice(0, 3).join('/'); const searchPath = join(REPO_ROOT, baseDir); try { // Search for markdown files matching the component ID const pattern = join(searchPath, '**', '*.md'); const files = globSync(pattern, { nodir: true }); const matches = files.filter(file => file.toLowerCase().includes(componentId.toLowerCase()) ); if (matches.length > 0) { console.log(` ${colors.yellow}→ Possible matches:${colors.reset}`); matches.forEach(file => { const relPath = file.replace(REPO_ROOT + '/', ''); console.log(` ${colors.cyan}${relPath}${colors.reset}`); }); } else { console.log(` ${colors.yellow}→ No similar files found in ${baseDir}${colors.reset}`); } } catch (error) { console.log(` ${colors.yellow}→ Search in: ${baseDir}${colors.reset}`); console.log(` ${colors.yellow}→ Looking for files matching: ${componentId}${colors.reset}`); } } function checkDependencyExists(dep: string, registry: Registry): string { // Parse dependency format: type:id const match = dep.match(/^([^:]+):(.+)$/); if (!match) { return 'invalid_format'; } const [, depType, depId] = match; // Map dependency type to registry category const categoryMap: Record = { agent: 'agents', subagent: 'subagents', command: 'commands', tool: 'tools', plugin: 'plugins', context: 'contexts', config: 'config', skill: 'skills', }; const registryCategory = categoryMap[depType]; if (!registryCategory) { return 'unknown_type'; } const components = registry.components[registryCategory]; if (!components) { return 'not_found'; } // Check if component exists in registry - exact ID match const exists = components.find((c) => c.id === depId); if (exists) { return 'found'; } // For context dependencies, also try path-based lookup if (depType === 'context') { // Check for wildcard pattern (e.g., context:core/context-system/*) if (depId.includes('*')) { const prefix = depId.split('*')[0]; const matches = components.find((c) => c.path.startsWith(`.opencode/context/${prefix}`) ); if (matches) { return 'found'; } } else { // Try exact path match const contextPath = `.opencode/context/${depId}.md`; const existsByPath = components.find((c) => c.path === contextPath); if (existsByPath) { return 'found'; } } } return 'not_found'; } function validateComponentDependencies(registry: Registry): void { console.log(''); printInfo('Validating component dependencies...'); console.log(''); const componentTypes = Object.keys(registry.components) as Array< keyof Registry['components'] >; for (const compType of componentTypes) { const components = registry.components[compType]; if (!components || components.length === 0) { continue; } for (const component of components) { const { id, name, dependencies } = component; if (!dependencies || dependencies.length === 0) { continue; } for (const dep of dependencies) { if (!dep) { continue; } const result = checkDependencyExists(dep, registry); switch (result) { case 'found': if (VERBOSE) { printSuccess(`Dependency OK: ${name} → ${dep}`); } break; case 'not_found': MISSING_DEPENDENCIES++; MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`); printError( `Missing dependency: ${name} (${compType.replace(/s$/, '')}) depends on "${dep}" (not found in registry)` ); break; case 'invalid_format': MISSING_DEPENDENCIES++; MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`); printError( `Invalid dependency format: ${name} (${compType.replace(/s$/, '')}) has invalid dependency "${dep}" (expected format: type:id)` ); break; case 'unknown_type': MISSING_DEPENDENCIES++; MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`); printError( `Unknown dependency type: ${name} (${compType.replace(/s$/, '')}) has unknown dependency type in "${dep}"` ); break; } } } } } function scanDirectory(dir: string, registryPaths: Set): void { if (!existsSync(dir)) { return; } const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); const relPath = fullPath.replace(REPO_ROOT + '/', ''); if (entry.isDirectory()) { // Skip node_modules if (entry.name === 'node_modules') continue; // Skip plugin internal directories if (relPath.includes('/plugin/docs/') || relPath.includes('/plugin/tests/')) continue; // Skip scripts directories if (relPath.includes('/scripts/')) continue; scanDirectory(fullPath, registryPaths); } else if (entry.isFile()) { // Only check .md and .ts files if (!entry.name.endsWith('.md') && !entry.name.endsWith('.ts')) continue; // Skip exclusions if (entry.name === 'README.md') continue; if (entry.name.endsWith('-template.md')) continue; if (relPath.endsWith('/tool/index.ts')) continue; if (relPath.endsWith('/tool/template/index.ts')) continue; if (relPath.endsWith('/plugin/agent-validator.ts')) continue; // Skip skill support files (only SKILL.md needs to be in registry, all other files are copied with the skill) if (relPath.includes('/skill/') && !entry.name.match(/^SKILL\.md$/i)) continue; // Check if in registry if (!registryPaths.has(relPath)) { ORPHANED_FILES++; ORPHANED_COMPONENTS.push(relPath); if (VERBOSE) { printWarning(`Orphaned file (not in registry): ${relPath}`); } } } } } function scanForOrphanedFiles(registry: Registry): void { if (!VERBOSE) return; console.log(''); console.log(`${colors.bold}Scanning for orphaned files...${colors.reset}`); // Get all paths from registry const registryPaths = new Set(); for (const category of Object.keys(registry.components)) { const components = registry.components[category as keyof Registry['components']]; if (components) { components.forEach(c => registryPaths.add(c.path)); } } const categories = ['agent', 'command', 'tool', 'plugin', 'context', 'skill']; for (const category of categories) { const categoryDir = join(REPO_ROOT, '.opencode', category); scanDirectory(categoryDir, registryPaths); } } function printSummary(): boolean { console.log(''); console.log(`${colors.bold}═══════════════════════════════════════════════════════════════${colors.reset}`); console.log(`${colors.bold}Validation Summary${colors.reset}`); console.log(`${colors.bold}═══════════════════════════════════════════════════════════════${colors.reset}`); console.log(''); console.log(`Total paths checked: ${colors.cyan}${TOTAL_PATHS}${colors.reset}`); console.log(`Valid paths: ${colors.green}${VALID_PATHS}${colors.reset}`); console.log(`Missing paths: ${colors.red}${MISSING_PATHS}${colors.reset}`); console.log(`Missing dependencies: ${colors.red}${MISSING_DEPENDENCIES}${colors.reset}`); if (VERBOSE) { console.log(`Orphaned files: ${colors.yellow}${ORPHANED_FILES}${colors.reset}`); } console.log(''); let hasErrors = false; // Check for missing paths if (MISSING_PATHS > 0) { hasErrors = true; printError(`Found ${MISSING_PATHS} missing file(s)`); console.log(''); console.log('Missing files:'); for (const entry of MISSING_FILES) { const [catId, name, path] = entry.split('|'); console.log(` - ${path} (${catId})`); } console.log(''); if (!FIX_MODE) { printInfo('Run with --fix flag to see suggested fixes'); console.log(''); } } // Check for missing dependencies if (MISSING_DEPENDENCIES > 0) { hasErrors = true; printError(`Found ${MISSING_DEPENDENCIES} missing or invalid dependencies`); console.log(''); console.log('Missing dependencies:'); for (const entry of MISSING_DEPS) { const [compType, id, name, dep] = entry.split('|'); console.log(` - ${name} (${compType.replace(/s$/, '')}) → ${dep}`); } console.log(''); printInfo('Fix by either:'); console.log(' 1. Adding the missing component to the registry'); console.log(' 2. Removing the dependency from the component\'s frontmatter'); console.log(''); } // Success case if (!hasErrors) { printSuccess('All registry paths are valid!'); printSuccess('All component dependencies are valid!'); if (ORPHANED_FILES > 0 && VERBOSE) { console.log(''); printWarning(`Found ${ORPHANED_FILES} orphaned file(s) not in registry`); console.log(''); console.log('Orphaned files:'); for (const file of ORPHANED_COMPONENTS) { console.log(` - ${file}`); } console.log(''); console.log('Consider adding these to registry.json or removing them.'); } return true; } else { console.log('Please fix these issues before proceeding.'); return false; } } // Main function main(): void { // Parse arguments const args = process.argv.slice(2); for (const arg of args) { switch (arg) { case '-v': case '--verbose': VERBOSE = true; break; case '-f': case '--fix': FIX_MODE = true; VERBOSE = true; break; case '-h': case '--help': usage(); break; default: console.log(`Unknown option: ${arg}`); usage(); } } printHeader(); // Validate registry file const registry = validateRegistryFile(); console.log(''); printInfo('Validating component paths...'); console.log(''); // Validate each category validateComponentPaths('agents', 'Agents', registry); validateComponentPaths('subagents', 'Subagents', registry); validateComponentPaths('commands', 'Commands', registry); validateComponentPaths('tools', 'Tools', registry); validateComponentPaths('plugins', 'Plugins', registry); validateComponentPaths('contexts', 'Contexts', registry); validateComponentPaths('config', 'Config', registry); validateComponentPaths('skills', 'Skills', registry); // Validate component dependencies validateComponentDependencies(registry); // Scan for orphaned files if verbose if (VERBOSE) { scanForOrphanedFiles(registry); } // Print summary and exit with appropriate code if (printSummary()) { process.exit(0); } else { process.exit(1); } } main();