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
127 lines (126 loc) • 7.19 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 { defineGroupCommand, defineJsonCommand, output } from "../cli/shared.js";
import { getEffectiveRegistries, loadUserConfig, mutateConfig } from "../core/config/config.js";
import { NotFoundError, UsageError } from "../core/errors.js";
import { formatRegistryLabel, formatRegistryUrl, hasRegistryUrlCredentials, registryEntryForOutput, } from "../core/registry-url.js";
import { warn } from "../core/warn.js";
export const registryCommand = defineGroupCommand({
meta: { name: "registry", description: "Manage bundle registries" },
subCommands: {
list: defineJsonCommand({
meta: { name: "list", description: "List configured registries" },
async run() {
const config = loadUserConfig();
// R-066 #5: mirror the shared config↔default-fallback helper instead
// of re-deriving it inline.
output("registry-list", { registries: getEffectiveRegistries(config).map(registryEntryForOutput) });
},
}),
add: defineJsonCommand({
meta: { name: "add", description: "Add a registry by URL" },
args: {
url: { type: "positional", description: "Registry index URL", required: true },
name: { type: "string", description: "Human-friendly name for the registry" },
provider: { type: "string", description: "Provider type (e.g. static-index, skills-sh)" },
options: { type: "string", description: "Provider-specific options as JSON." },
"allow-insecure-transport": {
type: "boolean",
description: "Allow a plain HTTP registry URL (otherwise rejected)",
default: false,
},
},
async run({ args }) {
if (hasRegistryUrlCredentials(args.url)) {
warn(`Registry ${formatRegistryLabel({ url: args.url, name: args.name })} was added, but its URL's ` +
"username/password is ignored: authenticated registries are not supported by the built-in " +
"static-index or skills-sh providers.");
}
if (!args.url.startsWith("http")) {
throw new UsageError("Registry URL must start with http:// or https://");
}
if (args.url.startsWith("http://")) {
const allowInsecureTransport = args["allow-insecure-transport"];
if (!allowInsecureTransport) {
throw new UsageError("Registry URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious index. " +
"Use https:// or pass --allow-insecure-transport if you have explicitly accepted the risk.");
}
warn("Warning: registry URL uses plain HTTP (not HTTPS). --allow-insecure-transport was set; an on-path attacker could substitute a malicious index.");
}
const entry = { url: args.url };
if (args.name)
entry.name = args.name;
if (args.provider)
entry.provider = args.provider;
if (args.options) {
try {
entry.options = JSON.parse(args.options);
}
catch {
throw new UsageError("--options must be valid JSON");
}
}
let added = false;
const updated = mutateConfig((config) => {
const registries = [...(config.registries ?? [])];
if (registries.some((registry) => registry.url === args.url))
return config;
registries.push(entry);
added = true;
return { ...config, registries };
}).config;
output("registry-add", {
registries: (updated.registries ?? []).map(registryEntryForOutput),
added,
...(!added ? { message: "Registry URL already configured" } : {}),
});
},
}),
remove: defineJsonCommand({
meta: { name: "remove", description: "Remove a registry by URL or name" },
args: {
target: { type: "positional", description: "Registry URL or name to remove", required: true },
yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
},
async run({ args }) {
const config = loadUserConfig();
const registries = [...(config.registries ?? [])];
const displayTarget = hasRegistryUrlCredentials(args.target) ||
args.target.startsWith("http://") ||
args.target.startsWith("https://")
? formatRegistryUrl(args.target)
: args.target;
const idx = registries.findIndex((r) => r.url === args.target || r.name === args.target);
if (idx === -1) {
// Was a success envelope with `removed: false` and exit 0, so
// `akm registry remove typo && ...` proceeded as if it had removed
// something. A missing target is exit 1, like every other not-found.
throw new NotFoundError(`No registry matching "${displayTarget}" is configured.`, "SOURCE_NOT_FOUND", "Run `akm registry list` to see configured registries.");
}
const { confirmDestructive } = await import("../cli/confirm.js");
const confirmed = await confirmDestructive(`Remove registry "${displayTarget}"? This cannot be undone.`, {
yes: args.yes === true,
});
if (!confirmed) {
process.stderr.write("Aborted.\n");
return;
}
let removed;
const updated = mutateConfig((latest) => {
const current = [...(latest.registries ?? [])];
const currentIndex = current.findIndex((registry) => registry.url === args.target || registry.name === args.target);
if (currentIndex < 0)
return latest;
removed = current.splice(currentIndex, 1)[0];
return { ...latest, registries: current };
}).config;
output("registry-remove", {
registries: (updated.registries ?? []).map(registryEntryForOutput),
removed: removed !== undefined,
...(removed ? { entry: registryEntryForOutput(removed) } : { message: "No matching registry found" }),
});
},
}),
},
});