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
215 lines (214 loc) • 9.47 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/.
import fs from "node:fs";
import path from "node:path";
import { adapterForId } from "../../core/adapter/registry.js";
import { compareCodePoints, hasErrnoCode, isWithin } from "../../core/common.js";
import { ConfigError, UsageError } from "../../core/errors.js";
import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
import { warnOnce } from "../../core/warn.js";
import { resolveUniqueWorkflowSource, workflowNameForConceptId, } from "../../workflows/source-files.js";
import { buildFileContext } from "../walk/file-context.js";
const CONTENT_READ_REQUIRED = Symbol("adapter ownership probe requires content");
export class AdapterConceptOwnershipError extends UsageError {
}
export class AdapterConceptCollisionError extends AdapterConceptOwnershipError {
constructor(adapterId, conceptId, paths) {
const sorted = [...paths].sort(compareCodePoints);
super(`Adapter "${adapterId}" has multiple physical owners for "${conceptId}": ${sorted.join(", ")}.`, "RESOURCE_ALREADY_EXISTS");
this.name = "AdapterConceptCollisionError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
function normalizedConceptId(conceptId) {
const normalized = conceptId.replaceAll("\\", "/");
if (!normalized ||
path.posix.isAbsolute(normalized) ||
normalized === ".." ||
normalized.startsWith("../") ||
path.posix.normalize(normalized) !== normalized) {
return undefined;
}
return normalized;
}
function componentFor(sourcePath, adapterId) {
return { id: adapterId, adapter: adapterId, root: sourcePath, writable: false };
}
function lexicallyWithin(candidate, root) {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
/** Add case-only suffix spellings beside an adapter-authored read candidate. */
function candidateSpellings(candidate, sourcePath, realRoot) {
const authored = path.resolve(candidate.path);
if (!lexicallyWithin(authored, sourcePath))
return [{ ...candidate, path: authored }];
const parent = path.dirname(authored);
let realParent;
let entries;
try {
realParent = fs.realpathSync(parent);
entries = fs.readdirSync(parent, { withFileTypes: true });
}
catch {
return [{ ...candidate, path: authored }];
}
if (!isWithin(realParent, realRoot))
return [{ ...candidate, path: authored }];
const extension = path.extname(authored);
if (!extension)
return [{ ...candidate, path: authored }];
const stem = path.basename(authored, extension);
return entries
.filter((entry) => {
const entryExtension = path.extname(entry.name);
return (path.basename(entry.name, entryExtension) === stem && entryExtension.toLowerCase() === extension.toLowerCase());
})
.map((entry) => ({ ...candidate, path: path.join(parent, entry.name) }));
}
function inspectCandidate(sourcePath, realRoot, adapterId, candidate) {
const authoredPath = path.resolve(candidate.path);
if (!lexicallyWithin(authoredPath, sourcePath))
return undefined;
let authoredStat;
try {
authoredStat = fs.lstatSync(authoredPath);
}
catch {
return undefined;
}
if (!authoredStat.isFile() && !authoredStat.isSymbolicLink())
return undefined;
let realPath;
try {
realPath = fs.realpathSync(authoredPath);
}
catch {
return undefined;
}
if (!isWithin(realPath, realRoot))
return undefined;
try {
if (!fs.statSync(realPath).isFile())
return undefined;
}
catch {
return undefined;
}
return { path: authoredPath, realPath, conceptId: candidate.conceptId, adapterId };
}
function claimsWithoutContent(adapter, component, owner) {
const file = buildFileContext(component.root, owner.path);
let requestedBytes = false;
const denyBytes = () => {
requestedBytes = true;
throw CONTENT_READ_REQUIRED;
};
const noContentFile = { ...file, content: denyBytes, frontmatter: denyBytes };
try {
const document = adapter.recognize(component, noContentFile);
if (requestedBytes)
return true;
return document?.conceptId === owner.conceptId;
}
catch (error) {
if (error === CONTENT_READ_REQUIRED)
return true;
throw error;
}
}
/**
* Resolve one adapter/component's exact physical concept owner without reading
* authored bytes. Native workflow arbitration is reused verbatim; every other
* adapter supplies its own read placements and is probed with a byte-denying
* FileContext so path-level abstention remains authoritative.
*/
export function resolveAdapterConceptOwner(sourcePath, adapterId, conceptId, options) {
const adapter = adapterForId(adapterId);
const normalized = normalizedConceptId(conceptId);
if (!adapter || !normalized)
return undefined;
let realRoot;
try {
realRoot = fs.realpathSync(sourcePath);
}
catch (error) {
// Genuinely absent — "no owner" is correct. Any other failure (e.g.
// EACCES) is not "doesn't exist"; treating it as such would report a
// legitimate bundle root as owning nothing instead of raising the
// unreadable-directory error.
if (hasErrnoCode(error, "ENOENT"))
return undefined;
throw new ConfigError(`Unable to read bundle directory at "${sourcePath}".`, "STASH_DIR_UNREADABLE");
}
const component = componentFor(sourcePath, adapterId);
const ownersByIdentity = new Map();
const workflowName = workflowNameForConceptId(adapterId, normalized);
const resolutionConceptId = workflowName === undefined
? normalized
: adapterId === "akm"
? `workflows/${canonicalizeWorkflowName(workflowName)}`
: canonicalizeWorkflowName(workflowName);
if (workflowName !== undefined) {
const workflowSource = resolveUniqueWorkflowSource(sourcePath, adapterId, workflowName);
if (workflowSource) {
const workflowOwner = {
path: workflowSource.path,
realPath: workflowSource.realPath,
conceptId: resolutionConceptId,
adapterId,
workflowSource,
};
ownersByIdentity.set(`${path.resolve(workflowOwner.path)}\0${resolutionConceptId}`, workflowOwner);
}
}
// Closed-form candidate set (#857): `readCandidates` inverts the conceptId
// directly into every physical spelling that could own it (no bundle walk
// — see each adapter's own doc comment for its candidate classes), and
// `candidateSpellings` adds case-only siblings via one `readdirSync` of
// each candidate's own parent directory.
const spellings = (adapter.readCandidates?.(component, resolutionConceptId) ?? []).flatMap((candidate) => candidateSpellings(candidate, sourcePath, realRoot));
const inspected = [
...new Map(spellings.map((candidate) => [`${path.resolve(candidate.path)}\0${candidate.conceptId}`, candidate])).values(),
]
.sort((left, right) => compareCodePoints(left.path, right.path))
.flatMap((candidate) => {
const owner = inspectCandidate(sourcePath, realRoot, adapterId, candidate);
return owner ? [owner] : [];
});
for (const candidate of inspected) {
if (candidate.conceptId !== resolutionConceptId)
continue;
const identity = `${path.resolve(candidate.path)}\0${candidate.conceptId}`;
if (ownersByIdentity.has(identity))
continue;
if (claimsWithoutContent(adapter, component, candidate))
ownersByIdentity.set(identity, candidate);
}
const owners = [...ownersByIdentity.values()]
.filter((owner) => owner.conceptId === resolutionConceptId)
.sort((left, right) => compareCodePoints(left.path, right.path));
if (owners.length > 1) {
if (options?.mode !== "read") {
throw new AdapterConceptCollisionError(adapterId, resolutionConceptId, owners.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")));
}
const [winner, ...losers] = owners;
warnOnce(`adapter-concept-collision:${adapterId}:${resolutionConceptId}`, `Adapter "${adapterId}" has multiple physical owners for "${resolutionConceptId}": ` +
`${owners.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")).join(", ")}. ` +
`Reading "${path.relative(sourcePath, winner.path).replaceAll("\\", "/")}" and ignoring ` +
`${losers.map((owner) => path.relative(sourcePath, owner.path).replaceAll("\\", "/")).join(", ")}.`);
return winner;
}
return owners[0];
}
export function indexedPathMatchesOwner(indexedPath, owner) {
if (path.resolve(indexedPath) !== path.resolve(owner.path))
return false;
try {
return path.resolve(fs.realpathSync(indexedPath)) === path.resolve(owner.realPath);
}
catch {
return false;
}
}