- Fix security-auditor.md color bare hex to quoted - Fix orchestrator.md duplicate devops-engineer key - Fix .kilo/kilo.jsonc: orchestrator + root model to kimi-k2.6:cloud - Update agent-frontmatter-validation.md with diagnostic guide - Update global.md with YAML frontmatter rules for all agents - Update agent-architect.md + workflow-architect.md with color checklist - Add scripts/validate-agents.cjs: zero-dependency, cross-platform, --fix flag, scans worktrees
348 lines
9.4 KiB
JavaScript
348 lines
9.4 KiB
JavaScript
// 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);
|
|
}
|