244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
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/<skill_id>/references/*.md";
|
|
skillMdRole: string;
|
|
};
|
|
next: string[];
|
|
}
|
|
|
|
export function healthHelp(): Record<string, unknown> {
|
|
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/<skill_id>/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<SkillHealthItem | null>((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/<skill_id>/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/<skill_id>/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/<skill_id>/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");
|
|
}
|