import type { Context } from "@earendil-works/chord"; import { getOrThrow } from "@earendil-works/pi-durable/env"; import type { Environment } from "@berry/api"; import { parse } from "@std/yaml"; import { userDirectory } from "./paths.ts"; export const MAX_SKILL_BYTES = 64 * 1024; const MAX_SKILLS = 128; const MAX_COLLECTION_ENTRIES = 256; export type Skill = { name: string; description: string; directory: string; location: string; scope: "project" | "user" | "host"; }; export type Catalog = { projectRoot: string; skills: Skill[]; diagnostics: string[]; }; export function parseSkill(content: string, directoryName: string) { const frontmatter = content.match(/^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m); if (!frontmatter || frontmatter.index !== 0) throw new Error("Missing YAML frontmatter"); const metadata = parse(frontmatter[1], { schema: "core" }); if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { throw new Error("Frontmatter must be a mapping"); } const { name, description } = metadata as Record; if (typeof name !== "string" || name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { throw new Error("name must be at most 64 lowercase letters, numbers, and single hyphens"); } if (typeof description !== "string" || !description.trim() || description.length > 1024) { throw new Error("description must be a nonempty string of at most 1024 characters"); } const warnings: string[] = []; if (name !== directoryName) warnings.push(`name ${name} differs from directory ${directoryName}`); return { name, description, warnings }; } /** A bounded, complete UTF-8 read of a regular skill file. */ export async function readSkill(env: Environment, location: string, context: Context) { const reader = getOrThrow(await env.openBinaryReader(location, undefined, context)); try { const { size } = getOrThrow(await reader.info(context)); if (size > MAX_SKILL_BYTES) throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES / 1024} KiB`); const bytes = getOrThrow(await reader.read(0, MAX_SKILL_BYTES + 1, context)); if (bytes.length > MAX_SKILL_BYTES) { throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES / 1024} KiB`); } return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } finally { await reader.close(context); } } /** The cwd's ancestors through its nearest repository root, or just the cwd outside a repository. */ export async function projectDirectories(env: Environment, context: Context): Promise { const cwd = getOrThrow(await env.canonicalPath(env.cwd, context)); const ancestors: string[] = []; for (let dir = cwd; ancestors.length < 128;) { ancestors.push(dir); for (const marker of [".git", ".jj"]) { const path = getOrThrow(await env.joinPath([dir, marker], context)); const found = await env.fileInfo(path, context); if (found.ok) return ancestors; if (found.error.code !== "not_found" && found.error.code !== "not_directory") { throw found.error; } } const parent = getOrThrow(await env.joinPath([dir, ".."], context)); if (parent === dir) break; dir = parent; } return [cwd]; } /** Discover only known collections, with project roots omitted unless explicitly allowed. */ export async function discover( env: Environment, host: Environment, context: Context, trustedRoots?: readonly string[], ): Promise { const directories = await projectDirectories(env, context); const projectRoot = directories.at(-1)!; const sources: { env: Environment; path: string; scope: Skill["scope"] }[] = []; if (trustedRoots === undefined || trustedRoots.includes(projectRoot)) { for (const dir of directories) { sources.push({ env, path: getOrThrow(await env.joinPath([dir, ".agents", "skills"], context)), scope: "project", }); } } const config = await userDirectory(host, "XDG_CONFIG_HOME", [".config"], context); sources.push( { env: host, path: getOrThrow(await host.joinPath([config, "berry", "skills"], context)), scope: "host", }, { env, path: getOrThrow(await env.joinPath([env.home, ".agents", "skills"], context)), scope: "user", }, ); const skills = new Map(); const diagnostics: string[] = []; const seen = new Set(); for (const source of sources) { const env = source.env; const opened = await env.openDirReader(source.path, context); if (!opened.ok) { if (opened.error.code === "aborted") throw opened.error; if (opened.error.code !== "not_found") { diagnostics.push(`${source.path}: ${opened.error.message}`); } continue; } const reader = opened.value; let page; try { page = getOrThrow(await reader.next(MAX_COLLECTION_ENTRIES + 1, context)); } finally { await reader.close(context); } if (!page.done || page.entries.length > MAX_COLLECTION_ENTRIES) { diagnostics.push(`${source.path}: stopped after ${MAX_COLLECTION_ENTRIES} entries`); } const entries = page.entries.slice(0, MAX_COLLECTION_ENTRIES).sort((a, b) => a.name.localeCompare(b.name) ); for (const entry of entries) { if (entry.kind === "file" || entry.name.startsWith(".")) continue; const location = getOrThrow(await env.joinPath([entry.path, "SKILL.md"], context)); const canonical = await env.canonicalPath(location, context); if (!canonical.ok) { if (canonical.error.code === "aborted") throw canonical.error; if (canonical.error.code !== "not_found") { diagnostics.push(`${location}: ${canonical.error.message}`); } continue; } const identity = `${env.id}\0${canonical.value}`; if (seen.has(identity)) continue; seen.add(identity); try { const content = await readSkill(env, canonical.value, context); const { name, description, warnings } = parseSkill(content, entry.name); for (const warning of warnings) diagnostics.push(`${location}: ${warning}`); const previous = skills.get(name); if (previous) { diagnostics.push(`${location}: ${name} is shadowed by ${previous.location}`); continue; } if (skills.size === MAX_SKILLS) { diagnostics.push(`Stopped after ${MAX_SKILLS} skills`); return { projectRoot, skills: [...skills.values()], diagnostics }; } skills.set(name, { name, description, directory: getOrThrow(await env.canonicalPath(entry.path, context)), location: canonical.value, scope: source.scope, }); } catch (error) { context.abortSignal?.throwIfAborted(); if (error instanceof Error && "code" in error && error.code === "aborted") throw error; diagnostics.push(`${location}: ${error instanceof Error ? error.message : String(error)}`); } } } return { projectRoot, skills: [...skills.values()], diagnostics }; }