- 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>
288 lines
9.3 KiB
TypeScript
288 lines
9.3 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|