automagik-genie
Version:
Self-evolving AI agent orchestration framework with Model Context Protocol support
579 lines (512 loc) • 17.2 kB
JavaScript
#!/usr/bin/env node
/**
* Frontmatter Validator for Genie Framework
*
* Validates .md file frontmatter across Genie framework:
* - YAML syntax validation
* - Required fields presence
* - Amendment 7 violations (version, timestamps)
* - Proper delimiter structure
*
* Usage:
* node validate-frontmatter.js [path]
* (defaults to .genie/ if no path provided)
*/
const fs = require('fs');
const path = require('path');
const yaml = require('yaml');
const { execSync } = require('child_process');
// Configuration
const REQUIRED_FIELDS = {
agent: ['name', 'description', 'genie'],
spell: ['name', 'description'],
};
const FORBIDDEN_FIELDS = ['version', 'last_updated', 'author'];
// Valid Claude models
const VALID_CLAUDE_MODELS = ['haiku', 'sonnet', 'opus-4'];
// NOTE: Executor names are NOT validated against a hardcoded list
// They are fetched dynamically from Forge via AgentRegistry.getSupportedExecutors()
// Validation here only checks format (uppercase). Runtime validation happens in Forge.
// Cache for opencode models (fetched once)
let OPENCODE_MODELS_CACHE = null;
// Results tracking
const results = {
totalFiles: 0,
scannedFiles: 0,
validFiles: 0,
issues: [],
skipped: [],
};
/**
* Fetch valid OpenCode models from `opencode models` command
*/
async function getOpenCodeModels() {
if (OPENCODE_MODELS_CACHE !== null) {
return OPENCODE_MODELS_CACHE;
}
try {
const output = execSync('opencode models', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // Suppress stderr
});
OPENCODE_MODELS_CACHE = output
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
return OPENCODE_MODELS_CACHE;
} catch (err) {
console.warn('⚠️ Could not fetch opencode models (is opencode installed?). Skipping OpenCode model validation.');
OPENCODE_MODELS_CACHE = []; // Empty cache = skip validation
return OPENCODE_MODELS_CACHE;
}
}
/**
* Check if file should be scanned
*/
function shouldScan(filePath) {
const excludePatterns = [
'node_modules',
'.git',
'dist',
'build',
'coverage',
'/backups/', // Exclude backup directories
'README.md', // README files don't need frontmatter
'SETUP-', // Setup guide files
];
return !excludePatterns.some(pattern => filePath.includes(pattern));
}
/**
* Extract frontmatter from markdown content
*/
function extractFrontmatter(content, filePath) {
const lines = content.split('\n');
// Check if file starts with frontmatter delimiter
if (lines[0] !== '---') {
return { hasFrontmatter: false };
}
// Find closing delimiter
let closingIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i] === '---') {
closingIndex = i;
break;
}
}
if (closingIndex === -1) {
return {
hasFrontmatter: true,
error: 'Missing closing frontmatter delimiter (---)',
line: 1,
};
}
// Extract YAML content
const yamlContent = lines.slice(1, closingIndex).join('\n');
try {
const parsed = yaml.parse(yamlContent);
return {
hasFrontmatter: true,
frontmatter: parsed,
yamlContent,
closingLine: closingIndex + 1,
};
} catch (err) {
return {
hasFrontmatter: true,
error: `Invalid YAML syntax: ${err.message}`,
line: 1,
yamlContent,
};
}
}
/**
* Detect file type (agent, spell, etc.)
*/
function detectFileType(filePath) {
if (filePath.includes('/agents/')) return 'agent';
if (filePath.includes('/spells/')) return 'spell';
return 'other';
}
/**
* Validate frontmatter fields
*/
async function validateFields(frontmatter, fileType, filePath) {
const issues = [];
// Check required fields
const required = REQUIRED_FIELDS[fileType] || [];
for (const field of required) {
if (!frontmatter[field]) {
issues.push({
type: 'missing_required',
field,
message: `Missing required field: ${field}`,
});
}
}
// Check forbidden fields (Amendment 7)
for (const field of FORBIDDEN_FIELDS) {
if (frontmatter[field]) {
issues.push({
type: 'amendment_7_violation',
field,
message: `Forbidden field (Amendment 7): ${field}`,
suggestion: 'Remove (git tracks this)',
});
}
}
// Check for nested genie structure (agents only)
if (fileType === 'agent' && frontmatter.genie) {
if (typeof frontmatter.genie !== 'object') {
issues.push({
type: 'invalid_structure',
field: 'genie',
message: 'genie field must be an object',
});
} else {
// Validate genie.executor and genie.model
const genieIssues = await validateGenieFields(frontmatter.genie);
issues.push(...genieIssues);
}
}
// Check for empty required fields (present but empty/null)
for (const field of required) {
if (frontmatter[field] !== undefined && !frontmatter[field]) {
issues.push({
type: 'empty_required',
field,
message: `Required field is empty: ${field}`,
suggestion: 'Provide a value or remove the field',
});
}
}
return issues;
}
/**
* Validate genie.executor and genie.model fields
*/
async function validateGenieFields(genie) {
const issues = [];
const executor = genie.executor;
const model = genie.model;
const variant = genie.executorVariant || genie.variant || genie.executor_variant || genie.executorProfile;
// Validate executor format (should be uppercase)
// NOTE: We don't validate against a hardcoded list - executors are dynamic in Forge
// AgentRegistry.getSupportedExecutors() fetches the current list from Forge
if (executor) {
if (executor !== executor.toUpperCase()) {
issues.push({
type: 'executor_case',
field: 'genie.executor',
message: `Executor should be uppercase (Forge format): ${executor}`,
suggestion: `Change to: ${executor.toUpperCase()}`,
});
}
}
// Warn about executorVariant (deprecated field)
if (variant) {
issues.push({
type: 'deprecated_field',
field: 'genie.executorVariant',
message: `executorVariant is deprecated (conflicts with Forge profile-per-agent pattern)`,
suggestion: `Remove this field - Forge creates one profile per agent automatically`,
});
}
// Validate model based on executor
if (executor && model) {
if (executor === 'CLAUDE_CODE') {
// Claude models: haiku, sonnet, opus-4
if (!VALID_CLAUDE_MODELS.includes(model)) {
issues.push({
type: 'invalid_claude_model',
field: 'genie.model',
message: `Invalid Claude model: ${model}`,
suggestion: `Valid Claude models: ${VALID_CLAUDE_MODELS.join(', ')}`,
});
}
} else if (executor === 'OPENCODE') {
// OpenCode models: must be in `opencode models` output
const validModels = await getOpenCodeModels();
if (validModels.length > 0 && !validModels.includes(model)) {
issues.push({
type: 'invalid_opencode_model',
field: 'genie.model',
message: `OpenCode model not found: ${model}`,
suggestion: `Run 'opencode models' to see valid models`,
});
}
}
}
// Validate executor-specific permission flags (from Forge API 2025-10-26)
// See: .genie/product/docs/executor-configuration.md
const permissionIssues = validatePermissionFlags(genie, executor);
issues.push(...permissionIssues);
return issues;
}
/**
* Validate executor-specific permission flags
* Based on live Forge API query 2025-10-26
*/
function validatePermissionFlags(genie, executor) {
const issues = [];
// Permission flags by executor (from Forge DEFAULT profiles)
const EXECUTOR_PERMISSION_FLAGS = {
CLAUDE_CODE: ['dangerously_skip_permissions'],
CODEX: ['sandbox'],
AMP: ['dangerously_allow_all'],
OPENCODE: [], // No permission flags
};
const VALID_PERMISSION_FLAGS = {
dangerously_skip_permissions: { executor: 'CLAUDE_CODE', type: 'boolean' },
sandbox: { executor: 'CODEX', type: 'string', values: ['danger-full-access', 'read-only', 'safe'] },
dangerously_allow_all: { executor: 'AMP', type: 'boolean' },
};
const VALID_ADDITIONAL_FIELDS = {
model_reasoning_effort: { executors: ['CODEX'], type: 'string', values: ['low', 'medium', 'high'] },
};
// Check for permission flags in frontmatter
for (const [flag, config] of Object.entries(VALID_PERMISSION_FLAGS)) {
if (genie[flag] !== undefined) {
// Flag is present - check if it matches the executor
if (executor !== config.executor) {
issues.push({
type: 'wrong_executor_permission_flag',
field: `genie.${flag}`,
message: `Permission flag '${flag}' is for ${config.executor}, but executor is ${executor}`,
suggestion: executor ?
`Remove this flag or use ${executor}-specific flag: ${EXECUTOR_PERMISSION_FLAGS[executor]?.join(', ') || 'none'}` :
`Remove this flag or set executor to ${config.executor}`,
});
} else {
// Correct executor - validate value type
if (config.type === 'boolean' && typeof genie[flag] !== 'boolean') {
issues.push({
type: 'invalid_permission_flag_type',
field: `genie.${flag}`,
message: `${flag} must be a boolean (true or false)`,
suggestion: `Change to: true or false (no quotes)`,
});
} else if (config.type === 'string' && typeof genie[flag] !== 'string') {
issues.push({
type: 'invalid_permission_flag_type',
field: `genie.${flag}`,
message: `${flag} must be a string`,
suggestion: `Valid values: ${config.values.join(', ')}`,
});
} else if (config.values && !config.values.includes(genie[flag])) {
issues.push({
type: 'invalid_permission_flag_value',
field: `genie.${flag}`,
message: `Invalid value for ${flag}: '${genie[flag]}'`,
suggestion: `Valid values: ${config.values.join(', ')}`,
});
}
}
}
}
// Validate additional executor-specific fields
for (const [field, config] of Object.entries(VALID_ADDITIONAL_FIELDS)) {
if (genie[field] !== undefined) {
// Check if field is valid for this executor
if (executor && !config.executors.includes(executor)) {
issues.push({
type: 'wrong_executor_field',
field: `genie.${field}`,
message: `Field '${field}' is only valid for ${config.executors.join(', ')}, but executor is ${executor}`,
suggestion: `Remove this field or use one of: ${config.executors.join(', ')}`,
});
} else {
// Validate value type and values
if (config.type === 'string' && typeof genie[field] !== 'string') {
issues.push({
type: 'invalid_field_type',
field: `genie.${field}`,
message: `${field} must be a string`,
suggestion: `Valid values: ${config.values.join(', ')}`,
});
} else if (config.values && !config.values.includes(genie[field])) {
issues.push({
type: 'invalid_field_value',
field: `genie.${field}`,
message: `Invalid value for ${field}: '${genie[field]}'`,
suggestion: `Valid values: ${config.values.join(', ')}`,
});
}
}
}
}
// Warn about deprecated 'additional_params' usage
if (genie.additional_params !== undefined) {
issues.push({
type: 'deprecated_field',
field: 'genie.additional_params',
message: `additional_params is not used by Forge (defaults to empty array)`,
suggestion: `Remove this field and use executor-specific permission flags instead`,
});
}
return issues;
}
/**
* Scan single markdown file
*/
async function scanFile(filePath) {
results.totalFiles++;
if (!shouldScan(filePath)) {
results.skipped.push({ file: filePath, reason: 'Excluded path' });
return;
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const extracted = extractFrontmatter(content, filePath);
results.scannedFiles++;
// No frontmatter (may be valid for some files)
if (!extracted.hasFrontmatter) {
const fileType = detectFileType(filePath);
if (fileType === 'agent' || fileType === 'spell') {
results.issues.push({
file: filePath,
line: 1,
type: 'missing_frontmatter',
message: `${fileType} file missing frontmatter`,
severity: 'error',
});
}
return;
}
// Frontmatter extraction error
if (extracted.error) {
results.issues.push({
file: filePath,
line: extracted.line,
type: 'invalid_frontmatter',
message: extracted.error,
severity: 'error',
yaml: extracted.yamlContent,
});
return;
}
// Validate fields
const fileType = detectFileType(filePath);
const fieldIssues = await validateFields(extracted.frontmatter, fileType, filePath);
if (fieldIssues.length > 0) {
fieldIssues.forEach(issue => {
results.issues.push({
file: filePath,
line: 2, // Approximate (inside frontmatter)
type: issue.type,
field: issue.field,
message: issue.message,
suggestion: issue.suggestion,
severity: [
'amendment_7_violation',
'deprecated_field',
'executor_case',
'wrong_executor_permission_flag',
'invalid_permission_flag_type',
'invalid_permission_flag_value',
'wrong_executor_field',
'invalid_field_type',
'invalid_field_value'
].includes(issue.type) ? 'warning' : 'error',
});
});
} else {
results.validFiles++;
}
} catch (err) {
results.issues.push({
file: filePath,
type: 'read_error',
message: `Failed to read file: ${err.message}`,
severity: 'error',
});
}
}
/**
* Recursively scan directory
*/
async function scanDirectory(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await scanDirectory(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
await scanFile(fullPath);
}
}
}
/**
* Generate report
*/
function generateReport() {
console.log('\n=== Frontmatter Validation Report ===\n');
console.log(`Total files: ${results.totalFiles}`);
console.log(`Scanned: ${results.scannedFiles}`);
console.log(`Valid: ${results.validFiles}`);
console.log(`Issues found: ${results.issues.length}`);
console.log(`Skipped: ${results.skipped.length}\n`);
if (results.issues.length === 0) {
console.log('✅ No frontmatter issues found!\n');
return 0;
}
// Group issues by severity
const errors = results.issues.filter(i => i.severity === 'error');
const warnings = results.issues.filter(i => i.severity === 'warning');
if (errors.length > 0) {
console.log(`🔴 ERRORS (${errors.length}):\n`);
errors.forEach(issue => {
console.log(` ${issue.file}:${issue.line || '?'}`);
console.log(` Type: ${issue.type}`);
console.log(` Message: ${issue.message}`);
if (issue.field) console.log(` Field: ${issue.field}`);
if (issue.suggestion) console.log(` Suggestion: ${issue.suggestion}`);
if (issue.yaml) console.log(` YAML:\n${issue.yaml.split('\n').map(l => ' ' + l).join('\n')}`);
console.log('');
});
}
if (warnings.length > 0) {
console.log(`⚠️ WARNINGS (${warnings.length}):\n`);
warnings.forEach(issue => {
console.log(` ${issue.file}:${issue.line || '?'}`);
console.log(` Type: ${issue.type}`);
console.log(` Message: ${issue.message}`);
if (issue.field) console.log(` Field: ${issue.field}`);
if (issue.suggestion) console.log(` Suggestion: ${issue.suggestion}`);
console.log('');
});
}
// Summary
console.log('\n=== Summary by Issue Type ===\n');
const byType = {};
results.issues.forEach(issue => {
byType[issue.type] = (byType[issue.type] || 0) + 1;
});
Object.entries(byType).forEach(([type, count]) => {
console.log(` ${type}: ${count}`);
});
console.log('');
return errors.length > 0 ? 1 : 0;
}
/**
* Main
*/
async function main() {
const targetPath = process.argv[2] || '.genie';
if (!fs.existsSync(targetPath)) {
console.error(`Error: Path not found: ${targetPath}`);
process.exit(1);
}
console.log(`Scanning: ${targetPath}\n`);
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) {
await scanDirectory(targetPath);
} else if (targetPath.endsWith('.md')) {
await scanFile(targetPath);
} else {
console.error('Error: Target must be a directory or .md file');
process.exit(1);
}
const exitCode = generateReport();
process.exit(exitCode);
}
main();