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
182 lines (181 loc) • 7.36 kB
JavaScript
// 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 `akm-task` adapter for task `.yml` sources.
*
* A native akm task-YAML bundle (spec §6/§7). A `.yml` file derives
* `type: task`; conceptId strips the `.yml` extension. Tasks are AKM-native
* YAML, NOT OKF markdown concepts. Recognition does NOT gate on validity — an
* invalid task (e.g. `uses` plus `run`) is still RECOGNIZED; the `invalid-task-yaml`
* violation surfaces only in `validate`.
*
* `.yaml` is the one extension `validate` inspects but `recognize` refuses: it
* is not a task spelling (nothing indexes or schedules it), so it is reported
* as `invalid-task-yaml` rather than silently skipped (issue #760).
*
* ── validate (spec §6 task validation column) ──
*
* Validation enters the canonical task source parser (`parseTaskSource`,
* which reads only task source v4 — a `version: 2` or `version: 3`
* document fails closed with `TASK_SCHEMA_VERSION_UNSUPPORTED`, naming
* `akm migrate apply`, which converts it). That parser owns the closed key sets,
* the executable-selector XOR, hostile YAML policy, the `akm/command`
* builtin, bounds, and physical `working-directory` containment. The
* adapter only translates a parser failure into the format-family
* diagnostic shape.
*
* Conformance oracle (authored, DO NOT modify): fixture
* `tests/fixtures/bundles/akm-task/` + goldens
* `tests/fixtures/format-family-goldens/akm-task/{recognition,placement,lint,renderer}.json`.
*/
import fs from "node:fs";
import path from "node:path";
import { readBoundedTaskSourceYaml } from "../../../tasks/source/bounded-document.js";
import { parseTaskSource, peekTaskSourceVersion } from "../../../tasks/source/parse-task-source.js";
import { TASK_EXTENSION, TASK_NEAR_MISS_EXTENSION, taskExtensionDetail, taskSourceErrorDetail, } from "../../../tasks/source-v3.js";
import { toPosix } from "../../common.js";
import { hashContent } from "./shared.js";
/** A native task bundle is single-component; its one component is `main`. */
const COMPONENT_ID = "main";
/** The task YAML extension (spec §6 task row). */
const TASK_EXT = TASK_EXTENSION;
function recognize(c, file) {
if (file.ext !== TASK_EXT)
return null;
const conceptId = toPosix(file.relPath).replace(/\.yml$/i, "");
const name = conceptId.split("/").pop() ?? conceptId;
const raw = file.content();
return {
ref: `${c.id}//${conceptId}`,
bundle: c.id,
component: COMPONENT_ID,
conceptId,
path: file.absPath,
hash: hashContent(raw),
adapterId: "akm-task",
type: "task",
name,
content: raw,
};
}
async function validate(c, changes, ctx) {
const diagnostics = [];
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 ext = path.extname(change.path).toLowerCase();
// `.yaml` is NOT a task extension — the file never indexes and never runs.
// It is validated here purely so the near miss is REPORTED rather than
// skipped the way every other extension is (issue #760).
if (ext !== TASK_EXT && ext !== TASK_NEAR_MISS_EXTENSION)
continue;
const relPath = toPosix(change.path);
if (ext === TASK_NEAR_MISS_EXTENSION) {
diagnostics.push({
file: relPath,
issue: "invalid-task-yaml",
detail: taskExtensionDetail(relPath),
fixed: false,
});
continue;
}
try {
parseTaskSource({ yaml: raw, filePath: relPath, workspaceRoot: c.root });
}
catch (cause) {
diagnostics.push({
file: relPath,
issue: "invalid-task-yaml",
detail: taskSourceErrorDetail(cause),
fixed: false,
});
}
}
return diagnostics;
}
export const akmTaskAdapter = {
id: "akm-task",
version: "0.9.2",
// `.yaml` is listed as a COLLECTION hint only — `recognize` still gates on
// `.yml`, so a `.yaml` file is never indexed as a task. Listing it is what
// routes the near-miss file into `validate`, where it is reported instead of
// silently skipped (issue #760).
extensions: [TASK_EXT, TASK_NEAR_MISS_EXTENSION],
recognize,
validate,
readCandidates(c, conceptId) {
const posix = toPosix(conceptId).replace(/\.ya?ml$/i, "");
return [
{ path: path.join(c.root, `${posix}.yml`), conceptId: posix },
{ path: path.join(c.root, `${posix}.yaml`), conceptId: posix },
];
},
/** A task places to `<conceptId>.yml`; an already-suffixed conceptId is idempotent. */
placeNew(c, conceptId) {
const posix = toPosix(conceptId);
return path.join(c.root, /\.yml$/i.test(posix) ? posix : `${posix}.yml`);
},
/** Tasks live anywhere under the component root. */
directoryList() {
return ["."];
},
/**
* Install-time probe (§1.2): a root holding a top-level, valid task source
* v4 `.yml` file, or a task v2/v3 file that `akm migrate apply` has yet to
* convert (the runtime refuses those, so without this the migrator and
* `akm task sync` would not find them in a bundle whose adapter config does
* not record). The full parser keeps this disjoint from unrelated YAML and
* prevents probe semantics from drifting from validation semantics.
*/
looksLikeRoot(root) {
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
}
catch {
return false;
}
for (const entry of entries) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== TASK_EXT)
continue;
let raw;
try {
raw = fs.readFileSync(path.join(root, entry.name), "utf8");
}
catch {
continue;
}
try {
parseTaskSource({ yaml: raw, filePath: entry.name, workspaceRoot: root });
return true;
}
catch {
if (isLegacyTaskDocument(raw, entry.name))
return true;
// Continue probing the remaining top-level .yml files.
}
}
return false;
},
};
/** The executable keys each retired task schema version required one of. */
const LEGACY_TASK_TARGET_KEYS = {
2: ["workflow", "prompt", "command"],
3: ["uses", "run"],
};
/** A task v2/v3 document: its schema version, plus a target key that version used. */
function isLegacyTaskDocument(yaml, filePath) {
let root;
try {
root = readBoundedTaskSourceYaml({ yaml, filePath }, { sourceLabel: "task source" }).root;
}
catch {
return false;
}
const keys = LEGACY_TASK_TARGET_KEYS[peekTaskSourceVersion(root) ?? 0];
return keys !== undefined && keys.some((key) => Object.hasOwn(root, key));
}