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
177 lines (176 loc) • 8.87 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/.
/**
* `akm task validate <path>` (#907) — parse ONE task file by filesystem
* path, not a concept ref/id, and report the same diagnostic `akm task
* sync` would produce for it. The file need not live in any configured
* bundle: unlike every other `akm task` subcommand, this one never resolves
* a bundle/adapter/concept id at all — it reads exactly the path it was
* given and classifies it.
*
* Reuses the exact version router `parseTaskSource`
* (`src/tasks/source/parse-task-source.ts`) every other task-source reader
* uses (`akm task sync`'s `compileTaskSources` included) — this module never
* forks a second parser. `readBoundedTaskSourceYaml` /
* `peekTaskSourceVersion` / `own` are the SAME front-end helpers the router
* calls first; they are used here only to recover the file's DECLARED schema
* version for the report and to tell `blocked` from `invalid`.
*
* Beyond parsing, this module also runs the SAME two per-source gates
* `akm task sync`'s `compileTaskSources` runs before it ever installs a
* schedule — `assertTaskScheduleInputsSatisfyContract` and
* `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
* exactly this reuse) — so a file `sync` would reject can never
* be reported `valid` here. Cron dialect is checked against
* `backendNameForPlatform()`, the same platform default `sync` falls back
* to whenever it has no injected/native-inspected backend to hand (see
* `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
* installed anywhere; there is no native scheduler state to inspect for it.
*
* Deliberately DOES NOT call `prepareTaskV3Execution` (the function
* `compileTaskSources` calls between those two gates) or resolve an
* execution engine: that path resolves a composed command/persona ref
* against the local index and lowers the task's cascade-composed
* engine/model — both of which assume a real, indexed bundle and a
* configured engine. A bare file passed to `validate` has neither, so
* running that step would make an otherwise-valid command-kind task report
* `invalid` on any machine with no engine configured. `resolved`
* is therefore the compiled task shape `sync` itself would build a
* scheduler binding from — id, the compiled v4 `version`, `target`
* (`uses`/`run`), the declared `inputs` contract, and `schedule` bindings —
* never an execution-lowered plan.
*
* Outcome classification (mirrors `parse-task-source.ts`'s own routing
* table in its header, extended for the two gates above):
* - `valid` — parses as task source v4 and passes both sync gates.
* - `blocked` — declared `version: 2` or `3`, or a `version: 4`
* document still carrying the retired
* `schedule[].enabled`: what `akm migrate apply`
* rewrites. The runtime reads only v4 (#987), so the
* reason names that command.
* - `invalid` — the document declares SOME version (`4`, or anything
* other than 2/3/4) but fails to parse/validate, OR it
* parsed but fails one of the two sync gates above, OR
* the YAML itself does not parse at all (a genuine
* syntax error, not merely a non-task shape) — reported
* with the parser's own reason.
* - `not-a-task` — the document parses as YAML but never declares a
* `version:` field at all (or isn't a YAML mapping) —
* the strongest signal available that the file was
* never intended as a task source in the first place.
*/
import fs from "node:fs";
import path from "node:path";
import { UsageError } from "../../core/errors.js";
import { backendNameForPlatform } from "../../tasks/backends/index.js";
import { assertTaskScheduleCronValid, assertTaskScheduleInputsSatisfyContract } from "../../tasks/scheduler-sync.js";
import { own, readBoundedTaskSourceYaml } from "../../tasks/source/bounded-document.js";
import { parseTaskSource, peekTaskSourceVersion, v4ScheduleHasRetiredEnabledKey, } from "../../tasks/source/parse-task-source.js";
/** True when `root` is a YAML mapping that itself declares a `version:` key, regardless of that key's type/value. */
function declaresVersionKey(root) {
return root !== null && typeof root === "object" && !Array.isArray(root) && own(root, "version");
}
function buildResolved(id, v4) {
return {
id,
version: v4.version,
...(v4.name !== undefined ? { name: v4.name } : {}),
...(v4.description !== undefined ? { description: v4.description } : {}),
target: v4.target,
inputs: v4.inputs ?? {},
schedule: v4.schedule,
};
}
export async function akmTaskValidate(filePath) {
const resolvedPath = path.resolve(filePath);
let stat;
try {
stat = fs.statSync(resolvedPath);
}
catch {
throw new UsageError(`Task file not found: ${JSON.stringify(filePath)}.`, "INVALID_FLAG_VALUE");
}
if (!stat.isFile()) {
throw new UsageError(`${JSON.stringify(filePath)} is not a regular file.`, "INVALID_FLAG_VALUE");
}
let yaml;
try {
yaml = fs.readFileSync(resolvedPath, "utf8");
}
catch (cause) {
throw new UsageError(`Task file ${JSON.stringify(filePath)} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_FLAG_VALUE");
}
// Peek the declared version BEFORE the real parse, using the identical
// bounded YAML front end `parseTaskSource` calls internally — never a
// second/looser YAML reader. A front-end failure here (unparseable YAML,
// not a mapping, exceeds a resource bound) means the document itself does
// not parse at all — tracked as `peekFailed` so that case reports
// `invalid`, never `not-a-task` (`not-a-task` is reserved for
// YAML that DOES parse but never declared a task shape). The real parse
// below throws the identical error either way, so nothing is lost by
// swallowing it here.
let root;
let peekFailed = false;
try {
root = readBoundedTaskSourceYaml({ yaml, filePath: resolvedPath }, { sourceLabel: "task source" }).root;
}
catch {
peekFailed = true;
}
const declaredVersion = peekFailed ? undefined : peekTaskSourceVersion(root);
const hasVersionKey = !peekFailed && declaresVersionKey(root);
const workspaceRoot = path.dirname(resolvedPath);
const backend = backendNameForPlatform();
let parsed;
try {
parsed = parseTaskSource({ yaml, filePath: resolvedPath, workspaceRoot });
}
catch (cause) {
if (!(cause instanceof UsageError))
throw cause;
const reason = cause.message;
// What `akm migrate apply` rewrites is the only refusal for these
// shapes (`parse-task-source.ts`), so no message-text sniffing is needed.
if (declaredVersion === 2 ||
declaredVersion === 3 ||
(declaredVersion === 4 && v4ScheduleHasRetiredEnabledKey(root))) {
return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
}
if (peekFailed) {
return { ok: false, path: resolvedPath, outcome: "invalid", reason };
}
if (!hasVersionKey) {
return { ok: false, path: resolvedPath, outcome: "not-a-task", reason };
}
return {
ok: false,
path: resolvedPath,
...(declaredVersion !== undefined ? { sourceVersion: declaredVersion } : {}),
outcome: "invalid",
reason,
};
}
// Success is unreachable from any path that leaves `declaredVersion`
// undefined — the router requires a numeric version 4 to reach here.
const sourceVersion = declaredVersion ?? 4;
// The document itself parsed — now the two gates `compileTaskSources`
// runs before accepting it. A violation here is `invalid`.
try {
assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
assertTaskScheduleCronValid(parsed.v4, backend);
}
catch (cause) {
if (!(cause instanceof UsageError))
throw cause;
return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
}
const id = path.parse(resolvedPath).name;
return {
ok: true,
path: resolvedPath,
sourceVersion,
outcome: "valid",
resolved: buildResolved(id, parsed.v4),
};
}