* fix(context): migrate delegation references to split workflow files * chore(ci): add markdown link validation with regex skip rules * fix(context): migrate index references to navigation * fix(agent): allow limited bash for task status updates * refactor(skills): migrate to .opencode/skills and normalize skill files * fix(agent): align subagent permissions and workflow structure * docs: reconcile workflow examples and design iteration references * feat(skill): enforce schema checks in task validate command * fix(install): improve colored output and skills collision grouping
543 lines
16 KiB
TypeScript
Executable file
543 lines
16 KiB
TypeScript
Executable file
#!/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<string, string>;
|
||
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<string, keyof Registry['components']> = {
|
||
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<string>): 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('/skills/') && !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<string>();
|
||
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();
|