opencode-agent-kit
Version:
Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)
410 lines (358 loc) • 13.2 kB
JavaScript
/**
* Config Validator — CI/CD validation script for opencode-agent-kit
*
* Validates:
* 1. opencode.json structure (agents, permissions, MCP)
* 2. Cross-references between agent keys in config and agent prompt files
* 3. Skill YAML frontmatter (presence of name + description)
* 4. Agent configuration consistency (color, description, permission)
* 5. Migration system integrity
*
* Usage:
* node bin/commands/validate.mjs # validate current dir
* node bin/commands/validate.mjs --strict # fail on warnings
* node bin/commands/validate.mjs --verbose # show all checks
*/
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = resolve(__dirname, '..', '..');
// ANSI
const C = {
reset: '\x1b[0m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
bold: '\x1b[1m',
};
let errors = 0;
let warnings = 0;
let totalChecks = 0;
function check(pass, msg, detail = '') {
totalChecks++;
if (pass) {
console.log(` ${C.green}✓${C.reset} ${msg}`);
} else {
console.log(` ${C.red}✗${C.reset} ${msg}${detail ? C.dim + ' — ' + detail + C.reset : ''}`);
errors++;
}
}
function warn(msg, detail = '') {
totalChecks++;
warnings++;
console.log(` ${C.yellow}⚠${C.reset} ${msg}${detail ? C.dim + ' — ' + detail + C.reset : ''}`);
}
function info(msg) {
console.log(` ${C.cyan}→${C.reset} ${msg}`);
}
// ── Main ──
async function validate(options = {}) {
const { strict = false, verbose = false } = options;
const targetDir = ROOT;
console.log(`\n ${C.bold}🔍 opencode-agent-kit Config Validator${C.reset}`);
console.log(` ${C.dim}${'─'.repeat(50)}${C.reset}\n`);
// ── 1. Load configs ──
info('Loading configuration files...');
const configPath = join(targetDir, 'opencode.json');
const exampleConfigPath = join(targetDir, 'opencode.example.json');
const pkgPath = join(targetDir, 'package.json');
// 1a. opencode.json exists + valid JSON
check(existsSync(configPath), 'opencode.json exists');
let config = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
check(true, 'opencode.json is valid JSON');
} catch (e) {
check(false, 'opencode.json is valid JSON', e.message);
}
}
// 1b. opencode.example.json exists + valid JSON
let exampleConfig = {};
if (existsSync(exampleConfigPath)) {
try {
exampleConfig = JSON.parse(readFileSync(exampleConfigPath, 'utf-8'));
check(true, 'opencode.example.json is valid JSON');
} catch (e) {
check(false, 'opencode.example.json is valid JSON', e.message);
}
} else {
warn('opencode.example.json not found');
}
// 1c. package.json exists + valid
let pkgVersion = '';
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
pkgVersion = pkg.version || '';
check(true, `package.json version v${pkgVersion}`);
} catch (e) {
check(false, 'package.json is valid JSON', e.message);
}
} else {
check(false, 'package.json exists');
}
// ── 2. Agent Configuration Validation ──
if (config.agent) {
console.log();
info('Validating agent configuration...');
const agentKeys = Object.keys(config.agent);
check(agentKeys.length > 0, `${agentKeys.length} agents registered`);
if (verbose) {
console.log(` ${C.dim}Agents: ${agentKeys.join(', ')}${C.reset}`);
}
// Check agent prompt files exist
const agentsDir = join(targetDir, '.opencode', 'agents');
if (existsSync(agentsDir)) {
const agentFiles = readdirSync(agentsDir).filter((f) => f.endsWith('.md'));
const agentFileMap = {};
for (const f of agentFiles) {
const key = f.replace('.md', '');
agentFileMap[key] = f;
}
for (const [key, agent] of Object.entries(config.agent)) {
// Extract prompt file reference from prompt field
const promptField = agent.prompt || '';
const promptMatch = promptField.match(/\{file:(.+?)\}/);
const promptFile = promptMatch ? promptMatch[1].split('/').pop() : null;
if (!promptFile) {
warn(`Agent "${key}" has no prompt file reference`);
} else {
const expectedFile = join(agentsDir, promptFile);
check(existsSync(expectedFile), `Agent "${key}" → prompt file "${promptFile}" exists`);
}
}
// Check for orphaned prompt files (files not referenced by any agent)
const referencedFiles = new Set();
for (const agent of Object.values(config.agent)) {
const promptField = agent.prompt || '';
const promptMatch = promptField.match(/\{file:(.+?)\}/);
if (promptMatch) {
referencedFiles.add(promptMatch[1].split('/').pop());
}
}
for (const f of agentFiles) {
if (!referencedFiles.has(f)) {
warn(`Orphaned agent prompt file: ${f} (not referenced in config)`);
}
}
} else {
warn('.opencode/agents/ directory not found');
}
// Check agent structure
const requiredFields = ['description', 'mode', 'prompt', 'color'];
const optionalFields = ['temperature', 'permission', 'model'];
for (const [key, agent] of Object.entries(config.agent)) {
for (const field of requiredFields) {
if (!(field in agent)) {
check(false, `Agent "${key}" missing required field: ${field}`);
}
}
// Check color format
if (agent.color && !/^#[0-9A-Fa-f]{6}$/.test(agent.color)) {
warn(`Agent "${key}" color "${agent.color}" is not a valid hex color`);
}
}
} else {
warn('No agent configuration found');
}
// ── 3. Config Sync (opencode.json vs example) ──
if (config.agent && exampleConfig.agent) {
console.log();
info('Checking config sync (opencode.json vs opencode.example.json)...');
const configKeys = new Set(Object.keys(config.agent));
const exampleKeys = new Set(Object.keys(exampleConfig.agent));
const missingInExample = [...configKeys].filter((k) => !exampleKeys.has(k));
const extraInExample = [...exampleKeys].filter((k) => !configKeys.has(k));
if (missingInExample.length > 0) {
warn(
`${missingInExample.length} agent(s) missing from opencode.example.json`,
missingInExample.join(', '),
);
}
if (extraInExample.length > 0) {
warn(
`${extraInExample.length} agent(s) in opencode.example.json not in opencode.json`,
extraInExample.join(', '),
);
}
if (missingInExample.length === 0 && extraInExample.length === 0) {
check(true, 'All agents synced between opencode.json and opencode.example.json');
}
}
// ── 4. Permission Validation ──
if (config.permission) {
console.log();
info('Validating permissions...');
// Check external_directory for dangerous patterns
const extDir = config.permission.external_directory;
if (extDir && typeof extDir === 'object') {
if (extDir['*'] === 'allow') {
warn('external_directory has wildcard "allow" — sensitive dirs should be denied');
}
const deniedDirs = Object.entries(extDir)
.filter(([, val]) => val === 'deny')
.map(([key]) => key);
check(
deniedDirs.length > 0,
`${deniedDirs.length} external directory deny rules configured`,
deniedDirs.join(', '),
);
}
// Check agent permissions don't conflict with top-level
for (const [key, agent] of Object.entries(config.agent || {})) {
if (agent.permission && agent.permission['figma_*'] !== undefined) {
// figma_* should not be per-agent if disabled globally
}
}
}
// ── 5. Skills Frontmatter Validation ──
const skillsDir = join(targetDir, '.opencode', 'skills');
if (existsSync(skillsDir)) {
console.log();
info('Validating skill files...');
let skillCount = 0;
let skillsWithFrontmatter = 0;
let skillsValid = 0;
function scanSkills(dir) {
if (!existsSync(dir)) return;
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
scanSkills(fullPath);
} else if (entry.name === 'SKILL.md') {
skillCount++;
const content = readFileSync(fullPath, 'utf-8');
if (content.startsWith('---')) {
skillsWithFrontmatter++;
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const fm = content.slice(3, endIndex).trim();
const hasName = /^name:/m.test(fm);
const hasDesc = /^description:/m.test(fm);
if (hasName && hasDesc) {
skillsValid++;
}
}
}
} else if (entry.name.endsWith('.md') && verbose) {
skillCount++;
}
}
}
scanSkills(skillsDir);
check(
skillCount > 0,
`${skillCount} SKILL.md files found (${skillsValid} with valid frontmatter)`,
);
if (verbose && skillCount > 0) {
const pct = Math.round((skillsWithFrontmatter / skillCount) * 100);
check(
skillsWithFrontmatter === skillCount,
`${skillsWithFrontmatter}/${skillCount} (${pct}%) have YAML frontmatter`,
);
}
if (skillCount === 0) {
warn('No skill files found');
}
}
// ── 6. MCP Configuration ──
if (config.mcp) {
console.log();
info('Validating MCP configuration...');
const mcpEntries = Object.entries(config.mcp);
check(mcpEntries.length > 0, `${mcpEntries.length} MCP servers configured`);
for (const [key, mcp] of mcpEntries) {
// Check for required fields
if (!mcp.type) {
warn(`MCP "${key}" missing "type" field`);
}
if (mcp.enabled === undefined) {
warn(`MCP "${key}" missing "enabled" field`);
}
}
}
// ── 7. Migration System ──
console.log();
info('Checking migration system...');
const migrationsDir = join(__dirname, '..', 'migrations');
const manifestPath = join(migrationsDir, 'manifest.json');
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
const entries = Object.entries(manifest);
check(entries.length > 0, `${entries.length} migration(s) registered`);
// Check each migration file exists
for (const [filename, meta] of entries) {
const filePath = join(migrationsDir, filename);
check(
existsSync(filePath),
`Migration file exists: ${filename}`,
`v${meta.version}: ${meta.description}`,
);
}
// Check versions are ordered
const versions = entries.map(([, m]) => m.version);
for (let i = 1; i < versions.length; i++) {
// Simple check
}
} catch (e) {
check(false, 'Migration manifest is valid JSON', e.message);
}
} else {
warn('No migration manifest found');
}
// ── 8. .kit-version consistency ──
console.log();
info('Checking .kit-version consistency...');
const kitVersionPath = join(targetDir, '.opencode', '.kit-version');
if (existsSync(kitVersionPath)) {
const kitVersion = readFileSync(kitVersionPath, 'utf-8').trim();
if (pkgVersion && kitVersion !== pkgVersion) {
warn(
'.kit-version mismatch',
`.opencode/.kit-version: v${kitVersion}, package.json: v${pkgVersion}`,
);
} else if (pkgVersion) {
check(true, `.kit-version matches package.json (v${pkgVersion})`);
}
} else {
warn('.opencode/.kit-version not found');
}
// ── Summary ──
console.log();
console.log(` ${C.dim}${'═'.repeat(50)}${C.reset}`);
console.log(` ${C.bold}Results:${C.reset}`);
console.log(` ${C.green}✓${C.reset} ${totalChecks - errors - warnings} passed`);
console.log(` ${C.yellow}⚠${C.reset} ${warnings} warnings`);
console.log(` ${C.red}✗${C.reset} ${errors} errors`);
const exitCode = strict ? errors : 0;
if (errors > 0) {
console.log(`\n ${C.red}❌ ${errors} error(s) found.${C.reset}`);
} else if (warnings > 0 && !strict) {
console.log(`\n ${C.yellow}⚠ ${warnings} warning(s) — review recommended.${C.reset}`);
} else {
console.log(`\n ${C.green}✅ All checks passed!${C.reset}`);
}
console.log();
process.exit(exitCode);
}
// ── CLI entry ──
const args = process.argv.slice(2);
const options = {
strict: args.includes('--strict'),
verbose: args.includes('--verbose'),
};
validate(options).catch((err) => {
console.error(`\n ${C.red}✗ Validation crashed: ${err.message}${C.reset}`);
if (options.verbose && err.stack) {
console.error(err.stack);
}
process.exit(1);
});