UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

259 lines (258 loc) • 11.7 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * The `agent-skills` adapter — akm 0.9.0 format-family work item (#46). * * Recognizes a COLLECTION of standalone SKILL.md packages (like * github.com/anthropics/skills): each `<name>/SKILL.md` is one `skill` item * whose conceptId is the package DIRECTORY (`<name>`); bundled resources under * the package (`<name>/reference/FORMS.md`) are part of the item, not standalone * concepts (item-scoped incrementality, spec §4). The SKILL.md codec is shared * with claude/opencode as functions (spec §8); this adapter differs in that the * package dir sits directly under the component root (no `skills/` prefix) and * that `validate` enforces the FULL Agent Skills contract (spec §4.5). * * RECOGNITION ≠ VALIDATION: an invalid skill (bad name charset, over-long * description) is still RECOGNIZED as `type: skill` (the raw invalid `name` is * projected so it is inspectable downstream); the violations surface only in * `validate` (spec §4.5). This mirrors how `okf` recognizes content and defers * leniency to validate. * * ── validate (spec §4.5, APPROVED codes — maintainer resolution 2026-07) ── * * - `skill-name-invalid` — name must be NFKC 1-64 chars matching * `^[a-z0-9]+(-[a-z0-9]+)*$`, carry no reserved word, and equal the parent * dir name. * - `skill-description-too-long` — description must be 1-1024 chars. * - `missing-skill-md` — a package dir with no SKILL.md (edge case; git cannot * commit an empty dir, so it is covered by a directory-level check, not a * fixture). Implemented by {@link missingManifestDiagnostics}, which scans * the component root through `ValidateContext.list` — the change-set loop * cannot reach it, because a change is always a file and a manifest-less * package contributes no SKILL.md change (issue #774). * * The two field codes are APPROVED-BUT-NOT-YET-CODED elsewhere and are * implemented here. Base checks are NOT run: * a SKILL.md carries no `updated` field, so `missing-updated` would fire on * every conformant skill and contradict the lint golden. * * Conformance oracle (authored, DO NOT modify): fixture * `tests/fixtures/bundles/agent-skills/` + goldens * `tests/fixtures/format-family-goldens/agent-skills/{recognition,placement,lint,renderer}.json`. */ import fs from "node:fs"; import path from "node:path"; import { parseFrontmatter } from "../../asset/frontmatter.js"; import { toPosix } from "../../common.js"; import { hashContent, nonEmptyString, readTags } from "./shared.js"; /** A skills collection is a single-component bundle; its one component is `main`. */ const COMPONENT_ID = "main"; /** The skill manifest basename (the item marker). */ const SKILL_MANIFEST = "SKILL.md"; /** Agent Skills hard limits (spec §4.5). */ const NAME_MAX = 64; const DESCRIPTION_MAX = 1024; /** Agent Skills reserved words a name must not contain (real-world source). */ const RESERVED_NAME_WORDS = ["anthropic", "claude"]; /** name charset/shape rule (spec §4.5). */ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; /** conceptId + package-dir name for a `<name>/SKILL.md` file, or null when the file is not a skill manifest. */ function skillPackage(relPath) { const posix = toPosix(relPath); const segs = posix.split("/").filter((s) => s.length > 0); // The item marker is a SKILL.md inside a package directory (`<name>/SKILL.md`, // possibly nested `<a>/<b>/SKILL.md`); a bare root SKILL.md is not a package. if (segs.length < 2 || segs[segs.length - 1] !== SKILL_MANIFEST) return null; const conceptId = segs.slice(0, segs.length - 1).join("/"); const dirName = segs[segs.length - 2]; return { conceptId, dirName }; } function recognize(c, file) { const pkg = skillPackage(file.relPath); if (pkg === null) return null; const raw = file.content(); const parsed = parseFrontmatter(raw); const data = parsed.data; const body = parsed.content; // name is projected RAW so an invalid name is inspectable; fall back to the dir. const name = nonEmptyString(data.name) ?? pkg.dirName; const description = nonEmptyString(data.description); const tags = readTags(data.tags); const doc = { ref: `${c.id}//${pkg.conceptId}`, bundle: c.id, component: COMPONENT_ID, conceptId: pkg.conceptId, path: file.absPath, hash: hashContent(raw), adapterId: "agent-skills", type: "skill", name, content: body, }; if (description !== undefined) doc.description = description; if (tags !== undefined) doc.tags = tags; return doc; } /** The Agent Skills §4.5 hard-rule checks for one SKILL.md (name + description). */ function skillFieldDiagnostics(relPath, dirName, data) { const diagnostics = []; const name = typeof data.name === "string" ? data.name.normalize("NFKC") : ""; const lower = name.toLowerCase(); const reserved = RESERVED_NAME_WORDS.find((w) => lower.includes(w)); const nameInvalid = name.length < 1 || name.length > NAME_MAX || !NAME_RE.test(name) || reserved !== undefined || name !== dirName; if (nameInvalid) { diagnostics.push({ file: relPath, issue: "skill-name-invalid", detail: `skill name '${name}' must match ^[a-z0-9]+(-[a-z0-9]+)*$ (NFKC, 1-${NAME_MAX} chars, ` + `no reserved word, == parent dir name '${dirName}') — Agent Skills hard rule (spec §4.5).`, fixed: false, }); } const description = typeof data.description === "string" ? data.description : ""; if (description.length > DESCRIPTION_MAX) { diagnostics.push({ file: relPath, issue: "skill-description-too-long", detail: `skill description is ${description.length} characters; the Agent Skills hard limit is 1-${DESCRIPTION_MAX} (spec §4.5).`, fixed: false, }); } return diagnostics; } /** * How deep below a candidate package directory the manifest probe looks. Agent * Skills packages sit at the component root (`<name>/SKILL.md`), occasionally * one group level down (`<group>/<name>/SKILL.md`) — this bound keeps a deep * resource tree (a package's `reference/`, `assets/`, …) from turning the lint * sweep into a full recursive walk. */ const MAX_PACKAGE_PROBE_DEPTH = 3; function missingManifestDiagnostic(dir) { return { file: dir, issue: "missing-skill-md", detail: `no SKILL.md in ${dir}/`, fixed: false }; } /** * Classify one directory as a package, a grouping directory, or one broken * package candidate. Once a real package root is found, its resource * directories are never descended into. If manifests exist only below this * directory, it is a group and each sibling candidate is checked independently. */ async function scanPackageCandidate(dir, entries, ctx, depth) { if (entries.includes(SKILL_MANIFEST)) return { containsManifest: true, diagnostics: [] }; if (depth >= MAX_PACKAGE_PROBE_DEPTH) { return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] }; } const children = []; for (const entry of entries) { if (entry.startsWith(".")) continue; const child = `${dir}/${entry}`; // `list` on a FILE yields `[]` (the read throws and is swallowed), so an // empty listing is the "not a directory worth descending" signal — no // separate stat is available on ValidateContext, and none is needed. const childEntries = await ctx.list(child); if (childEntries.length === 0) continue; children.push(await scanPackageCandidate(child, childEntries, ctx, depth + 1)); } if (children.some((child) => child.containsManifest)) { return { containsManifest: true, diagnostics: children.flatMap((child) => child.diagnostics), }; } // No descendant establishes this as a grouping directory. Diagnose the // candidate itself, not its resource subdirectories. return { containsManifest: false, diagnostics: [missingManifestDiagnostic(dir)] }; } /** * The directory-level `missing-skill-md` check (issue #774). * * `validate` walks CHANGES, and a change is always a file — so a package * directory carrying resources but no manifest contributes nothing the * change-loop can see, and the check the spec (§4.5) and the lint golden's * `missingSkillMd` edge case both name was unreachable. This scans the * component root through {@link ValidateContext.list} instead, so the case is * actually reported. * * A package's own resource dirs (`pdf-processing/reference/`) are part of the * item, not candidate packages. Grouping directories are supported too, but * their children are classified independently so one valid package cannot hide * a manifest-less sibling. */ async function missingManifestDiagnostics(ctx) { const diagnostics = []; for (const name of await ctx.list(".")) { if (name.startsWith(".")) continue; // .git, .github, … are not skill packages const entries = await ctx.list(name); if (entries.length === 0) continue; // a root file (README.md), or an untrackable empty dir diagnostics.push(...(await scanPackageCandidate(name, entries, ctx, 1)).diagnostics); } return diagnostics; } async function validate(_c, changes, ctx) { const diagnostics = []; const seenDirs = new Set(); for (const change of changes) { if (change.op === "delete") continue; const raw = change.after ?? (await ctx.readFile(change.path)); if (typeof raw !== "string") continue; const pkg = skillPackage(change.path); if (pkg === null) continue; if (seenDirs.has(pkg.conceptId)) continue; seenDirs.add(pkg.conceptId); // `missing-skill-md` cannot fire here — the change IS a SKILL.md. The // manifest-less package case is served by {@link missingManifestDiagnostics} // below, which scans directories rather than changes. diagnostics.push(...skillFieldDiagnostics(toPosix(change.path), pkg.dirName, parseFrontmatter(raw).data)); } diagnostics.push(...(await missingManifestDiagnostics(ctx))); return diagnostics; } export const agentSkillsAdapter = { id: "agent-skills", version: "0.9.0", extensions: [".md"], recognize, validate, readCandidates(c, conceptId) { return [{ path: path.join(c.root, conceptId, SKILL_MANIFEST), conceptId }]; }, /** A skill places to `<name>/SKILL.md`; the conceptId IS the package directory (spec §4.5). */ placeNew(c, conceptId) { return path.join(c.root, conceptId, SKILL_MANIFEST); }, /** Install-time probe (§1.2): a root that directly contains one or more `<name>/SKILL.md` skill packages. */ looksLikeRoot(root) { let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return false; } return entries.some((entry) => { if (!entry.isDirectory()) return false; try { return fs.existsSync(path.join(root, entry.name, SKILL_MANIFEST)); } catch { return false; } }); }, };