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

271 lines (270 loc) • 12.9 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/. /** * Shared recognition / placement / validation for the two AGENT TOOL-DIRECTORY * adapters — `claude` (`.claude`) and `opencode` (`.opencode`) — akm 0.9.0 * format-family work item (#46). Both translate a foreign tool layout into the * open AKM `type` vocabulary (spec §6/§7): a root instruction file * (CLAUDE.md / AGENTS.md) → `instruction`, `commands/*.md` → `command`, * `agents/*.md` → `agent`, `skills/<name>/SKILL.md` → `skill` (item = the dir). * They differ only in two parameters carried on {@link ToolDirLayout}: * - the instruction basename + its concept id, * - the adapter id + component id. * * The SKILL.md codec is shared with the `agent-skills` adapter as functions * (spec §8): a skill's conceptId is its DIRECTORY, and its `name` is the * frontmatter `name` (== the dir name per the Agent Skills §4.5 hard rule). * * ── D-R6 (reserved files) ── * * `index.md` / `log.md` are OKF structural files at EVERY depth — never a * concept. `classify` excludes them up front so a stray `commands/index.md` * never becomes a `command` (mirrors okf/akm/llm-wiki `RESERVED_FILES`). * * ── validate leniency (spec §6 skill row) ── * * The tool-dir adapters TOLERATE the tools' native frontmatter (argument-hint, * allowed-tools, tools, model, mode, temperature, agent, …) — the strict * unknown-frontmatter behavior belongs to `agent-skills`, not here. So `command` * / `agent` get a LENIENT name-or-signal check (never the akm `CommandLinter`'s * frontmatter `name`+`type` requirement, which these tool files do not carry), * `skill` gets the coded `missing-skill-md` directory check * ({@link skillDirectoryDiagnostics}), and `instruction` runs the shared base * checks (a no-op on a frontmatter-free CLAUDE.md/AGENTS.md). Notably NO base * checks run on command/agent/skill: their tool frontmatter carries no `updated` * field, so `missing-updated` would fire on every file and contradict the lint * golden's clean result. * * ── Cycle-safety ── * * Imported only by `claude-adapter.ts` / `opencode-adapter.ts` (themselves * imported by the `adapters/index.ts` barrel — NOT test-only: `core/adapter/ * registry.ts:33` imports it in production for the frozen `BUILTIN_ADAPTERS` * list). This leaf still can never gain an inbound edge from a cycle * participant, since nothing on that import chain imports back into it. It * value-imports only pure leaves (`shared`, `akm-lint`, `frontmatter`) plus * Node builtins. */ import path from "node:path"; import { createAdapterExtensions, } from "../../../execution/source.js"; import { parseFrontmatter } from "../../asset/frontmatter.js"; import { toPosix } from "../../common.js"; import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js"; import { skillDirectoryDiagnostics } from "./akm-lint.js"; import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js"; /** The canonical (plural) subdir spellings writes normalize to (open-question-6). */ const CANONICAL_COMMAND_DIR = "commands"; const CANONICAL_AGENT_DIR = "agents"; const CANONICAL_SKILL_DIR = "skills"; const SKILL_MANIFEST = "SKILL.md"; function isReserved(base) { return RESERVED_FILES.has(base.toLowerCase()); } /** Classify one component-root-relative file into a tool-dir `type` + conceptId, or abstain (null). */ function classify(relPath, layout) { const posix = toPosix(relPath); const segs = posix.split("/").filter((s) => s.length > 0); if (segs.length === 0) return null; const base = segs[segs.length - 1]; if (isReserved(base)) return null; // Root instruction file. if (segs.length === 1 && base === layout.instructionFile) { return { type: "instruction", conceptId: layout.instructionConceptId, name: layout.instructionConceptId }; } const head = segs[0]; const ext = path.extname(base).toLowerCase(); // skill: <skillDir>/<name>/SKILL.md — the item is the DIRECTORY. Any other // file under a skill dir (bundled resources) is part of the item, not a concept. if (layout.skillDirs.has(head)) { if (segs.length === 3 && base === SKILL_MANIFEST) { return { type: "skill", conceptId: `${segs[0]}/${segs[1]}`, name: segs[1] }; } return null; } if (layout.commandDirs.has(head) && ext === ".md" && segs.length >= 2) { return { type: "command", conceptId: posix.replace(/\.md$/i, ""), name: base.replace(/\.md$/i, "") }; } if (layout.agentDirs.has(head) && ext === ".md" && segs.length >= 2) { return { type: "agent", conceptId: posix.replace(/\.md$/i, ""), name: base.replace(/\.md$/i, "") }; } return null; } /** recognize() for a tool-dir adapter: derive the open `type`, project the OKF-shaped fields. */ export function recognizeToolDir(layout, c, file) { const cls = classify(file.relPath, layout); if (cls === null) return null; const raw = file.content(); const parsed = parseFrontmatter(raw); const data = parsed.data; const body = parsed.content; // For a skill, `name` is the frontmatter `name` (== dir name); otherwise the // basename-derived concept name. const name = cls.type === "skill" ? (nonEmptyString(data.name) ?? cls.name) : cls.name; const description = nonEmptyString(data.description); const tags = readTags(data.tags); const doc = { ref: `${c.id}//${cls.conceptId}`, bundle: c.id, component: layout.componentId, conceptId: cls.conceptId, path: file.absPath, hash: hashContent(raw), adapterId: layout.adapterId, type: cls.type, name, content: body, }; if (cls.type === "instruction") doc.ownsPresentation = true; if (description !== undefined) doc.description = description; if (tags !== undefined) doc.tags = tags; return doc; } /** * placeNew() for a tool-dir adapter (spec §7: "AKM workspace layout IS the tool * dir minus the prefix"). Writes NORMALIZE to the canonical plural subdir * (open-question-6): a `command/foo` concept still places at `commands/foo.md`. * The instruction file is fixed at the component root. */ export function placeNewToolDir(layout, c, conceptId) { const posix = toPosix(conceptId); if (posix === layout.instructionConceptId) return path.join(c.root, layout.instructionFile); const segs = posix.split("/").filter((s) => s.length > 0); const head = segs[0]; const rest = segs.slice(1).join("/"); if (rest.length > 0) { if (layout.skillDirs.has(head)) return path.join(c.root, CANONICAL_SKILL_DIR, rest, SKILL_MANIFEST); if (layout.commandDirs.has(head)) return path.join(c.root, CANONICAL_COMMAND_DIR, `${rest}.md`); if (layout.agentDirs.has(head)) return path.join(c.root, CANONICAL_AGENT_DIR, `${rest}.md`); } return path.join(c.root, `${posix}.md`); } /** List native read spellings. */ export function readCandidatesToolDir(layout, c, conceptId) { const posix = toPosix(conceptId); if (posix === layout.instructionConceptId) { return [{ path: path.join(c.root, layout.instructionFile), conceptId: posix }]; } const segs = posix.split("/").filter((segment) => segment.length > 0); const head = segs[0]; const rest = segs.slice(1).join("/"); if (!head || !rest) return []; if (layout.skillDirs.has(head)) { return segs.length === 2 ? [{ path: path.join(c.root, head, rest, SKILL_MANIFEST), conceptId: posix }] : []; } if (layout.commandDirs.has(head) || layout.agentDirs.has(head)) { return [{ path: path.join(c.root, head, `${rest}.md`), conceptId: posix }]; } return []; } /** LENIENT command/agent check: a diagnostic only when NEITHER a name/description NOR a type-shaped signal is present. */ function nameOrSignalDiagnostics(type, relPath, data, body) { if (nonEmptyString(data.name) !== undefined || nonEmptyString(data.description) !== undefined) return []; // command-shaped body/frontmatter signals (spec §6 command row): $ARGUMENTS / $1 / an `agent` frontmatter key. if (type === "command" && (/\$ARGUMENTS\b/.test(body) || /\$\d/.test(body) || nonEmptyString(data.agent) !== undefined)) { return []; } return [ { file: relPath, issue: "missing-name-or-type", detail: `${type} has neither a name/description nor a ${type}-shaped signal`, fixed: false, }, ]; } /** validate() for a tool-dir adapter — see file header for the leniency contract. */ export async function validateToolDir(layout, c, changes, ctx) { const diagnostics = []; const seenSkillDirs = 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 relPath = toPosix(change.path); // The one coded skill check (missing-skill-md) fires on ANY change under a // `<skillDir>/<name>/…` package (self-gated + deduped), even a bundled // resource — mirrors the akm adapter's per-change SkillLinter.lintDirectory // pass. diagnostics.push(...(await skillDirectoryDiagnostics(relPath, seenSkillDirs, ctx, layout.skillDirs))); const cls = classify(change.path, layout); if (cls === null) continue; if (cls.type === "instruction") { diagnostics.push(...(await runBaseValidateChecks(relPath, parseFrontmatter(raw), c.root, ctx))); } else if (cls.type === "command" || cls.type === "agent") { const parsed = parseFrontmatter(raw); diagnostics.push(...nameOrSignalDiagnostics(cls.type, relPath, parsed.data, parsed.content)); } // skill: covered by skillDirectoryDiagnostics above; no additional per-file check. } return diagnostics; } function nativeExecutionExtensions(layout, cls, data) { const values = {}; if (layout.adapterId === "claude" && cls.type === "command" && Object.hasOwn(data, "argument-hint")) { if (typeof data["argument-hint"] !== "string") { throw new TypeError("frontmatter.argument-hint must be a string"); } values.argumentHint = data["argument-hint"]; } if (layout.adapterId === "opencode" && Object.hasOwn(data, "mode")) { if (typeof data.mode !== "string") throw new TypeError("frontmatter.mode must be a string"); values.mode = data.mode; } return Object.keys(values).length > 0 ? createAdapterExtensions(layout.adapterId, values) : undefined; } /** Translate a native Claude/OpenCode command or agent without exposing raw frontmatter. */ export function renderToolDirExecutionSource(layout, c, file) { const cls = classify(file.relPath, layout); if (cls?.type !== "command" && cls?.type !== "agent") return null; const raw = file.content(); return renderMarkdownExecutionSource({ kind: cls.type === "command" ? "command" : "persona", raw, identity: { ref: `${c.id}//${cls.conceptId}`, bundle: c.id, adapter: layout.adapterId, file: file.relPath, }, defaults: (data) => executionDefaultsFromFrontmatter(data, { kind: cls.type === "command" ? "command" : "persona", toolsKeys: layout.adapterId === "claude" && cls.type === "command" ? ["allowed-tools", "tools"] : ["tools"], }), extensions: (data) => nativeExecutionExtensions(layout, cls, data), }); } /** Build the concrete `BundleAdapter` from a layout + a `looksLikeRoot` probe (claude/opencode share everything else). */ export function makeToolDirAdapter(layout, looksLikeRoot) { return { id: layout.adapterId, version: "0.9.0", extensions: [".md"], recognize: (c, file) => recognizeToolDir(layout, c, file), renderExecutionSource: (c, file) => renderToolDirExecutionSource(layout, c, file), validate: (c, changes, ctx) => validateToolDir(layout, c, changes, ctx), readCandidates: (c, conceptId) => readCandidatesToolDir(layout, c, conceptId), placeNew: (c, conceptId) => placeNewToolDir(layout, c, conceptId), directoryList: () => [CANONICAL_COMMAND_DIR, CANONICAL_AGENT_DIR, CANONICAL_SKILL_DIR], looksLikeRoot, }; }