agents
Version:
A home for your AI agents
325 lines (324 loc) • 12.6 kB
JavaScript
//#region src/skills/result.ts
/** @internal Wrap a success value. */
function ok(value) {
return {
ok: true,
value
};
}
/** @internal Wrap an expected error. */
function err(error) {
return {
ok: false,
error
};
}
//#endregion
//#region src/skills/skill.ts
/**
* A `SKILL.md` that breaks an Agent Skills rule. Returned as the error of a
* failed parse, or as a warning when the skill loads anyway.
*/
var SkillParseError = class extends Error {
/**
* @param code - Which rule the skill broke.
* @param message - Human-readable explanation, safe to show to a model.
* @param options - The underlying cause, such as a YAML syntax error.
*/
constructor(code, message, options) {
super(message, options);
this.code = code;
this._tag = "SkillParseError";
this.name = "SkillParseError";
}
};
const NAME_MAX_LENGTH = 64;
const DESCRIPTION_MAX_LENGTH = 1024;
const COMPATIBILITY_MAX_LENGTH = 500;
const NAME_CHARACTERS = /^[\p{L}\p{N}-]+$/u;
const UNSAFE_NAME_CHARACTERS = /[/\\\u0000-\u001f\u007f]/;
const SPEC_FIELDS = /* @__PURE__ */ new Set([
"name",
"description",
"license",
"compatibility",
"metadata",
"allowed-tools"
]);
/**
* Parse a skill name against the Agent Skills spec.
*
* The name is NFKC-normalized first. It must be 1-64 characters of
* lowercase letters, digits, and hyphens, must not start or end with a
* hyphen, and must not contain `--`.
*
* @param input - The candidate name.
* @returns The branded name, or `SkillParseError` with code `invalid-name`.
*/
function parseSkillName(input) {
const name = input.normalize("NFKC");
const problem = specNameProblem(name);
if (problem !== null) return err(new SkillParseError("invalid-name", `Invalid skill name "${input}": ${problem}.`));
return ok(name);
}
/**
* @internal Build a skill from already-decoded frontmatter. Sources that do
* not start from a `SKILL.md` string (such as manifests) use this so every
* skill passes the same rules.
*/
function parseSkillFrontmatter(data, body, options = {}) {
if (!isRecord(data)) return err(new SkillParseError("invalid-frontmatter", "SKILL.md frontmatter must be a YAML mapping."));
const warnings = [];
let failure;
function take(field) {
if ("fatal" in field) {
failure ??= field.fatal;
return;
}
for (const problem of field.problems ?? []) warnings.push(problem);
return field.value;
}
const name = take(parseName(data.name, options.directory));
const description = take(parseDescription(data.description));
const license = take(parseLicense(data.license));
const compatibility = take(parseCompatibility(data.compatibility));
const metadata = take(parseMetadata(data.metadata));
const allowedTools = take(parseAllowedTools(data["allowed-tools"]));
if (failure || name === void 0 || description === void 0 || metadata === void 0 || allowedTools === void 0) return err(failure ?? new SkillParseError("invalid-frontmatter", "Invalid frontmatter."));
const extraFields = {};
for (const [key, value] of Object.entries(data)) if (!SPEC_FIELDS.has(key)) extraFields[key] = value;
return {
ok: true,
value: {
name,
description,
...license === void 0 ? {} : { license },
...compatibility === void 0 ? {} : { compatibility },
metadata,
allowedTools,
extraFields,
body
},
warnings
};
}
/**
* Whether a model may activate this skill on its own. Skills opt out with
* `disable-model-invocation: true`, as a top-level field or in `metadata`.
*
* @param skill - The parsed skill.
* @returns `false` when the skill disables model invocation.
*/
function isModelInvocable(skill) {
return skill.extraFields["disable-model-invocation"] !== true && skill.metadata["disable-model-invocation"] !== "true";
}
/** Why a name breaks the spec format, or `null` when it follows it. */
function specNameProblem(name) {
if (name.length === 0) return "it must not be empty";
if ([...name].length > NAME_MAX_LENGTH) return `it must be at most ${NAME_MAX_LENGTH} characters`;
if (name !== name.toLowerCase()) return "it must be lowercase";
if (!NAME_CHARACTERS.test(name)) return "it may only contain letters, digits, and hyphens";
if (name.startsWith("-") || name.endsWith("-")) return "it must not start or end with a hyphen";
if (name.includes("--")) return "it must not contain consecutive hyphens";
return null;
}
/** Whether a name is usable as a directory, enum value, and path segment. */
function isSafeName(name) {
return name.length > 0 && name === name.trim() && name !== "." && name !== ".." && !UNSAFE_NAME_CHARACTERS.test(name);
}
function parseName(value, directory) {
const fallback = (value === void 0 || value === null) && directory ? directory : void 0;
const declared = typeof value === "string" ? value : fallback;
if (declared === void 0) return { fatal: new SkillParseError("invalid-name", "SKILL.md frontmatter must set `name` to a string.") };
const name = declared.trim().normalize("NFKC");
if (!isSafeName(name)) return { fatal: new SkillParseError("invalid-name", `Invalid skill name "${declared}": it must be a single path segment without slashes or control characters.`) };
const accepted = name;
const problems = [];
if (fallback) problems.push(new SkillParseError("invalid-name", `SKILL.md frontmatter has no \`name\`; using its directory "${fallback}".`));
else {
const formatProblem = declared !== declared.trim() ? "it must not start or end with whitespace" : specNameProblem(name);
if (formatProblem !== null) problems.push(new SkillParseError("invalid-name", `Invalid skill name "${declared}": ${formatProblem}.`));
if (directory !== void 0 && directory.normalize("NFKC") !== name) problems.push(new SkillParseError("name-mismatch", `Skill name "${name}" must match its directory "${directory}".`));
}
return problems.length === 0 ? { value: accepted } : {
value: accepted,
problems
};
}
function parseDescription(value) {
const description = typeof value === "string" ? value.trim() : "";
if (description.length === 0) return { fatal: new SkillParseError("invalid-description", "SKILL.md frontmatter must set `description` to a non-empty string.") };
if ([...description].length > DESCRIPTION_MAX_LENGTH) return {
value: description,
problems: [new SkillParseError("invalid-description", `Skill description must be at most ${DESCRIPTION_MAX_LENGTH} characters.`)]
};
return { value: description };
}
function parseLicense(value) {
if (value === void 0 || value === null) return { value: void 0 };
if (typeof value !== "string" || value.trim().length === 0) return {
value: void 0,
problems: [new SkillParseError("invalid-license", "Skill `license` must be a non-empty string.")]
};
return { value: value.trim() };
}
function parseCompatibility(value) {
if (value === void 0 || value === null) return { value: void 0 };
const compatibility = typeof value === "string" ? value.trim() : "";
if (compatibility.length === 0 || [...compatibility].length > COMPATIBILITY_MAX_LENGTH) return {
value: compatibility.length === 0 ? void 0 : compatibility,
problems: [new SkillParseError("invalid-compatibility", `Skill \`compatibility\` must be 1-${COMPATIBILITY_MAX_LENGTH} characters.`)]
};
return { value: compatibility };
}
/**
* The spec defines `metadata` as a string-to-string map. YAML turns
* unquoted values such as `version: 1.0` into numbers, so scalar values are
* converted to strings rather than rejected. Nested values break the rule
* and are dropped.
*/
function parseMetadata(value) {
if (value === void 0 || value === null) return { value: {} };
if (!isRecord(value)) return {
value: {},
problems: [new SkillParseError("invalid-metadata", "Skill `metadata` must be a mapping of string keys to string values.")]
};
const metadata = {};
let problem;
for (const [key, entry] of Object.entries(value)) {
if (typeof entry !== "string" && typeof entry !== "number" && typeof entry !== "boolean") {
problem ??= new SkillParseError("invalid-metadata", `Skill \`metadata.${key}\` must be a string.`);
continue;
}
metadata[key] = String(entry);
}
return problem ? {
value: metadata,
problems: [problem]
} : { value: metadata };
}
/**
* The spec defines `allowed-tools` as a space-separated string. A YAML list
* of strings is also accepted, since some clients write it that way.
*/
function parseAllowedTools(value) {
if (value === void 0 || value === null) return { value: [] };
if (typeof value === "string") return { value: value.split(/\s+/).filter((tool) => tool.length > 0) };
if (Array.isArray(value) && value.every((tool) => typeof tool === "string")) return { value: value.map((tool) => tool.trim()).filter(Boolean) };
return {
value: [],
problems: [new SkillParseError("invalid-allowed-tools", "Skill `allowed-tools` must be a space-separated string.")]
};
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
//#endregion
//#region src/skills/resource.ts
/** A resource path that is absolute, escapes the skill, or is malformed. */
var InvalidSkillResourcePathError = class extends Error {
/** @param path - The rejected path. */
constructor(path) {
super(`Skill resource path must be a normalized relative path inside the skill: ${path}`);
this.path = path;
this._tag = "InvalidSkillResourcePathError";
this.name = "InvalidSkillResourcePathError";
}
};
/**
* Parse a path to a file inside a skill directory.
*
* @param path - Candidate path relative to the skill root.
* @returns The branded path, or `InvalidSkillResourcePathError` when it is
* empty, absolute, contains `.`/`..`/empty segments or NUL, or names
* `SKILL.md` itself.
*/
function parseSkillResourcePath(path) {
if (path.length === 0 || path.startsWith("/") || path.includes("\0") || path.includes("\\") || path === "SKILL.md" || path.split("/").some((part) => part === "" || part === "." || part === "..")) return err(new InvalidSkillResourcePathError(path));
return ok(path);
}
const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
"",
".bash",
".css",
".csv",
".html",
".js",
".json",
".jsx",
".md",
".mdx",
".mjs",
".py",
".sh",
".svg",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml"
]);
const MIME_TYPES = /* @__PURE__ */ new Map([
[".css", "text/css"],
[".csv", "text/csv"],
[".gif", "image/gif"],
[".html", "text/html"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".js", "text/javascript"],
[".json", "application/json"],
[".md", "text/markdown"],
[".mjs", "text/javascript"],
[".pdf", "application/pdf"],
[".png", "image/png"],
[".py", "text/x-python"],
[".sh", "text/x-shellscript"],
[".svg", "image/svg+xml"],
[".txt", "text/plain"],
[".webp", "image/webp"],
[".xml", "application/xml"],
[".yaml", "application/yaml"],
[".yml", "application/yaml"]
]);
/** Directories and files never treated as skill resources. */
const IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
".git",
"node_modules",
".DS_Store"
]);
/** @internal Lowercased extension including the dot, or `""`. */
function extensionOf(path) {
const file = path.split("/").at(-1) ?? path;
const index = file.lastIndexOf(".");
return index <= 0 ? "" : file.slice(index).toLowerCase();
}
/** @internal Whether a skill-relative path is tooling noise, not content. */
function isIgnoredSkillPath(path) {
return path.split("/").some((part) => IGNORED_SEGMENTS.has(part));
}
/** @internal Describe a resource from its path alone. */
function describeSkillResource(path, size) {
const root = path.split("/", 1)[0];
const kind = root === "scripts" ? "script" : root === "references" ? "reference" : root === "assets" ? "asset" : "file";
const extension = extensionOf(path);
const mimeType = MIME_TYPES.get(extension);
return {
path,
kind,
encoding: TEXT_EXTENSIONS.has(extension) ? "text" : "base64",
...mimeType === void 0 ? {} : { mimeType },
...size === void 0 ? {} : { size }
};
}
/** @internal Encode file bytes the way {@link SkillResource} expects. */
function encodeSkillResourceContent(bytes, encoding) {
if (encoding === "text") return new TextDecoder().decode(bytes);
const parts = [];
for (let offset = 0; offset < bytes.byteLength; offset += 32768) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 32768)));
return btoa(parts.join(""));
}
//#endregion
export { isIgnoredSkillPath as a, isModelInvocable as c, err as d, ok as f, extensionOf as i, parseSkillFrontmatter as l, describeSkillResource as n, parseSkillResourcePath as o, encodeSkillResourceContent as r, SkillParseError as s, InvalidSkillResourcePathError as t, parseSkillName as u };
//# sourceMappingURL=resource-B59OV2a2.js.map