import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, join, relative } from "node:path"; import { repoRoot, rootPath } from "./config"; import type { RenderedCliResult } from "./output"; const DEFAULT_SKILLS_DIR = ".agents/skills"; const DEFAULT_SKILL_MD_LIMIT_BYTES = 10 * 1024; interface HealthOptions { skillsDir: string; thresholdBytes: number; json: boolean; full: boolean; } interface SkillHealthItem { skillId: string; skillPath: string; skillMdPath: string; referencesDir: string; bytes: number; lines: number; limitBytes: number; overBytes: number; status: "OK" | "ALERT"; action: string; } interface SkillHealthResult { ok: boolean; command: "health"; check: "skill-md-size"; thresholdBytes: number; skillsDir: string; summary: { totalSkills: number; okSkills: number; alertSkills: number; maxBytes: number; maxSkillId: string | null; }; alerts: SkillHealthItem[]; skills: SkillHealthItem[]; policy: { limitBytes: number; splitTarget: ".agents/skills//references/*.md"; skillMdRole: string; }; next: string[]; } export function healthHelp(): Record { return { command: "health", output: "text by default; use --json, --full, or --raw for structured output", usage: [ "bun scripts/cli.ts health", "bun scripts/cli.ts health --json", "bun scripts/cli.ts health --skills-dir .agents/skills --threshold-bytes 10240", ], checks: [ { name: "skill-md-size", defaultThresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES, scope: ".agents/skills/*/SKILL.md", failure: "SKILL.md over threshold", action: "move lower-frequency detail into .agents/skills//references/*.md and leave SKILL.md as high-frequency routing/workflow guidance plus reference links", }, ], }; } export function runHealthCommand(args: string[]): SkillHealthResult | RenderedCliResult { const options = parseHealthOptions(args); const result = scanSkillHealth(options); if (options.json || options.full) return result; return { ok: result.ok, command: "health", contentType: "text/plain", renderedText: renderSkillHealth(result), }; } function parseHealthOptions(args: string[]): HealthOptions { const options: HealthOptions = { skillsDir: DEFAULT_SKILLS_DIR, thresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES, json: false, full: false, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { options.json = true; } else if (arg === "--full" || arg === "--raw") { options.full = true; options.json = true; } else if (arg === "--skills-dir") { options.skillsDir = stringArg(arg, args[index + 1]); index += 1; } else if (arg === "--threshold-bytes") { options.thresholdBytes = positiveIntArg(arg, args[index + 1], 1024 * 1024); index += 1; } else { throw new Error(`unknown health option: ${arg}`); } } return options; } function stringArg(name: string, raw: string | undefined): string { if (raw === undefined || raw.trim().length === 0) throw new Error(`${name} requires a non-empty value`); return raw; } function positiveIntArg(name: string, raw: string | undefined, max: number): number { const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return Math.min(value, max); } function resolvePath(path: string): string { return isAbsolute(path) ? path : rootPath(path); } function displayPath(path: string): string { const rel = relative(repoRoot, path); return rel.length > 0 && !rel.startsWith("..") ? rel : path; } function scanSkillHealth(options: HealthOptions): SkillHealthResult { const skillsRoot = resolvePath(options.skillsDir); if (!existsSync(skillsRoot)) throw new Error(`health skills directory not found: ${displayPath(skillsRoot)}`); const skillDirs = readdirSync(skillsRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => join(skillsRoot, entry.name)) .filter((dir) => existsSync(join(dir, "SKILL.md"))) .sort(); const skills = skillDirs.map((skillPath) => skillHealthItem(skillPath, options.thresholdBytes)); const alerts = skills.filter((item) => item.status === "ALERT"); const max = skills.reduce((current, item) => current === null || item.bytes > current.bytes ? item : current, null); const ok = skills.length > 0 && alerts.length === 0; return { ok, command: "health", check: "skill-md-size", thresholdBytes: options.thresholdBytes, skillsDir: displayPath(skillsRoot), summary: { totalSkills: skills.length, okSkills: skills.length - alerts.length, alertSkills: alerts.length, maxBytes: max?.bytes ?? 0, maxSkillId: max?.skillId ?? null, }, alerts, skills, policy: { limitBytes: options.thresholdBytes, splitTarget: ".agents/skills//references/*.md", skillMdRole: "Keep SKILL.md under the limit with only trigger-critical, highest-frequency workflow guidance; move lower-frequency detail into directly linked references.", }, next: skills.length === 0 ? [`No SKILL.md files were found under ${displayPath(skillsRoot)}; check --skills-dir.`] : alerts.length === 0 ? ["No skill split is required."] : [ "Move lower-frequency detail into .agents/skills//references/*.md.", "Keep SKILL.md as a compact high-frequency workflow and navigation index.", "Rerun: bun scripts/cli.ts health", ], }; } function skillHealthItem(skillPath: string, limitBytes: number): SkillHealthItem { const skillMdPath = join(skillPath, "SKILL.md"); const text = readFileSync(skillMdPath, "utf8"); const bytes = statSync(skillMdPath).size; const skillId = skillPath.split(/[\\/]/u).pop() ?? displayPath(skillPath); const referencesDir = join(skillPath, "references"); const overBytes = Math.max(0, bytes - limitBytes); return { skillId, skillPath: displayPath(skillPath), skillMdPath: displayPath(skillMdPath), referencesDir: displayPath(referencesDir), bytes, lines: text.split(/\r?\n/u).length, limitBytes, overBytes, status: overBytes > 0 ? "ALERT" : "OK", action: overBytes > 0 ? `split lower-frequency detail to ${displayPath(referencesDir)}` : "-", }; } function renderSkillHealth(result: SkillHealthResult): string { const lines = [ `skill health (${result.ok ? "ok" : "alert"})`, "", renderTable(["SUMMARY", "VALUE"], [ ["skills", String(result.summary.totalSkills)], ["limit", `${result.thresholdBytes} bytes`], ["alerts", String(result.summary.alertSkills)], ["largest", result.summary.maxSkillId === null ? "-" : `${result.summary.maxSkillId} ${result.summary.maxBytes} bytes`], ]), "", ]; if (result.alerts.length > 0) { lines.push( "ALERTS", renderTable(["SKILL", "BYTES", "OVER", "LINES", "ACTION"], result.alerts.map((item) => [ item.skillId, String(item.bytes), String(item.overBytes), String(item.lines), item.action, ])), "", ); } else { lines.push("ALERTS", " none", ""); } lines.push( "Policy:", ` SKILL.md limit: ${result.thresholdBytes} bytes`, " Split target: .agents/skills//references/*.md", " Keep SKILL.md to highest-frequency workflow/routing content and direct reference links.", "", "Next:", ...result.next.map((item) => ` ${item}`), " bun scripts/cli.ts health --full", ); return lines.join("\n"); } function renderTable(headers: string[], rows: string[][]): string { const normalized = rows.length === 0 ? [headers.map(() => "-")] : rows; const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => (row[index] ?? "").length))); const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); return [renderRow(headers), ...normalized.map(renderRow)].join("\n"); }