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
190 lines (189 loc) • 9.9 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 workflowTemplate from "../../assets/workflows/workflow-template.md" with { type: "text" };
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
import { makeBundleRef } from "../../core/asset/asset-ref.js";
import { isWithin, writeFileAtomic } from "../../core/common.js";
import { loadConfig } from "../../core/config/config.js";
import { UsageError } from "../../core/errors.js";
import { defaultBundleForTarget } from "../../core/mutation-target.js";
import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../../core/recognition-util.js";
import { warn } from "../../core/warn.js";
import { prepareWriteTargetForMutation, resolveWriteTarget, withWriteTargetMutation } from "../../core/write-source.js";
import { checkWorkflowPlan, compileWorkflowSource } from "../compile.js";
const DEFAULT_WORKFLOW_TEMPLATE = renderWorkflowTemplate("New Workflow");
export function getWorkflowTemplate() {
return DEFAULT_WORKFLOW_TEMPLATE;
}
export function buildWorkflowTemplate(name) {
if (!name)
return DEFAULT_WORKFLOW_TEMPLATE;
// Only the H1 is customized. Step ids are STRUCTURAL in the Markdown authoring form
// — each appears in `steps[].id`, as a body `## <id>` heading, and possibly in
// another step's `inputs:` — so rewriting them from a name would have to patch
// three places consistently to stay parseable. Generic `first-step`/
// `second-step` are the author's to rename.
const customized = renderWorkflowTemplate(humanizeWorkflowName(name));
validateWorkflowContent(customized, `<template:${name}>.md`);
return customized;
}
/** Parse AND compile `content`, so a create never writes an asset that `show`/`start`/`validate` would then reject. */
function validateWorkflowContent(content, sourcePath) {
const result = compileWorkflowSource(content, { path: sourcePath });
if (!result.ok) {
throw new UsageError(formatWorkflowErrors(sourcePath, result.errors));
}
const compiled = checkWorkflowPlan(result.plan);
if (!compiled.ok) {
throw new UsageError(formatWorkflowErrors(sourcePath, compiled.errors));
}
}
/** Peer YAML is executable, but `akm workflow create` emits the Markdown authoring form only. */
const YAML_SUFFIX_RE = /\.ya?ml$/i;
export function assertWorkflowMarkdownName(name) {
if (!YAML_SUFFIX_RE.test(name.trim()))
return;
throw new UsageError(`akm workflow create is markdown-only: it emits Markdown and cannot create "${name}". ` +
`Use a plain name (no ".yaml"/".yml" suffix), or author a peer GitHub-shaped ".yml" workflow directly.`);
}
// 0.9.2 review round 2 (P4 sweep criterion-21 caveat, spec
// docs/plans/specs/p4-deletions-closeout.md §4.1 row B-49): this return's
// third member was `stashDir` through 0.9.1 and leaked verbatim into the
// `akm workflow create` JSON envelope (Stable per STABILITY.md) via
// `output("workflow-create", { ok: true, ...result })`
// (src/commands/workflow-cli.ts). Renamed to `bundleDir` here — the same
// value-preserving field rename `RunTaskOptions.stashDir` got in P1b (see
// src/tasks/run/task-result.ts's header, F-3) — so the shipped envelope now
// matches current bundle vocabulary. BREAKING vs 0.9.1's envelope shape.
export function createWorkflowAsset(input) {
assertWorkflowMarkdownName(input.name);
const config = loadConfig();
const resolvedTarget = resolveWriteTarget(config);
const target = prepareWriteTargetForMutation(resolvedTarget, { allowedAdapters: ["akm", "akm-workflow"] });
const bundleDir = target.source.path;
const standaloneWorkflowBundle = target.source.adapterId === "akm-workflow";
const typeRoot = standaloneWorkflowBundle ? bundleDir : path.join(bundleDir, "workflows");
const normalizedName = normalizeWorkflowName(input.name);
const conceptId = standaloneWorkflowBundle ? normalizedName : `workflows/${normalizedName}`;
const assetPath = path.join(typeRoot, `${normalizedName}.md`);
const relativeAssetPath = path.relative(path.resolve(typeRoot), path.resolve(assetPath));
if (relativeAssetPath === ".." ||
relativeAssetPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeAssetPath)) {
throw new UsageError(`Resolved workflow path escapes the bundle: "${normalizedName}"`, "PATH_ESCAPE_VIOLATION");
}
// A peer `.md` or `.yml` file whose canonical name matches already owns this
// ref. Case variants (`upper.MD` vs `upper.md`) may also name one physical
// file on a case-insensitive filesystem, so writing the Markdown create
// target would silently clobber or shadow the existing asset.
const shadowing = findExistingWorkflowPaths(typeRoot, normalizedName).find((p) => p !== assetPath);
if (shadowing !== undefined) {
throw new UsageError(`Workflow "${normalizedName}" already exists as ${path.relative(bundleDir, shadowing)} — the ` +
`\`${conceptId}\` ref resolves to that file, so creating this one would shadow it. ` +
`Remove or rename the existing file first, or create the workflow under a different name.`, "RESOURCE_ALREADY_EXISTS");
}
if (fs.existsSync(assetPath) && !input.force) {
throw new UsageError(`Workflow "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
}
const content = input.from
? readWorkflowSource(input.from, bundleDir)
: (input.content ?? buildWorkflowTemplate(normalizedName));
const sourcePath = input.from ?? `workflows/${normalizedName}.md`;
validateWorkflowContent(content, sourcePath);
const authoredContent = ensureAkmMarkdownType(content, "workflow");
const mode = fs.existsSync(assetPath) ? fs.lstatSync(assetPath).mode & 0o777 : 0o644;
const defaultBundle = defaultBundleForTarget(config);
const ref = makeBundleRef(target.source.name === defaultBundle ? undefined : target.source.name, conceptId);
withWriteTargetMutation(target, [assetPath], { purpose: "workflow-authoring", message: `Create ${ref}` }, () => {
fs.mkdirSync(path.dirname(assetPath), { recursive: true });
writeFileAtomic(assetPath, authoredContent.endsWith("\n") ? authoredContent : `${authoredContent}\n`, mode);
});
return {
ref,
path: assetPath,
bundleDir,
};
}
function readWorkflowSource(source, bundleDir) {
const resolved = path.resolve(source);
let stat;
try {
stat = fs.statSync(resolved);
}
catch {
throw new UsageError(`Workflow source not found: "${source}".`);
}
if (!stat.isFile()) {
throw new UsageError(`Workflow source must be a file: "${source}".`);
}
// The user is allowed to import any readable file as a workflow body, but
// an import from outside the stash is unusual enough to warn about. Anyone
// running `akm workflow create --from /etc/passwd` deserves a heads-up.
if (!isWithin(resolved, bundleDir)) {
warn(`Importing workflow content from outside the stash: ${resolved}\n ` +
`If this was unintentional, abort and re-run with a --from path inside ${bundleDir}.`);
}
return fs.readFileSync(resolved, "utf8");
}
function normalizeWorkflowName(name) {
// Strip a recognized workflow extension (.md) so the canonical name — and
// thus the `workflows/<name>` ref — is extension-free regardless of how the
// user spelled it.
const normalized = canonicalizeWorkflowName(name
.trim()
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, ""));
if (!normalized) {
throw new UsageError("Workflow name cannot be empty.");
}
const segments = normalized.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new UsageError("Workflow name must be a relative path without '.' or '..' segments.");
}
return normalized;
}
function humanizeWorkflowName(name) {
return (name
.split("/")
.pop()
?.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase())
.trim() || "New Workflow");
}
export function formatWorkflowErrors(path, errors) {
const lines = errors.map((e) => ` ${path}:${e.line} — ${e.message}`);
const heading = errors.length === 1 ? "Workflow has 1 error:" : `Workflow has ${errors.length} errors:`;
return [heading, ...lines].join("\n");
}
/**
* Every file under `typeRoot` whose canonical workflow name equals
* `normalizedName`. Peer `.md`/`.yml` extension matching is case-INSENSITIVE,
* so both cross-format owners and case variants are returned for the create
* path to refuse rather than silently clobber or shadow them.
*/
function findExistingWorkflowPaths(typeRoot, normalizedName) {
const parent = path.join(typeRoot, path.dirname(normalizedName));
let entries;
try {
entries = fs.readdirSync(parent, { withFileTypes: true });
}
catch {
return [];
}
const basename = path.basename(normalizedName);
return WORKFLOW_EXTENSIONS.flatMap((extension) => entries
.filter((entry) => {
if (!entry.isFile() && !entry.isSymbolicLink())
return false;
const entryExtension = path.extname(entry.name);
return entryExtension.toLowerCase() === extension && entry.name.slice(0, -entryExtension.length) === basename;
})
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => path.join(parent, entry.name)));
}
function renderWorkflowTemplate(title) {
return workflowTemplate.replace("{{TITLE}}", title);
}