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
158 lines (157 loc) • 8.1 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 path from "node:path";
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
import { validateExplicitBundleName } from "../../core/bundle-id.js";
import { isRemoteUrl } from "../../core/common.js";
import { bundleEntryToSourceEntry, bundlesToSourceEntries, getSources, mutateConfig } from "../../core/config/config.js";
import { ConfigError, UsageError } from "../../core/errors.js";
import { revokeSchedulerActivationsForBundle } from "../../tasks/activation-config.js";
import { bundleKeyForPath, bundleKeyForUrl, nextBundleKey, placeBundle, } from "./bundle-config-ops.js";
// ── Operations ──────────────────────────────────────────────────────────────
/**
* Add a stash source (filesystem path or remote provider URL) to config.
*
* Filesystem paths are auto-detected when `target` does not start with
* `http://` or `https://`. URL sources require a `providerType` option
* (e.g. "website", "git").
*/
export function addStash(opts) {
const { target, name, providerType, options: providerOptions, writable, credential, before, after } = opts;
if (providerType === "openviking") {
throw new ConfigError("openviking is not supported in akm v1.", "INVALID_CONFIG_FILE");
}
if (writable === true && providerType && providerType !== "filesystem" && providerType !== "git") {
throw new ConfigError("writable: true is only supported on filesystem and git sources", "INVALID_CONFIG_FILE");
}
if (credential && providerType !== "git") {
throw new ConfigError("credential is only supported on git sources", "INVALID_CONFIG_FILE");
}
let result;
const targetIsUrl = isRemoteUrl(target);
if (targetIsUrl && !providerType) {
throw new UsageError("--provider is required for URL sources (e.g. --provider git --provider website)");
}
// R-013: `--provider npm` names an npm package SPEC (e.g. "lodash",
// "@scope/pkg@^2"), never a URL — a tarball URL is not installable as a
// package spec and would only fail much later, at first sync, with a
// confusing "Invalid npm package name" error. Reject it loudly here instead.
if (providerType === "npm" && targetIsUrl) {
throw new UsageError(`--provider npm expects a package spec (e.g. "lodash" or "@scope/pkg@^2"), not a URL: "${target}". ` +
"Drop --provider npm and re-run `akm bundle add <package>` to add an npm source.");
}
// A bare (non-URL) target with --provider npm is a declarative npm source
// add — the same "locator, not a URL" descriptor path used for git/website
// URL sources below. Without this, a non-URL target fell through to the
// filesystem branch below and `--provider npm` was silently ignored,
// creating a bogus filesystem bundle for the current working directory
// (R-013).
const useDescriptorPath = targetIsUrl || providerType === "npm";
mutateConfig((config) => {
const bundles = { ...(config.bundles ?? {}) };
let key;
if (useDescriptorPath) {
const existingKey = bundleKeyForUrl(config, target);
if (name !== undefined)
validateExplicitBundleName(bundles, name, existingKey);
if (existingKey) {
const already = targetIsUrl ? "Source URL already configured" : "Source already configured";
result = { sources: getSources(config), added: false, message: already };
return config;
}
key = nextBundleKey(bundles, name, target);
const entry = urlBundleDescriptor(providerType, target, providerOptions, writable === true, credential);
const nextBundles = placeBundle(bundles, key, entry, { before, after });
const next = { ...config, bundles: nextBundles };
result = {
sources: bundlesToSourceEntries(next) ?? [],
added: true,
entry: bundleEntryToSourceEntry(key, entry),
};
return next;
}
else {
const resolvedPath = path.resolve(target);
const existingKey = bundleKeyForPath(config, resolvedPath);
if (name !== undefined)
validateExplicitBundleName(bundles, name, existingKey);
if (existingKey) {
result = { sources: getSources(config), added: false, message: "Source path already configured" };
return config;
}
key = nextBundleKey(bundles, name, resolvedPath);
const entry = {
path: resolvedPath,
...(writable === true ? { writable: true } : {}),
components: {
main: { root: ".", adapter: detectAdapterId(resolvedPath), writable: writable ?? true },
},
};
const nextBundles = placeBundle(bundles, key, entry, { before, after });
const next = { ...config, bundles: nextBundles };
result = {
sources: bundlesToSourceEntries(next) ?? [],
added: true,
entry: bundleEntryToSourceEntry(key, entry),
};
return next;
}
});
return result;
}
/**
* Build the 0.9.0 bundle descriptor for a declarative (non-filesystem) source
* (spec §10.1). `locator` is a URL for git/website; for npm it is a bare
* package spec (e.g. "lodash", "@scope/pkg@^2") — never a URL (R-013,
* rejected earlier in {@link addStash}).
*/
function urlBundleDescriptor(providerType, locator, options, writable, credential) {
if (providerType === "website") {
// Website provider options ride on the (passthrough) website descriptor and
// round-trip back to `entry.options` via bundleEntryToSourceEntry.
return {
website: { url: locator, ...(options ?? {}) },
components: { main: { root: ".", adapter: "website-snapshot", writable: false } },
};
}
if (providerType === "npm")
return { npm: locator };
if (providerType === "git") {
return { git: locator, ...(writable ? { writable: true } : {}), ...(credential ? { credential } : {}) };
}
throw new ConfigError(`unsupported source type "${providerType}"; expected filesystem, git, website, or npm`, "INVALID_CONFIG_FILE");
}
/**
* Remove a stash source by URL, path, or name.
* Match priority: URL > path > name (most specific first).
*/
export function removeStash(target) {
const isUrlTarget = isRemoteUrl(target);
const resolvedPath = !isUrlTarget ? path.resolve(target) : undefined;
let result;
mutateConfig((config) => {
const bundles = { ...(config.bundles ?? {}) };
// Match priority: URL > path > bundle key (name).
let key = isUrlTarget ? bundleKeyForUrl(config, target) : undefined;
if (!key && resolvedPath)
key = bundleKeyForPath(config, resolvedPath);
if (!key && target in bundles)
key = target;
if (!key) {
result = { sources: getSources(config), removed: false, message: "No matching source found" };
return config;
}
const removed = bundleEntryToSourceEntry(key, bundles[key]);
delete bundles[key];
const next = revokeSchedulerActivationsForBundle({
...config,
bundles: Object.keys(bundles).length > 0 ? bundles : undefined,
...(config.defaultBundle === key ? { defaultBundle: undefined } : {}),
...(config.defaultWriteTarget === key ? { defaultWriteTarget: undefined } : {}),
}, key);
result = { sources: bundlesToSourceEntries(next) ?? [], removed: true, entry: removed };
return next;
});
return result;
}