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
458 lines (457 loc) • 25 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/.
/**
* write-source — resolve a write target and publish writes to it.
*
* The only module that branches on `source.kind` for writes. It does two
* things:
*
* 1. Resolve the destination: `--target` → `defaultWriteTarget` → working
* stash (`defaultBundle`). `writable` defaults to true on `filesystem` and
* false on `git`; `website` / `npm` are never writable (the config loader
* rejects `writable: true` on them).
* 2. Publish: write the file atomically inside the bundle root, and for a
* git-backed target commit exactly the operation's paths once at the
* boundary ({@link commitWriteTargetBoundary} → `saveGitStash`), pushing
* with `--force-with-lease` when the target is writable and has an
* upstream.
*
* Nothing commits per asset (issue #507). Callers write and delete through
* {@link writeAssetToSource} / {@link deleteAssetFromSource}, then fire
* {@link commitWriteTargetBoundary} once — or wrap a custom mutation in
* {@link withWriteTargetMutation}, which does both under the asset lease. An
* accepted proposal commits its own paths ({@link commitAcceptedPaths}), on a
* filesystem stash that is a git repository too.
*/
import fs from "node:fs";
import path from "node:path";
import { withAssetMutationLeaseSync } from "../indexer/index-writer-lock.js";
import { lockContentRootFor } from "../integrations/lockfile.js";
import { GitStashPushError, getCachePaths, inspectGitUpstream, isGitBackedStash, parseGitRepoUrl, runGit, saveGitStash, } from "../sources/providers/git.js";
import { listIgnoredExactPaths } from "../sources/providers/git-stash.js";
import { detectAdapterId } from "./adapter/detect-adapter.js";
import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
import { deriveBundleId } from "./bundle-id.js";
import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources } from "./config/config.js";
import { ConfigError, UsageError } from "./errors.js";
import { sanitizeCommitMessage } from "./git-message.js";
import { warn, warnOnce } from "./warn.js";
import { recordWrittenPath } from "./write-provenance.js";
// ── Write-target resolution ─────────────────────────────────────────────────
/** `writable` defaults to true on `filesystem` and false on every other kind. */
export function resolveWritable(entry) {
if (typeof entry.writable === "boolean")
return entry.writable;
return entry.type === "filesystem";
}
/** The two kinds writes are defined for; scheduler state binds only to these. */
export function isWriteCapableSourceKind(kind) {
return kind === "filesystem" || kind === "git";
}
/**
* Resolve the destination for a write: explicit `--target`, then
* `defaultWriteTarget`, then the working stash (`defaultBundle`). There is no
* fallback to the first writable source.
*/
export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
const allConfiguredSources = resolveConfiguredSources(akmConfig);
const configuredSources = resolveActiveConfiguredSources(akmConfig);
const requireWritable = options.requireWritable !== false;
const flag = options.flag ?? "--target";
if (explicitTarget) {
const match = configuredSources.find((s) => s.name === explicitTarget);
if (!match) {
if (allConfiguredSources.some((source) => source.name === explicitTarget)) {
throw new UsageError(`Bundle "${explicitTarget}" is disabled.`, "INVALID_FLAG_VALUE");
}
throw new UsageError(`${flag} must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
}
if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass ${flag} to a different source.`);
}
return adaptConfiguredSource(match);
}
if (akmConfig.defaultWriteTarget) {
const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
if (!match) {
throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" does not match any configured source.`, "INVALID_CONFIG_FILE", "Update `defaultWriteTarget` in your config (run `akm config get defaultWriteTarget`) or run `akm bundle list` to see configured sources.");
}
if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${akmConfig.defaultWriteTarget}" source in your config, or change \`defaultWriteTarget\` to a writable source.`);
}
return adaptConfiguredSource(match);
}
return resolveWorkingStashTarget(akmConfig, options);
}
/** Resolve the implicit working stash without consulting `defaultWriteTarget`. */
export function resolveWorkingStashTarget(akmConfig, options = {}) {
const allConfiguredSources = resolveConfiguredSources(akmConfig);
const configuredSources = resolveActiveConfiguredSources(akmConfig);
const requireWritable = options.requireWritable !== false;
if (process.env.AKM_BUNDLE_DIR?.trim()) {
const stashDir = resolveStashDir();
const configuredBundleId = bundleKeyForContentRoot(akmConfig, stashDir);
const configured = configuredSources.find((source) => source.name === configuredBundleId);
if (configured) {
const target = adaptConfiguredSource(configured);
if (requireWritable && !resolveWritable(target.config)) {
throw new ConfigError(`Bundle "${configured.name}" is not writable.`, "INVALID_CONFIG_FILE");
}
return { ...target, selector: undefined };
}
if (allConfiguredSources.some((source) => source.name === configuredBundleId)) {
throw new ConfigError("The AKM_BUNDLE_DIR source is disabled in config.", "INVALID_CONFIG_FILE");
}
const bundleId = deriveBundleId(undefined, stashDir, new Set(Object.keys(akmConfig.bundles ?? {})));
return {
source: { kind: "filesystem", name: bundleId, path: stashDir, adapterId: detectAdapterId(stashDir) },
config: { type: "filesystem", name: bundleId, path: stashDir, writable: true },
};
}
const defaultBundleSource = akmConfig.defaultBundle
? configuredSources.find((source) => source.name === akmConfig.defaultBundle)
: undefined;
if (!defaultBundleSource || !akmConfig.defaultBundle) {
throw new ConfigError("No default bundle is configured.", "INVALID_CONFIG_FILE");
}
const target = adaptConfiguredSource(defaultBundleSource);
if (requireWritable && !resolveWritable(target.config)) {
throw new ConfigError(`defaultBundle "${akmConfig.defaultBundle}" is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${akmConfig.defaultBundle}" bundle, or set \`defaultWriteTarget\` to a writable source.`);
}
return { ...target, selector: undefined };
}
/**
* Map a runtime {@link ConfiguredSource} onto a write target. A managed git
* bundle's content root comes from the lock (`localRoot`) first — the same
* resolver the indexer's read path uses, so a write lands exactly where a read
* walks. Before the first lock row it falls back to the cache checkout and its
* `content/` convention.
*/
function adaptConfiguredSource(runtime) {
if (!isWriteCapableSourceKind(runtime.type)) {
throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
}
const kind = runtime.type;
const lockRoot = kind === "git" ? lockContentRootFor(runtime.name, runtime.type) : undefined;
const repoPath = lockRoot ?? pathFromConfiguredSource(runtime);
if (!repoPath) {
throw new ConfigError(`Source "${runtime.name}" has no resolvable on-disk path; writes are unsupported for this entry.`, "INVALID_CONFIG_FILE");
}
const contentRoot = kind === "git" ? (lockRoot ?? resolveGitContentRoot(repoPath)) : repoPath;
const componentRoot = path.resolve(contentRoot, runtime.componentRoot ?? ".");
if (!isWithin(componentRoot, contentRoot)) {
throw new ConfigError(`Component root "${runtime.componentRoot}" escapes bundle "${runtime.name}".`, "INVALID_CONFIG_FILE");
}
const adapterId = runtime.adapterId ?? detectAdapterId(componentRoot);
const config = {
type: runtime.type,
name: runtime.name,
path: componentRoot,
...(runtime.writable !== undefined ? { writable: runtime.writable } : {}),
...(runtime.options ? { options: runtime.options } : {}),
};
return {
selector: runtime.name,
source: { kind, name: runtime.name, path: componentRoot, adapterId, ...(kind === "git" ? { repoPath } : {}) },
config,
};
}
/** Resolve the asset root inside a git checkout while preserving root-layout repos. */
export function resolveGitContentRoot(repoPath) {
const contentPath = path.join(repoPath, "content");
return fs.existsSync(contentPath) && fs.statSync(contentPath).isDirectory() ? contentPath : repoPath;
}
function pathFromConfiguredSource(runtime) {
const spec = runtime.source;
if (spec.type === "filesystem")
return spec.path;
if (spec.type === "git") {
try {
return getCachePaths(parseGitRepoUrl(spec.url).canonicalUrl).repoDir;
}
catch {
return undefined;
}
}
return undefined;
}
// ── Preparing a target for mutation ─────────────────────────────────────────
/** Refuse AKM asset writes into a bundle whose adapter owns a different layout. */
export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
if (!source.adapterId || allowedAdapters.includes(source.adapterId))
return;
throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
}
/**
* Validate a write target before a command mutates it. A git target must be a
* materialized checkout on a branch; its repository root is resolved from the
* content root (which may be a subdirectory) so the boundary commit uses
* repository-relative paths. Being ahead of or behind upstream only warns.
*/
export function prepareWriteTargetForMutation(target, options = {}) {
assertAkmAssetWrite(target.source, options.allowedAdapters);
if (target.source.kind !== "git")
return target;
const contentRoot = path.resolve(target.source.path);
const rootResult = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
if (rootResult.status !== 0 || !rootResult.stdout.trim()) {
throw new ConfigError(`Writable Git target "${target.source.name}" is not materialized as a Git checkout at ${contentRoot}; refusing to write without a commit boundary.`, "INVALID_CONFIG_FILE", `Run \`akm bundle update ${target.source.name}\` to materialize it, or point the bundle at a writable Git checkout.`);
}
const repoPath = path.resolve(rootResult.stdout.trim());
const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "HEAD"]);
if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
throw new UsageError(`Writable Git target "${target.source.name}" is detached from a branch.`, "INVALID_FLAG_VALUE");
}
try {
const upstream = inspectGitUpstream(repoPath);
if (upstream.behind > 0) {
warnOnce(`write-source:git-behind:${repoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
}
if (upstream.ahead > 0) {
warnOnce(`write-source:git-ahead:${repoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
}
}
catch (error) {
// The upstream check is advisory (it fetches). Being offline must not stop
// a local write; the push reports its own failure at the boundary.
warnOnce(`write-source:git-upstream:${repoPath}`, `Could not check upstream for Git target "${target.source.name}" (${error instanceof Error ? error.message : String(error)}); writing anyway.`);
}
return { ...target, source: { ...target.source, path: contentRoot, repoPath } };
}
// ── Writing assets ──────────────────────────────────────────────────────────
/**
* Write a textual asset into `source` at the path implied by `ref`: refuses a
* non-writable config, rejects any kind but `filesystem` / `git`, then writes
* atomically inside the bundle root. No commit runs here for any kind; a git
* target records the path for {@link commitWriteTargetBoundary}.
*/
export async function writeAssetToSource(source, config, ref, content) {
ensureWritable(source, config);
assertSupportedKind(source);
assertAkmAssetWrite(source);
const filePath = resolveAssetFilePath(source, ref);
const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// Atomic: sibling temp file, fdatasync, rename — a crash or a full disk never
// leaves a half-written asset where a good one was.
writeFileAtomic(filePath, normalized, existingFileMode(filePath));
recordWriteTargetPath(source, filePath);
// Run-scoped write provenance (#652).
recordWrittenPath(filePath);
// Non-fatal portability advisory, after the write so it never blocks it.
const hostPaths = findAbsoluteHomePaths(normalized);
if (hostPaths.length > 0) {
warn(`warning: asset "${formatRefForMessage(ref)}" embeds absolute host path(s): ${hostPaths.join(", ")}. ` +
"These make the stash non-portable and leak the local username — prefer $HOME or ~ relative references.");
}
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
}
/** Delete the asset at `ref` from `source`. Same gates as {@link writeAssetToSource}, no commit. */
export async function deleteAssetFromSource(source, config, ref) {
ensureWritable(source, config);
assertSupportedKind(source);
assertAkmAssetWrite(source);
const filePath = resolveAssetFilePath(source, ref);
if (!fs.existsSync(filePath)) {
throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
}
fs.unlinkSync(filePath);
recordWriteTargetPath(source, filePath);
recordWrittenPath(filePath);
return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
}
function ensureWritable(source, config) {
if (resolveWritable(config))
return;
throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
}
function assertSupportedKind(source) {
if (isWriteCapableSourceKind(source.kind))
return;
throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
"Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
}
/** MS-DOS device names Windows reserves in every directory, with or without an extension. */
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
"con",
"prn",
"aux",
"nul",
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
]);
/** The on-disk path for `ref` inside `source`, refusing anything that escapes the type directory. */
function resolveAssetFilePath(source, ref) {
const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
if (basename === "index" || basename === "log") {
warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
}
if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
}
const typeDir = stashDirFor(ref.type);
if (!typeDir) {
throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
}
const typeRoot = path.join(source.path, typeDir);
const assetPath = assetPathForName(ref.type, typeRoot, ref.name);
if (!isWithin(assetPath, typeRoot)) {
throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
}
return assetPath;
}
/**
* Matches an absolute host home path (`/home/<user>`, `/Users/<user>`) with at
* least one user segment. Deliberately does not exempt fenced code, so content
* that documents such a path can trip it; the advisory is non-fatal.
*/
const ABSOLUTE_HOME_PATH_RE = /\/(?:home|Users)\/[^\s/"'`)\]}<>|:;,]+/g;
/** Distinct `/home/<user>` / `/Users/<user>` prefixes in `content`, first-seen order. */
export function findAbsoluteHomePaths(content) {
const seen = new Set();
for (const match of content.matchAll(ABSOLUTE_HOME_PATH_RE))
seen.add(match[0]);
return [...seen];
}
/** `[origin//]conceptId` for a commit subject, each component sanitized against CR/LF/NUL smuggling. */
export function formatRefForMessage(ref) {
const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
const conceptId = conceptIdFromTypeName(sanitizeCommitMessage(ref.type), sanitizeCommitMessage(ref.name));
return origin ? `${origin}//${conceptId}` : conceptId;
}
// ── Boundary commit ─────────────────────────────────────────────────────────
/**
* Absolute paths written to a git target since its last boundary commit, keyed
* by repository root. This is what lets the boundary `git add` exactly the
* operation's files and nothing else in the checkout.
*/
const pendingGitPaths = new Map();
function repoDirFor(source) {
return path.resolve(source.repoPath ?? source.path);
}
/** Record a path the git target's next boundary commit must include. */
export function recordWriteTargetPath(source, filePath) {
if (source.kind !== "git")
return;
const repoDir = repoDirFor(source);
const pending = pendingGitPaths.get(repoDir) ?? new Set();
pending.add(path.resolve(filePath));
pendingGitPaths.set(repoDir, pending);
}
function lstatOrNull(filePath) {
try {
return fs.lstatSync(filePath);
}
catch (error) {
if (error.code === "ENOENT")
return null;
throw error;
}
}
/**
* Refuse a write path outside the source root, or one that passes through a
* symbolic link below it (a linked directory would redirect the write out of
* the bundle). The source root itself may be a symlink.
*/
function assertWritePathsInsideSource(source, filePaths) {
const lexicalRoot = path.resolve(source.path);
const canonicalRoot = fs.realpathSync(lexicalRoot);
for (const filePath of filePaths) {
const relative = path.relative(lexicalRoot, path.resolve(filePath));
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new UsageError(`Write path resolves outside source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
}
let current = canonicalRoot;
for (const segment of relative.split(path.sep)) {
current = path.join(current, segment);
const stat = lstatOrNull(current);
if (!stat)
break;
if (stat.isSymbolicLink()) {
throw new UsageError(`Write path contains a symbolic link below source "${source.name}": ${relative}`);
}
}
}
}
/**
* Run `mutate` under the shared asset-mutation lease, then commit exactly
* `paths` on a git target (a no-op on filesystem targets). Every path must
* stay inside the source root.
*/
export function withWriteTargetMutation(target, paths, options, mutate) {
return withAssetMutationLeaseSync(options.purpose, () => {
assertWritePathsInsideSource(target.source, paths);
const result = mutate();
commitWriteTargetBoundary(target, options.message, { paths });
return result;
});
}
/**
* Commit a git target's recorded paths plus `options.paths` (absolute or
* repository-relative) as one commit, and push it with `--force-with-lease`
* when the target is writable, has an upstream, and `push !== false`. A no-op
* for filesystem targets unless `filesystem` is set. Ignored paths stay local:
* they are dropped from the commit with a warning rather than failing a write
* that already landed.
*/
export function commitWriteTargetBoundary(target, message, options) {
if (target.source.kind !== "git" && !options?.filesystem)
return;
const repoDir = repoDirFor(target.source);
const recorded = pendingGitPaths.get(repoDir) ?? new Set();
pendingGitPaths.delete(repoDir);
if (!isGitBackedStash(repoDir))
return;
const toRepoRelative = (filePath) => (path.isAbsolute(filePath) ? path.relative(repoDir, filePath) : filePath).replaceAll(path.sep, "/");
const paths = [...new Set([...(options?.paths ?? []), ...recorded].map(toRepoRelative))];
if (paths.length === 0)
return;
const ignored = new Set(listIgnoredExactPaths(repoDir, paths));
if (ignored.size > 0) {
warn(`warning: ${ignored.size} path(s) in "${target.source.name}" are ignored by .gitignore and stay local (not committed): ${[...ignored].join(", ")}`);
}
const committable = paths.filter((filePath) => !ignored.has(filePath));
if (committable.length === 0)
return;
try {
const saved = saveGitStash(undefined, message, resolveWritable(target.config), {
repoDir,
paths: committable,
...(options?.push === undefined ? {} : { push: options.push }),
});
if (saved.reason)
warn(`warning: "${target.source.name}" ${saved.reason}`);
}
catch (error) {
if (error instanceof GitStashPushError) {
throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
cause: error,
});
}
throw error;
}
}
/**
* Commit exactly the paths an accepted proposal wrote or removed, when the
* target is a git repository: a filesystem stash with a `.git` too, which the
* boundary commit skips, locally (the end-of-run sync pushes). A failed commit
* only warns, since the accept already landed.
*/
export function commitAcceptedPaths(target, message, paths) {
try {
commitWriteTargetBoundary(target, message, {
paths,
filesystem: true,
...(target.source.kind === "git" ? {} : { push: false }),
});
}
catch (error) {
warn(`warning: could not commit the accept (${message}): ${error instanceof Error ? error.message : String(error)}`);
}
}