// Agent Frontmatter Validation Script - Cross-Platform, Zero-Deps // Checks ONLY .kilo/agents/.md files and worktrees for YAML frontmatter correctness. // // Usage: node scripts/validate-agents.cjs // node scripts/validate-agents.cjs --fix (auto-quote unquoted colors) // node scripts/validate-agents.cjs --verbose const fs = require("fs"); const path = require("path"); const FIX_MODE = process.argv.includes("--fix"); const VERBOSE = process.argv.includes("--verbose"); let errors = []; let warnings = []; let fixes = []; // Config const ROOT = process.cwd(); const AGENT_DIRS = [".kilo/agents"]; const VALID_MODES = new Set(["subagent", "all"]); // Utils function log(...args) { console.log(...args); } function globMd(dirPath) { const results = []; function walk(current) { if (!fs.existsSync(current)) return; const entries = fs.readdirSync(current, { withFileTypes: true }); for (const e of entries) { const full = path.join(current, e.name); if (e.isDirectory()) walk(full); else if (e.name.endsWith(".md")) results.push(full); } } walk(dirPath); return results; } function findWorktrees() { const worktreesDir = path.join(ROOT, ".kilo", "worktrees"); if (!fs.existsSync(worktreesDir)) return []; try { return fs .readdirSync(worktreesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => path.join(worktreesDir, d.name)); } catch { return []; } } function loadValidModels() { const metaPath = path.join(ROOT, "kilo-meta.json"); if (!fs.existsSync(metaPath)) return new Set(); try { const meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); const models = new Set(); if (meta.agents) { for (const a of Object.values(meta.agents)) { if (a.model) models.add(a.model); } } return models; } catch { return new Set(); } } // Lightweight YAML Frontmatter Parser function parseYamlFrontmatter(content) { if (!content.startsWith("---")) return null; const end = content.indexOf("---", 3); if (end === -1) return null; const fmText = content.slice(3, end).trim(); const lines = fmText.split(/\r?\n/); const result = {}; let currentObj = null; for (const raw of lines) { const trimmed = raw.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const indent = raw.match(/^(\s*)/)[1].length; const topMatch = raw.match(/^(\w[\w-]*)\s*:\s*(.*)$/); if (topMatch && indent === 0) { const key = topMatch[1]; let val = topMatch[2].trim(); if (val.startsWith('"') || val.startsWith("'")) { const q = val[0]; const endIdx = val.slice(1).indexOf(q); result[key] = endIdx >= 0 ? val.slice(1, endIdx + 1) : val; currentObj = null; } else if (val === "") { result[key] = {}; currentObj = result[key]; } else { result[key] = val; currentObj = null; } continue; } if (currentObj !== null && indent > 0) { const nestedMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/); if (nestedMatch) { const nKey = nestedMatch[1]; let nVal = nestedMatch[2].trim(); if (nVal.startsWith('"') || nVal.startsWith("'")) { const q = nVal[0]; const endIdx = nVal.slice(1).indexOf(q); currentObj[nKey] = endIdx >= 0 ? nVal.slice(1, endIdx + 1) : nVal; } else if (nVal === "") { currentObj[nKey] = {}; } else { currentObj[nKey] = nVal; } } else if (trimmed.startsWith('"*"')) { const nextColon = trimmed.indexOf(":"); if (nextColon > 0) { const k = trimmed .slice(0, nextColon) .replace(/"/g, "") .trim(); const v = trimmed.slice(nextColon + 1).trim(); currentObj[k] = v.replace(/"/g, ""); } } } } return result; } // Validation function validateAgentFile(filePath, validModels) { let content; try { content = fs.readFileSync(filePath, "utf8"); } catch { errors.push({ file: filePath, msg: "Cannot read file" }); return; } if (!content.startsWith("---")) { errors.push({ file: filePath, msg: "Missing YAML frontmatter (must start with ---)", }); return; } const allLines = content.split(/\r?\n/); // Find frontmatter boundaries let fmEnd = -1; for (let i = 1; i < allLines.length; i++) { if (allLines[i].trim() === "---") { fmEnd = i; break; } } if (fmEnd === -1) { errors.push({ file: filePath, msg: "Unclosed YAML frontmatter" }); return; } const fmLines = allLines.slice(0, fmEnd); // --- Color MUST be double-quoted --- const colorLineIdx = fmLines.findIndex((l) => l.match(/^color\s*:/)); if (colorLineIdx >= 0) { const colorRaw = fmLines[colorLineIdx]; const colorMatch = colorRaw.match(/^color\s*:\s*(.+)$/); if (colorMatch) { const colorVal = colorMatch[1].trim(); const isValid = colorVal.match(/^"#[0-9A-Fa-f]{6}"$/); if (!isValid && colorVal !== "") { errors.push({ file: filePath, line: colorLineIdx + 1, msg: `color must be double-quoted hex (e.g. "#DC2626"), got: ${colorVal}`, }); const isBareHex = colorVal.match(/^#[0-9A-Fa-f]{6}$/); if (FIX_MODE && isBareHex) { allLines[colorLineIdx] = `color: "${colorVal}"`; fs.writeFileSync(filePath, allLines.join("\n"), "utf8"); fixes.push({ file: filePath, msg: `Quoted color: ${colorVal}` }); } } } } const fm = parseYamlFrontmatter(content); if (!fm) { errors.push({ file: filePath, msg: "Failed to parse YAML frontmatter" }); return; } // Check for duplicate keys ONLY inside frontmatter const rawKeys = []; for (let i = 0; i < fmEnd; i++) { const l = fmLines[i]; if (l.match(/^(\w[\w-]*)\s*:/)) { const key = l.match(/^(\w[\w-]*)\s*:/)[1]; if (rawKeys.includes(key)) { errors.push({ file: filePath, line: i + 1, msg: `Duplicate key: ${key}`, }); } rawKeys.push(key); } } // mode if (fm.mode) { const modeLineIdx = fmLines.findIndex((l) => l.match(/^mode\s*:/)) + 1; if (!VALID_MODES.has(fm.mode)) { errors.push({ file: filePath, line: modeLineIdx, msg: `Invalid mode: "${fm.mode}". Must be "subagent" or "all"`, }); } } // model if (fm.model) { const modelLineIdx = fmLines.findIndex((l) => l.match(/^model\s*:/)) + 1; if (!fm.model.includes("/")) { errors.push({ file: filePath, line: modelLineIdx, msg: `Model must include provider prefix, got: "${fm.model}"`, }); } } // description if (!fm.description || fm.description.trim() === "") { const descLineIdx = fmLines.findIndex((l) => l.match(/^description\s*:/)) + 1; errors.push({ file: filePath, line: descLineIdx, msg: "description is required and must be non-empty", }); } // permissions - check block exists with at least some permissions if (fm.permission) { // Only warn about missing keys, don't error - some agents are intentionally limited const RECOMMENDED_KEYS = ["read", "edit", "write", "bash", "glob", "grep", "task"]; const missing = RECOMMENDED_KEYS.filter((k) => !(k in fm.permission)); if (missing.length > 0) { const permLineIdx = fmLines.findIndex((l) => l.match(/^permission\s*:/)) + 1; warnings.push({ file: filePath, line: permLineIdx, msg: `Missing optional permission keys: ${missing.join(", ")}`, }); } } else { const permLineIdx = fmLines.findIndex((l) => l.match(/^permission\s*:/)) + 1; errors.push({ file: filePath, line: permLineIdx, msg: "permission block is required", }); } if (VERBOSE && errors.length === 0 && warnings.length === 0) { log(` OK ${path.relative(ROOT, filePath)}`); } } // Main const validModels = loadValidModels(); const worktrees = findWorktrees(); let totalFiles = 0; function scanAgents(dirPath, label) { const files = globMd(dirPath).filter((f) => f.includes(path.sep + "agents" + path.sep) ); if (!files.length) return; if (VERBOSE) log(`\n[${label}]`); for (const file of files) { totalFiles++; validateAgentFile(file, validModels); } } for (const d of AGENT_DIRS) { scanAgents(path.join(ROOT, d), "MAIN"); } for (const wt of worktrees) { for (const d of AGENT_DIRS) { scanAgents(path.join(wt, d), `WORKTREE:${path.basename(wt)}`); } } // Report log("\n" + "=".repeat(60)); log(`Checked ${totalFiles} agent files`); if (fixes.length > 0) { log(`\n✅ Auto-fixed ${fixes.length} issue(s):`); for (const f of fixes) { log(` ${path.relative(ROOT, f.file)}: ${f.msg}`); } } if (warnings.length > 0) { log(`\n⚠️ ${warnings.length} warning(s):`); for (const w of warnings) { const rel = path.relative(ROOT, w.file); const lineStr = w.line ? `:${w.line}` : ""; log(` ${rel}${lineStr} ${w.msg}`); } } if (errors.length === 0) { log("\n🎉 All agent frontmatters valid!\n"); process.exit(0); } else { log(`\n❌ ${errors.length} error(s):\n`); for (const e of errors) { const rel = path.relative(ROOT, e.file); const lineStr = e.line ? `:${e.line}` : ""; log(` ${rel}${lineStr} ${e.msg}`); } if (!FIX_MODE && errors.some((e) => e.msg.includes("color"))) { log("\n💡 Run with --fix to auto-quote colors.\n"); } log(""); process.exit(1); }