Files
skill-manager/scripts/src/actions/validate/action.ts
T
oleg-lukasonokandClaude Fable 5 6042272ccc Add skill-manager skill (migrated from skill-creator) with deploy action
- Skill content moved from ~/.agents/skills/skill-creator, renamed to skill-manager
- New deploy CLI action: installs any skill via absolute-path symlinks (or copies)
  into $HOME/.agents/skills, $HOME/.claude/skills and $HOME/.cline/skills
- Taskfile + shell module wrappers (task deploy / cli:deploy)
- SKILL.md: deploy docs, origin-repository/origin-path metadata

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:32:42 +03:00

288 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Action: validate
*
* Validates a skill directory against the agentskills.io specification.
*
* Checks performed:
* 1. SKILL.md exists
* 2. YAML frontmatter delimiters (--- ... ---) are present
* 3. 'name' field is present in frontmatter
* 4. 'name' matches the directory name (basename)
* 5. 'name' length ≤ 64 characters
* 6. 'name' uses only a-z, 0-9, hyphens
* 7. 'name' does not start with a hyphen
* 8. 'name' does not end with a hyphen
* 9. 'name' has no consecutive hyphens
* 10. 'description' field is present and non-empty
* 11. 'description' length ≤ 1024 characters
* 12. SKILL.md body (lines after frontmatter) ≤ 500 lines (recommendation)
* 13. If scripts/ exists, Taskfile.yml must be present inside it
*
* Output: YAML with success flag, counts, and per-check detail list.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, basename } from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CheckResult {
id: number;
pass: boolean;
level: "error" | "warning" | "info";
message: string;
}
interface DirectoryInfo {
found: boolean;
files?: number;
"Taskfile.yml"?: boolean;
}
interface ValidateOutput {
action: "validate";
"skill-dir": string;
success: boolean;
summary: {
passed: number;
failed: number;
warnings: number;
};
checks: CheckResult[];
optional: {
"scripts/": DirectoryInfo;
"references/": DirectoryInfo;
"assets/": DirectoryInfo;
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function countFilesSync(dir: string): number {
try {
const entries = readdirSync(dir, { withFileTypes: true });
let count = 0;
for (const e of entries) {
if (e.isDirectory()) {
count += countFilesSync(join(dir, e.name));
} else {
count++;
}
}
return count;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// Frontmatter parser — handles scalar and > block values
// ---------------------------------------------------------------------------
function parseFrontmatter(raw: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = raw.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
const keyMatch = line.match(/^([a-zA-Z_-]+)\s*:\s*(.*)/);
if (keyMatch) {
const key = keyMatch[1];
const val = keyMatch[2].trim();
if (val === ">" || val === "|") {
// Multi-line block scalar — collect indented continuation lines
const parts: string[] = [];
i++;
while (
i < lines.length &&
(lines[i].startsWith(" ") || lines[i].startsWith("\t") || lines[i].trim() === "")
) {
parts.push(lines[i].trim());
i++;
}
result[key] = parts.join(" ").trim();
continue;
} else {
result[key] = val;
}
}
i++;
}
return result;
}
// ---------------------------------------------------------------------------
// Optional directories inspector
// ---------------------------------------------------------------------------
function inspectOptional(skillDir: string): ValidateOutput["optional"] {
function dirInfo(subdir: string): DirectoryInfo {
const p = join(skillDir, subdir);
if (!existsSync(p)) return { found: false };
return { found: true, files: countFilesSync(p) };
}
const scriptsDir = join(skillDir, "scripts");
const scriptsInfo = dirInfo("scripts");
if (scriptsInfo.found) {
scriptsInfo["Taskfile.yml"] = existsSync(join(scriptsDir, "Taskfile.yml"));
}
return {
"scripts/": scriptsInfo,
"references/": dirInfo("references"),
"assets/": dirInfo("assets"),
};
}
// ---------------------------------------------------------------------------
// Output builder
// ---------------------------------------------------------------------------
function buildOutput(
skillDir: string,
checks: CheckResult[],
optional: ValidateOutput["optional"]
): Record<string, unknown> {
const passed = checks.filter((c) => c.pass).length;
const failed = checks.filter((c) => !c.pass && c.level === "error").length;
const warnings = checks.filter((c) => !c.pass && c.level === "warning").length;
return {
action: "validate",
"skill-dir": skillDir,
success: failed === 0,
summary: { passed, failed, warnings },
checks,
optional,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
const skillDir = options["skill-dir"];
if (!skillDir) {
return {
action: "validate",
success: false,
error: "Missing required option: --skill-dir",
};
}
const checks: CheckResult[] = [];
let checkId = 1;
const ok = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
({ id: checkId++, pass: true, level, message });
const err = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
({ id: checkId++, pass: false, level, message });
// ── Check 1: SKILL.md exists ─────────────────────────────────────────────
const skillMdPath = join(skillDir, "SKILL.md");
if (!existsSync(skillMdPath)) {
checks.push(err("SKILL.md does not exist in skill directory"));
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}
checks.push(ok("SKILL.md exists"));
const raw = readFileSync(skillMdPath, "utf-8");
// ── Check 2: Frontmatter delimiters ──────────────────────────────────────
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) {
checks.push(err("YAML frontmatter delimiters (--- ... ---) not found"));
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}
checks.push(ok("YAML frontmatter delimiters found"));
const fm = parseFrontmatter(fmMatch[1]);
const body = fmMatch[2];
// ── Checks 3–9: name field ───────────────────────────────────────────────
const name = fm["name"];
if (!name) {
checks.push(err("'name' field is missing from frontmatter"));
} else {
checks.push(ok(`'name' field is present: ${name}`));
const dirName = basename(skillDir);
checks.push(
name === dirName
? ok("'name' matches directory name")
: err(`'name' (${name}) does not match directory name (${dirName})`)
);
checks.push(
name.length <= 64
? ok(`'name' length is ${name.length} (max 64)`)
: err(`'name' length is ${name.length} (max 64)`)
);
checks.push(
/[^a-z0-9-]/.test(name)
? err("'name' contains characters outside a-z, 0-9, hyphen")
: ok("'name' uses only allowed characters (a-z, 0-9, -)")
);
checks.push(
name.startsWith("-")
? err("'name' must not start with a hyphen")
: ok("'name' does not start with a hyphen")
);
checks.push(
name.endsWith("-")
? err("'name' must not end with a hyphen")
: ok("'name' does not end with a hyphen")
);
checks.push(
/--/.test(name)
? err("'name' contains consecutive hyphens")
: ok("'name' has no consecutive hyphens")
);
}
// ── Checks 10–11: description field ──────────────────────────────────────
const description = fm["description"];
if (!description || description.trim() === "") {
checks.push(err("'description' field is missing or empty"));
} else {
const descLen = description.trim().length;
checks.push(ok(`'description' is present (${descLen} chars)`));
checks.push(
descLen <= 1024
? ok(`'description' length is ${descLen} (max 1024)`)
: err(`'description' length is ${descLen} (max 1024)`)
);
}
// ── Check 12: Body line count ─────────────────────────────────────────────
const bodyLines = body.split("\n").length;
checks.push(
bodyLines <= 500
? ok(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`)
: err(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`, "warning")
);
// ── Check 13: scripts/ must have Taskfile.yml ─────────────────────────────
const scriptsDir = join(skillDir, "scripts");
if (existsSync(scriptsDir)) {
checks.push(
existsSync(join(scriptsDir, "Taskfile.yml"))
? ok("scripts/Taskfile.yml exists")
: err("scripts/ exists but Taskfile.yml is missing — it is required")
);
}
return buildOutput(skillDir, checks, inspectOptional(skillDir));
}