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
343 lines (342 loc) • 15.7 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/.
/**
* Authoritative workflow-source ownership.
*
* Markdown and the bounded GitHub-shaped YAML subset are peer authoring
* formats, but one canonical workflow ref must have exactly one source file.
* This module is the shared filesystem arbitration point used before indexing,
* cache reuse, lookup/show, and runtime load/start. It never chooses one
* extension by priority: two recognized siblings are a hard collision.
*/
import fs from "node:fs";
import path from "node:path";
import { compareCodePoints, toPosix } from "../core/common.js";
import { UsageError } from "../core/errors.js";
import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
import { warnOnce } from "../core/warn.js";
export class WorkflowSourceRejectionError extends UsageError {
sourcePaths;
constructor(message, code, sourcePaths) {
super(message, code);
this.sourcePaths = [...sourcePaths].sort(compareCodePoints);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourceCollisionError extends WorkflowSourceRejectionError {
canonicalName;
constructor(canonicalName, sourcePaths) {
const sorted = [...sourcePaths].sort(compareCodePoints);
super(`Workflow "${canonicalName}" resolves to multiple workflow source files: ${sorted.join(", ")}. ` +
"A canonical workflow ref must be owned by exactly one recognized .md or .yml source; remove or rename the duplicate.", "RESOURCE_ALREADY_EXISTS", sorted);
this.name = "WorkflowSourceCollisionError";
this.canonicalName = canonicalName;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourceIdentityError extends UsageError {
constructor(ref, indexedPath, authoritativePath) {
super(`Indexed workflow source identity for "${ref}" points to ${indexedPath}, but the authoritative source is ${authoritativePath}. ` +
"Refusing the stale index/cache identity; run `akm index --full` to reconcile it.", "WORKFLOW_SOURCE_INVALID");
this.name = "WorkflowSourceIdentityError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourceNameError extends WorkflowSourceRejectionError {
constructor(sourcePath, nestedSuffix) {
super(`Workflow source filename ${sourcePath} has an extensionless stem ending in recognized workflow suffix "${nestedSuffix}". ` +
"Nested workflow suffixes are invalid; remove the inner .md or .yml suffix instead of relying on repeated stripping.", "WORKFLOW_SOURCE_INVALID", [sourcePath]);
this.name = "WorkflowSourceNameError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourceLinkIdentityError extends WorkflowSourceRejectionError {
constructor(sourcePath, targetPath) {
super(`Workflow source ${sourcePath} resolves through a symlink to ${targetPath} with a different source format. ` +
"The authored workflow path and resolved source must use the same .md or .yml format.", "WORKFLOW_SOURCE_INVALID", [sourcePath]);
this.name = "WorkflowSourceLinkIdentityError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourceLinkResolutionError extends WorkflowSourceRejectionError {
constructor(sourcePath) {
super(`Workflow source symlink ${sourcePath} cannot be resolved to a regular file.`, "WORKFLOW_SOURCE_INVALID", [
sourcePath,
]);
this.name = "WorkflowSourceLinkResolutionError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class WorkflowSourcePathIdentityError extends WorkflowSourceRejectionError {
constructor(sourcePath, targetPath) {
super(`Workflow source ${sourcePath} resolves outside the bundle root to ${targetPath}.`, "PATH_ESCAPE_VIOLATION", [
sourcePath,
]);
this.name = "WorkflowSourcePathIdentityError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
/** Convert an adapter-owned concept id into its extensionless workflow name. */
export function workflowNameForConceptId(adapterId, conceptId) {
if (adapterId === "akm-workflow")
return conceptId;
if (adapterId !== "akm")
return undefined;
const prefix = "workflows/";
if (!conceptId.startsWith(prefix) || conceptId.length === prefix.length)
return undefined;
return conceptId.slice(prefix.length);
}
/** Derive the once-canonicalizable workflow name from an adapter-owned authored path. */
export function workflowNameForSourcePath(sourceRoot, adapterId, sourcePath) {
if (adapterId !== "akm" && adapterId !== "akm-workflow")
return undefined;
const relativePath = toPosix(path.relative(path.resolve(sourceRoot), path.resolve(sourcePath)));
if (!isSafeRelativeName(relativePath))
return undefined;
const ownedPath = adapterId === "akm" ? relativePath.replace(/^workflows\//, "") : relativePath;
if (adapterId === "akm" && ownedPath === relativePath)
return undefined;
const extension = path.posix.extname(ownedPath);
if (!WORKFLOW_EXTENSIONS.includes(extension.toLowerCase()))
return undefined;
return ownedPath;
}
/**
* Enumerate every owned source path that maps to `name` under one component.
* Ownership is decided before parsing so a malformed peer cannot be hidden by
* a valid source. Results retain authored extension case for diagnostics.
*/
export function listWorkflowSourceFiles(sourceRoot, adapterId, name) {
if (adapterId !== "akm" && adapterId !== "akm-workflow")
return [];
const canonicalName = canonicalizeWorkflowName(normalizeName(name));
if (!isSafeRelativeName(canonicalName)) {
throw new UsageError("Workflow ref resolves outside the bundle root.", "PATH_ESCAPE_VIOLATION");
}
let realRoot;
const authoredRoot = path.resolve(sourceRoot);
try {
realRoot = fs.realpathSync(authoredRoot);
}
catch {
return [];
}
const ownershipRoot = adapterId === "akm" ? path.join(authoredRoot, "workflows") : authoredRoot;
const parent = path.join(ownershipRoot, path.dirname(canonicalName));
if (!isWithinResolved(parent, authoredRoot)) {
throw new UsageError("Workflow ref resolves outside the bundle root.", "PATH_ESCAPE_VIOLATION");
}
let entries;
try {
entries = fs.readdirSync(parent, { withFileTypes: true });
}
catch {
return [];
}
const basename = path.basename(canonicalName);
const candidates = [];
for (const entry of entries) {
if (!entry.isFile() && !entry.isSymbolicLink())
continue;
const extension = path.extname(entry.name);
const lowerExtension = extension.toLowerCase();
if (!WORKFLOW_EXTENSIONS.includes(lowerExtension))
continue;
if (entry.name.slice(0, -extension.length) !== basename)
continue;
const candidatePath = path.join(parent, entry.name);
candidates.push({
path: candidatePath,
relativePath: toPosix(path.relative(authoredRoot, candidatePath)),
lowerExtension,
extensionlessStem: entry.name.slice(0, -extension.length),
});
}
candidates.sort((left, right) => compareCodePoints(left.relativePath, right.relativePath));
return inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
}
/**
* Resolve a pre-enumerated set of authored workflow candidates in one batch.
*
* Callers that already walked a component (for example full lint/index scans)
* must use this surface instead of point-resolving every canonical ref and
* re-reading the same parent directory once per workflow. Candidate
* inspection, symlink containment/format rules, nested-suffix rejection, and
* collision construction remain shared with {@link listWorkflowSourceFiles}.
*/
export function resolveWorkflowSourceDomains(sourceRoot, adapterId, sourcePaths) {
if (adapterId !== "akm" && adapterId !== "akm-workflow")
return [];
const authoredRoot = path.resolve(sourceRoot);
let realRoot;
try {
realRoot = fs.realpathSync(authoredRoot);
}
catch {
return [];
}
const candidatesByName = new Map();
const seenAuthoredPaths = new Set();
for (const sourcePath of sourcePaths) {
// A full walk can legitimately feed the same authored file through more
// than one lexical spelling (or a caller may repeat it verbatim). Collapse
// only the normalized AUTHORED path here: distinct symlink/case-variant
// paths must remain separate ownership candidates even when they resolve to
// the same physical inode.
const normalizedSourcePath = path.resolve(sourcePath);
if (seenAuthoredPaths.has(normalizedSourcePath))
continue;
seenAuthoredPaths.add(normalizedSourcePath);
const authoredName = workflowNameForSourcePath(authoredRoot, adapterId, normalizedSourcePath);
if (authoredName === undefined)
continue;
const canonicalName = canonicalizeWorkflowName(authoredName);
if (!isSafeRelativeName(canonicalName))
continue;
const extension = path.extname(normalizedSourcePath);
const lowerExtension = extension.toLowerCase();
if (!WORKFLOW_EXTENSIONS.includes(lowerExtension))
continue;
const candidate = {
path: normalizedSourcePath,
relativePath: toPosix(path.relative(authoredRoot, normalizedSourcePath)),
lowerExtension,
extensionlessStem: path.basename(normalizedSourcePath).slice(0, -extension.length),
};
const domain = candidatesByName.get(canonicalName) ?? [];
domain.push(candidate);
candidatesByName.set(canonicalName, domain);
}
const resolutions = [];
for (const canonicalName of [...candidatesByName.keys()].sort(compareCodePoints)) {
const candidates = candidatesByName.get(canonicalName) ?? [];
candidates.sort((left, right) => compareCodePoints(left.relativePath, right.relativePath));
const sources = inspectWorkflowSourceDomain(candidates, canonicalName, realRoot);
const sourcePaths = candidates.map((candidate) => candidate.relativePath);
resolutions.push({
canonicalName,
sourcePaths,
source: pickWorkflowSource(adapterId, canonicalName, sources),
});
}
return resolutions;
}
function inspectWorkflowSourceDomain(candidates, canonicalName, realRoot) {
const sources = [];
for (const candidate of candidates) {
const inspection = inspectWorkflowSourceCandidate(candidate, canonicalName, realRoot);
if (inspection.source) {
sources.push(inspection.source);
continue;
}
for (const issue of inspection.issues) {
warnOnce(`workflow-source-invalid:${issue.sourcePaths.join(",")}`, issue.message);
}
}
return sources;
}
function pickWorkflowSource(adapterId, canonicalName, sources) {
if (sources.length <= 1)
return sources[0];
const winner = [...sources].sort((left, right) => left.format === right.format ? 0 : left.format === "markdown" ? -1 : 1)[0];
const shadowed = sources.filter((source) => source !== winner);
const displayName = adapterId === "akm" ? `workflows/${canonicalName}` : canonicalName;
warnOnce(`workflow-source-collision:${displayName}`, `Workflow "${displayName}" has both a .md and .yml source (${shadowed
.map((source) => source.relativePath)
.join(", ")} shadowed by ${winner?.relativePath}); using the .md source. Remove the shadowed sibling to ` +
"silence this warning.");
return winner;
}
function inspectWorkflowSourceCandidate(candidate, canonicalName, realRoot) {
const issues = [];
let authoredStat;
try {
authoredStat = fs.lstatSync(candidate.path);
}
catch {
issues.push(new WorkflowSourceLinkResolutionError(candidate.relativePath));
return { issues };
}
const isLink = authoredStat.isSymbolicLink();
if (!isLink && !authoredStat.isFile()) {
issues.push(new WorkflowSourceLinkResolutionError(candidate.relativePath));
return { issues };
}
let realPath;
try {
realPath = fs.realpathSync(candidate.path);
}
catch {
issues.push(new WorkflowSourceLinkResolutionError(candidate.relativePath));
return { issues };
}
const targetPath = toPosix(path.relative(realRoot, realPath));
const contained = isWithinResolved(realPath, realRoot);
if (!contained)
issues.push(new WorkflowSourcePathIdentityError(candidate.relativePath, targetPath));
if (isLink && path.extname(realPath).toLowerCase() !== candidate.lowerExtension) {
issues.push(new WorkflowSourceLinkIdentityError(candidate.relativePath, targetPath));
}
if (contained) {
try {
if (!fs.statSync(realPath).isFile())
issues.push(new WorkflowSourceLinkResolutionError(candidate.relativePath));
}
catch {
issues.push(new WorkflowSourceLinkResolutionError(candidate.relativePath));
}
}
if (issues.length > 0)
return { issues };
return {
issues,
source: {
path: candidate.path,
realPath,
relativePath: candidate.relativePath,
canonicalName,
format: candidate.lowerExtension === ".md" ? "markdown" : "github-yaml",
},
};
}
/** Return the sole owner, throw on a collision, or return undefined when absent. */
export function resolveUniqueWorkflowSource(sourceRoot, adapterId, name) {
const sources = listWorkflowSourceFiles(sourceRoot, adapterId, name);
const canonicalName = sources[0]?.canonicalName ?? canonicalizeWorkflowName(normalizeName(name));
return pickWorkflowSource(adapterId, canonicalName, sources);
}
/** Compare an indexed path with the single authoritative on-disk source. */
export function assertIndexedWorkflowSourceIdentity(ref, indexedPath, authoritative) {
let indexedRealPath;
try {
indexedRealPath = fs.realpathSync(indexedPath);
}
catch {
throw new WorkflowSourceIdentityError(ref, indexedPath, authoritative.path);
}
if (path.resolve(indexedPath) !== path.resolve(authoritative.path) ||
path.resolve(indexedRealPath) !== path.resolve(authoritative.realPath)) {
throw new WorkflowSourceIdentityError(ref, indexedPath, authoritative.path);
}
const indexedExtension = path.extname(indexedPath).toLowerCase();
const authoritativeExtension = authoritative.format === "markdown" ? ".md" : ".yml";
if (indexedExtension !== authoritativeExtension) {
throw new WorkflowSourceIdentityError(ref, indexedPath, authoritative.path);
}
}
function normalizeName(name) {
return name.replaceAll("\\", "/");
}
function isSafeRelativeName(name) {
return (name.length > 0 &&
!path.posix.isAbsolute(name) &&
name !== ".." &&
!name.startsWith("../") &&
path.posix.normalize(name) === name);
}
function isWithinResolved(candidate, root) {
const relative = path.relative(root, path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}