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

343 lines (342 loc) • 15.7 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/. /** * 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)); }