295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import ignore from "ignore";
|
|
import { parse } from "yaml";
|
|
import type { ExecutionEnv, Skill } from "./types.js";
|
|
|
|
const MAX_NAME_LENGTH = 64;
|
|
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
|
|
|
|
type IgnoreMatcher = ReturnType<typeof ignore>;
|
|
|
|
/** Warning produced while loading skills. */
|
|
export interface SkillDiagnostic {
|
|
/** Diagnostic severity. Currently only warnings are emitted. */
|
|
type: "warning";
|
|
/** Human-readable diagnostic message. */
|
|
message: string;
|
|
/** Path associated with the diagnostic. */
|
|
path: string;
|
|
}
|
|
|
|
interface SkillFrontmatter {
|
|
name?: string;
|
|
description?: string;
|
|
"disable-model-invocation"?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Load skills from one or more directories.
|
|
*
|
|
* Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files,
|
|
* and returns diagnostics for invalid skill files. Missing input directories are skipped.
|
|
*/
|
|
export async function loadSkills(
|
|
env: ExecutionEnv,
|
|
dirs: string | string[],
|
|
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
|
|
const skills: Skill[] = [];
|
|
const diagnostics: SkillDiagnostic[] = [];
|
|
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
|
|
const rootInfo = await safeFileInfo(env, dir);
|
|
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
|
|
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
|
|
skills.push(...result.skills);
|
|
diagnostics.push(...result.diagnostics);
|
|
}
|
|
return { skills, diagnostics };
|
|
}
|
|
|
|
/**
|
|
* Load skills from source-tagged directories.
|
|
*
|
|
* Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not
|
|
* interpret source values; applications define their own provenance shape.
|
|
*/
|
|
export async function loadSourcedSkills<TSource>(
|
|
env: ExecutionEnv,
|
|
inputs: Array<{ path: string; source: TSource }>,
|
|
): Promise<{
|
|
skills: Array<{ skill: Skill; source: TSource }>;
|
|
diagnostics: Array<SkillDiagnostic & { source: TSource }>;
|
|
}> {
|
|
const skills: Array<{ skill: Skill; source: TSource }> = [];
|
|
const diagnostics: Array<SkillDiagnostic & { source: TSource }> = [];
|
|
for (const input of inputs) {
|
|
const result = await loadSkills(env, input.path);
|
|
for (const skill of result.skills) skills.push({ skill, source: input.source });
|
|
for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source });
|
|
}
|
|
return { skills, diagnostics };
|
|
}
|
|
|
|
async function loadSkillsFromDirInternal(
|
|
env: ExecutionEnv,
|
|
dir: string,
|
|
includeRootFiles: boolean,
|
|
ignoreMatcher: IgnoreMatcher,
|
|
rootDir: string,
|
|
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
|
|
const skills: Skill[] = [];
|
|
const diagnostics: SkillDiagnostic[] = [];
|
|
|
|
if (!(await env.exists(dir))) return { skills, diagnostics };
|
|
const dirInfo = await safeFileInfo(env, dir);
|
|
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
|
|
|
|
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
|
|
|
|
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>;
|
|
try {
|
|
entries = await env.listDir(dir);
|
|
} catch {
|
|
return { skills, diagnostics };
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.name !== "SKILL.md") continue;
|
|
const fullPath = entry.path;
|
|
const kind = await resolveKind(env, entry);
|
|
if (kind !== "file") continue;
|
|
const relPath = relativeEnvPath(rootDir, fullPath);
|
|
if (ignoreMatcher.ignores(relPath)) continue;
|
|
|
|
const result = await loadSkillFromFile(env, fullPath);
|
|
if (result.skill) skills.push(result.skill);
|
|
diagnostics.push(...result.diagnostics);
|
|
return { skills, diagnostics };
|
|
}
|
|
|
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
const fullPath = entry.path;
|
|
const kind = await resolveKind(env, entry);
|
|
if (!kind) continue;
|
|
|
|
const relPath = relativeEnvPath(rootDir, fullPath);
|
|
const ignorePath = kind === "directory" ? `${relPath}/` : relPath;
|
|
if (ignoreMatcher.ignores(ignorePath)) continue;
|
|
|
|
if (kind === "directory") {
|
|
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir);
|
|
skills.push(...result.skills);
|
|
diagnostics.push(...result.diagnostics);
|
|
continue;
|
|
}
|
|
|
|
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
|
|
const result = await loadSkillFromFile(env, fullPath);
|
|
if (result.skill) skills.push(result.skill);
|
|
diagnostics.push(...result.diagnostics);
|
|
}
|
|
|
|
return { skills, diagnostics };
|
|
}
|
|
|
|
async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise<void> {
|
|
const relativeDir = relativeEnvPath(rootDir, dir);
|
|
const prefix = relativeDir ? `${relativeDir}/` : "";
|
|
|
|
for (const filename of IGNORE_FILE_NAMES) {
|
|
const ignorePath = joinEnvPath(dir, filename);
|
|
const info = await safeFileInfo(env, ignorePath);
|
|
if (info?.kind !== "file") continue;
|
|
try {
|
|
const content = await env.readTextFile(ignorePath);
|
|
const patterns = content
|
|
.split(/\r?\n/)
|
|
.map((line) => prefixIgnorePattern(line, prefix))
|
|
.filter((line): line is string => Boolean(line));
|
|
if (patterns.length > 0) ig.add(patterns);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function prefixIgnorePattern(line: string, prefix: string): string | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;
|
|
|
|
let pattern = line;
|
|
let negated = false;
|
|
if (pattern.startsWith("!")) {
|
|
negated = true;
|
|
pattern = pattern.slice(1);
|
|
} else if (pattern.startsWith("\\!")) {
|
|
pattern = pattern.slice(1);
|
|
}
|
|
if (pattern.startsWith("/")) pattern = pattern.slice(1);
|
|
const prefixed = prefix ? `${prefix}${pattern}` : pattern;
|
|
return negated ? `!${prefixed}` : prefixed;
|
|
}
|
|
|
|
async function loadSkillFromFile(
|
|
env: ExecutionEnv,
|
|
filePath: string,
|
|
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
|
|
const diagnostics: SkillDiagnostic[] = [];
|
|
try {
|
|
const rawContent = await env.readTextFile(filePath);
|
|
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent);
|
|
const skillDir = dirnameEnvPath(filePath);
|
|
const parentDirName = basenameEnvPath(skillDir);
|
|
|
|
for (const error of validateDescription(frontmatter.description)) {
|
|
diagnostics.push({ type: "warning", message: error, path: filePath });
|
|
}
|
|
|
|
const name = frontmatter.name || parentDirName;
|
|
for (const error of validateName(name, parentDirName)) {
|
|
diagnostics.push({ type: "warning", message: error, path: filePath });
|
|
}
|
|
|
|
if (!frontmatter.description || frontmatter.description.trim() === "") {
|
|
return { skill: null, diagnostics };
|
|
}
|
|
|
|
return {
|
|
skill: {
|
|
name,
|
|
description: frontmatter.description,
|
|
content: body,
|
|
filePath,
|
|
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
|
|
},
|
|
diagnostics,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "failed to parse skill file";
|
|
diagnostics.push({ type: "warning", message, path: filePath });
|
|
return { skill: null, diagnostics };
|
|
}
|
|
}
|
|
|
|
function validateName(name: string, parentDirName: string): string[] {
|
|
const errors: string[] = [];
|
|
if (name !== parentDirName) errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
|
|
if (name.length > MAX_NAME_LENGTH) errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
|
|
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)");
|
|
}
|
|
if (name.startsWith("-") || name.endsWith("-")) errors.push("name must not start or end with a hyphen");
|
|
if (name.includes("--")) errors.push("name must not contain consecutive hyphens");
|
|
return errors;
|
|
}
|
|
|
|
function validateDescription(description: string | undefined): string[] {
|
|
const errors: string[] = [];
|
|
if (!description || description.trim() === "") {
|
|
errors.push("description is required");
|
|
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
|
errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
|
|
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
|
|
const endIndex = normalized.indexOf("\n---", 3);
|
|
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
|
|
const yamlString = normalized.slice(4, endIndex);
|
|
const body = normalized.slice(endIndex + 4).trim();
|
|
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
|
|
}
|
|
|
|
async function safeFileInfo(
|
|
env: ExecutionEnv,
|
|
path: string,
|
|
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
|
|
try {
|
|
return await env.fileInfo(path);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function resolveKind(
|
|
env: ExecutionEnv,
|
|
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
|
|
): Promise<"file" | "directory" | undefined> {
|
|
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
|
try {
|
|
const realPath = await env.realPath(info.path);
|
|
const target = await env.fileInfo(realPath);
|
|
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function joinEnvPath(base: string, child: string): string {
|
|
return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`;
|
|
}
|
|
|
|
function dirnameEnvPath(path: string): string {
|
|
const normalized = path.replace(/\/+$/, "");
|
|
const slashIndex = normalized.lastIndexOf("/");
|
|
return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex);
|
|
}
|
|
|
|
function basenameEnvPath(path: string): string {
|
|
const normalized = path.replace(/\/+$/, "");
|
|
const slashIndex = normalized.lastIndexOf("/");
|
|
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
|
|
}
|
|
|
|
function relativeEnvPath(root: string, path: string): string {
|
|
const normalizedRoot = root.replace(/\/+$/, "");
|
|
const normalizedPath = path.replace(/\/+$/, "");
|
|
if (normalizedPath === normalizedRoot) return "";
|
|
return normalizedPath.startsWith(`${normalizedRoot}/`)
|
|
? normalizedPath.slice(normalizedRoot.length + 1)
|
|
: normalizedPath.replace(/^\/+/, "");
|
|
}
|