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
610 lines (609 loc) • 24.7 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 { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ConfigError, NotFoundError, UsageError } from "../core/errors.js";
import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "../integrations/github.js";
import { fetchRegistryJson } from "./network.js";
import { isExactSemver, isSemverRange, maxSatisfying } from "./semver.js";
/**
* Pass an HTTPS bearer credential to Git without placing it in the remote URL
* or argv. The value exists only in the child-process environment and is never
* persisted or included in diagnostics.
*/
export function gitCredentialEnvironment(credential) {
if (!credential)
return { ...process.env };
if (/[\r\n\0]/.test(credential)) {
throw new UsageError("Git credential contains an invalid control character.");
}
const configuredCount = process.env.GIT_CONFIG_COUNT;
const nextIndex = configuredCount === undefined ? 0 : Number(configuredCount);
if (!Number.isSafeInteger(nextIndex) || nextIndex < 0) {
throw new UsageError("GIT_CONFIG_COUNT must be a non-negative integer before a Git credential can be added.");
}
return {
...process.env,
GIT_CONFIG_COUNT: String(nextIndex + 1),
[`GIT_CONFIG_KEY_${nextIndex}`]: "http.extraHeader",
[`GIT_CONFIG_VALUE_${nextIndex}`]: `Authorization: Bearer ${credential}`,
};
}
/**
* Validate that a URL is safe to pass to git.
* Allowlists https:, http:, ssh:, git: schemes and git@ SSH shorthand.
* Rejects git protocol helpers (ext::, fd::) that can execute arbitrary commands.
*/
export function validateGitUrl(url) {
// git@ SSH shorthand: git@host:path
if (/^git@[^:]+:.+$/.test(url))
return;
let parsed;
try {
parsed = new URL(url);
}
catch {
throw new UsageError(`Invalid git URL: ${url}`);
}
const allowed = ["https:", "http:", "ssh:", "git:"];
if (!allowed.includes(parsed.protocol)) {
throw new UsageError(`Unsafe git URL scheme "${parsed.protocol}" in "${url}". Allowed: https, http, ssh, git, git@host:path`);
}
}
/** Validate that a git ref (branch/tag/commit) contains only safe characters. */
export function validateGitRef(ref) {
if (!/^[a-zA-Z0-9._\-/]+$/.test(ref)) {
throw new UsageError(`Unsafe git ref "${ref}": only alphanumerics, '.', '_', '-', '/' are allowed`);
}
}
export function parseRegistryRef(rawRef) {
const ref = rawRef.trim();
if (!ref)
throw new UsageError("Registry ref is required.", "MISSING_REQUIRED_ARGUMENT");
// Detect registry search result IDs (e.g. "skills-sh:org/skills/name")
// that are not installable refs. Known installable prefixes are handled below.
const registryIdHint = detectRegistrySearchId(ref);
if (registryIdHint) {
throw new UsageError(registryIdHint);
}
if (ref.startsWith("npm:")) {
return parseNpmRef(ref.slice(4), ref);
}
if (ref.startsWith("github:")) {
return parseGithubShorthand(ref.slice(7), ref);
}
if (ref.startsWith("git+")) {
return parseGitUrl(stripGitTransport(ref), ref);
}
if (ref.startsWith("file:")) {
return tryParseLocalRef(fileUriToPath(ref), true);
}
if (ref.startsWith("http://") || ref.startsWith("https://")) {
return parseRemoteUrl(ref);
}
const localRef = tryParseLocalRef(ref, isPathLikeRef(ref));
if (localRef) {
return localRef;
}
if (ref.startsWith("@") || !looksLikeGithubOwnerRepo(ref)) {
return parseNpmRef(ref, ref);
}
return parseGithubShorthand(ref, ref);
}
/** Inverse of {@link parseRegistryRef}: build the install ref for a source kind. */
export function buildInstallRef(source, ref, provenance = "operator") {
if (source === "git" && provenance === "registry") {
throw new UsageError("Registry-provided git transport refs are not installable. Add a trusted git URL directly if you intend to use it.");
}
switch (source) {
case "npm":
return `npm:${ref}`;
case "git":
return `git+${ref}`;
case "local":
return `file:${ref}`;
case "github":
return `github:${ref}`;
}
}
/**
* Known prefixes that `parseRegistryRef` handles as installable sources.
* Anything with a colon that doesn't start with one of these is likely a
* registry search result ID (e.g. `skills-sh:org/skills/name`).
*/
const KNOWN_PREFIXES = ["npm:", "github:", "git+", "file:", "http://", "https://"];
function detectRegistrySearchId(ref) {
const colonIdx = ref.indexOf(":");
if (colonIdx < 1)
return undefined;
// Skip known installable prefixes
for (const prefix of KNOWN_PREFIXES) {
if (ref.startsWith(prefix))
return undefined;
}
const prefix = ref.slice(0, colonIdx);
// Registry IDs use lowercase-with-hyphens prefixes (e.g. skills-sh, static-index)
if (!/^[a-z][a-z0-9-]*$/.test(prefix))
return undefined;
const rest = ref.slice(colonIdx + 1);
// Try to extract a plausible owner/repo from the rest (e.g. "org/repo/skill" → "org/repo")
const segments = rest.split("/").filter(Boolean);
const suggestedRef = segments.length >= 2 ? `github:${segments[0]}/${segments[1]}` : undefined;
const lines = [
`"${ref}" looks like a registry search result ID, not an installable ref.`,
`The "${prefix}:" prefix is a registry identifier and cannot be passed to \`akm bundle add\`.`,
"",
];
if (suggestedRef) {
lines.push(`Try installing the source repository directly:`, ` akm bundle add ${suggestedRef}`, "");
}
lines.push("Or search for the installable ref:", ` akm search "${segments.length > 2 ? segments[segments.length - 1] : rest}" --from registry`, "Then install using the installRef value from the result:", " akm bundle add github:owner/repo", " akm bundle add npm:package-name");
return lines.join("\n");
}
export async function resolveRegistryArtifact(parsed, options) {
switch (parsed.source) {
case "npm":
return resolveNpmArtifact(parsed);
case "local":
return resolveLocalArtifact(parsed);
case "git":
return resolveGitArtifact(parsed, options?.gitCredential);
case "github":
return resolveGithubArtifact(parsed, options?.gitCredential);
}
}
function parseNpmRef(input, originalRef) {
const trimmed = input.trim();
if (!trimmed)
throw new UsageError("Invalid npm ref.");
const parsed = splitNpmNameAndVersion(trimmed);
validateNpmPackageName(parsed.packageName);
return {
source: "npm",
ref: originalRef,
id: `npm:${parsed.packageName}`,
packageName: parsed.packageName,
requestedVersionOrTag: parsed.requestedVersionOrTag,
};
}
function parseGithubShorthand(input, originalRef) {
const [repoPart, requestedRef] = splitRefSuffix(input.trim());
const segments = repoPart.split("/").filter(Boolean);
if (segments.length !== 2) {
throw new UsageError("Invalid GitHub ref. Expected owner/repo or owner/repo#ref.");
}
const owner = segments[0];
const repo = segments[1].replace(/\.git$/i, "");
if (!owner || !repo) {
throw new UsageError("Invalid GitHub ref. Expected owner/repo.");
}
return {
source: "github",
ref: originalRef,
id: `github:${owner}/${repo}`,
owner,
repo,
requestedRef,
};
}
function parseRemoteUrl(rawUrl) {
let url;
try {
url = new URL(rawUrl);
}
catch {
throw new UsageError("Invalid registry URL.");
}
if (url.hostname === "github.com") {
return parseGithubUrl(url, rawUrl);
}
return parseGitUrl(rawUrl, rawUrl);
}
function parseGithubUrl(url, rawUrl) {
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length < 2) {
throw new UsageError("Invalid GitHub URL. Expected https://github.com/owner/repo.");
}
const owner = segments[0];
const repo = segments[1].replace(/\.git$/i, "");
const requestedRef = url.hash ? decodeURIComponent(url.hash.slice(1)) : undefined;
return {
source: "github",
ref: rawUrl,
id: `github:${owner}/${repo}`,
owner,
repo,
requestedRef,
};
}
function parseGitUrl(input, originalRef) {
const [urlPart, requestedRef] = splitRefSuffix(input.trim());
if (!urlPart)
throw new UsageError("Invalid git ref. A URL is required.");
// Normalize the URL for the id (strip .git suffix, fragment)
const normalized = urlPart.replace(/\.git$/i, "");
return {
source: "git",
ref: originalRef,
id: `git:${normalized}`,
url: urlPart,
requestedRef,
};
}
function tryParseLocalRef(rawRef, explicitPath) {
const resolvedPath = path.resolve(rawRef);
let stat;
try {
stat = fs.statSync(resolvedPath);
}
catch {
// Explicit paths (./foo, ../bar, /abs) should throw on missing
if (explicitPath) {
throw new NotFoundError(`Local path not found: ${resolvedPath}`, "FILE_NOT_FOUND", "Check the path exists and is readable.");
}
// Bare names that don't exist on disk — let caller fall through to npm/github
return undefined;
}
if (!stat.isDirectory()) {
if (explicitPath) {
throw new UsageError("Local add path must be a directory, but the provided path is not one.");
}
// Bare name exists but isn't a directory — not a local ref
return undefined;
}
const repoRoot = findGitRepoRoot(resolvedPath);
return {
source: "local",
ref: rawRef,
id: `local:${toReadableLocalId(resolvedPath)}`,
repoRoot,
sourcePath: resolvedPath,
};
}
function isPathLikeRef(ref) {
// A leading `@` marks an npm scope (`@scope/pkg`), never a filesystem path —
// treat it as non-path so it falls through to the npm branch instead of
// being resolved (and rejected) as an explicit local path.
if (ref.startsWith("@"))
return false;
if (ref === "." || ref === "..")
return true;
if (path.isAbsolute(ref))
return true;
if (ref.startsWith("./") || ref.startsWith("../") || ref.startsWith(".\\") || ref.startsWith("..\\")) {
return true;
}
// R-007: a bare `owner/repo` (or `owner/repo#ref`) shorthand is ambiguous
// with a relative directory of the same shape, and is never an EXPLICIT
// local path the way `./owner/repo` is. Treating it as non-path here means
// `tryParseLocalRef` silently returns `undefined` (rather than throwing
// NotFoundError) when no such directory exists on disk, letting
// `parseRegistryRef`'s GitHub-shorthand fallback run instead. A directory
// that DOES exist with this shape is unaffected — `tryParseLocalRef`
// resolves it before the missing-path distinction ever matters.
if (looksLikeGithubOwnerRepo(ref))
return false;
return ref.includes("/") || ref.includes("\\");
}
/** Default public npm registry host. */
const DEFAULT_NPM_REGISTRY_HOST = "registry.npmjs.org";
const UNTRUSTED_TARBALL_HINT = "Set AKM_NPM_REGISTRY to your private npm mirror's base URL if you install from a non-default registry.";
/**
* Validate that an npm tarball URL starts at the exact metadata registry
* origin: a mirror must not change scheme or port, or nominate a different
* host, in `dist.tarball`.
*/
export function validateNpmTarballUrl(tarballUrl, packageRef, registryOrigin) {
let url;
try {
url = new URL(tarballUrl);
}
catch {
throw new NotFoundError(`npm package ${packageRef} returned an invalid tarball URL: ${tarballUrl}`, "REGISTRY_RESPONSE_INVALID", UNTRUSTED_TARBALL_HINT);
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new NotFoundError(`npm package ${packageRef} returned a tarball with disallowed scheme "${url.protocol}".`, "REGISTRY_RESPONSE_INVALID", UNTRUSTED_TARBALL_HINT);
}
const expectedOrigin = registryOrigin ?? new URL(npmMetadataRegistry()).origin;
if (url.origin !== expectedOrigin) {
throw new NotFoundError(`npm package ${packageRef} returned a tarball URL on untrusted origin "${url.origin}" (expected: ${expectedOrigin}).`, "REGISTRY_RESPONSE_INVALID", UNTRUSTED_TARBALL_HINT);
}
}
/**
* The npm registry base URL for package METADATA: `AKM_NPM_REGISTRY` when set
* (a private mirror replaces the default wholesale, like npm's own
* `--registry`), else the public registry.
*/
function npmMetadataRegistry() {
const override = process.env.AKM_NPM_REGISTRY?.trim();
if (override) {
// A malformed override must not be silently ignored (falling back to the
// public registry as though nothing was configured) — that would install
// from the wrong registry without telling the operator (R-035).
let url;
try {
url = new URL(override);
}
catch {
throw new ConfigError(`AKM_NPM_REGISTRY is set to an invalid URL: ${override}`, "REGISTRY_URL_INVALID");
}
return `${url.origin}${url.pathname === "/" ? "" : url.pathname}`.replace(/\/+$/, "");
}
return `https://${DEFAULT_NPM_REGISTRY_HOST}`;
}
async function resolveNpmArtifact(parsed) {
const encodedName = encodeURIComponent(parsed.packageName);
const registryBase = npmMetadataRegistry();
const metadata = await fetchJson(`${registryBase}/${encodedName}`, undefined);
const versions = asRecord(metadata.versions);
const distTags = asRecord(metadata["dist-tags"]);
const requested = parsed.requestedVersionOrTag;
let resolvedVersion;
if (!requested) {
resolvedVersion = asString(distTags.latest);
}
else if (requested in versions) {
resolvedVersion = requested;
}
else {
// Try dist-tag first
resolvedVersion = asString(distTags[requested]);
// If not a dist-tag, try semver range resolution
if (!resolvedVersion && isSemverRange(requested)) {
const versionKeys = Object.keys(versions).filter(isExactSemver);
resolvedVersion = maxSatisfying(versionKeys, requested);
}
}
if (!resolvedVersion || !(resolvedVersion in versions)) {
throw new NotFoundError(`Unable to resolve npm ref "${parsed.ref}".`, "REGISTRY_NOT_FOUND");
}
const versionMeta = asRecord(versions[resolvedVersion]);
const dist = asRecord(versionMeta.dist);
const tarballUrl = asString(dist.tarball);
if (!tarballUrl) {
throw new NotFoundError(`npm package ${parsed.packageName}@${resolvedVersion} does not expose a tarball URL.`, "REGISTRY_RESPONSE_INVALID");
}
validateNpmTarballUrl(tarballUrl, `${parsed.packageName}@${resolvedVersion}`, new URL(registryBase).origin);
const resolvedRevision = asString(dist.shasum) ?? asString(dist.integrity);
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: tarballUrl,
resolvedVersion,
resolvedRevision,
};
}
async function resolveGithubArtifact(parsed, credential) {
const gitUrl = `https://github.com/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}.git`;
const repoBase = `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`;
// Prefer git-backed installs so private GitHub repos work with the user's
// normal git credential helper rather than requiring API-specific auth.
const gitResolvedRevision = resolveGitRevisionFromRemote(gitUrl, parsed.requestedRef, credential);
if (gitResolvedRevision) {
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: gitUrl,
resolvedVersion: parsed.requestedRef,
resolvedRevision: gitResolvedRevision,
};
}
const headers = { ...githubHeaders(), ...(credential ? { Authorization: `Bearer ${credential}` } : {}) };
if (parsed.requestedRef) {
const commit = await tryFetchJson(`${repoBase}/commits/${encodeURIComponent(parsed.requestedRef)}`, headers);
const resolvedRevision = asString(commit?.sha) ?? parsed.requestedRef;
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: `${repoBase}/tarball/${encodeURIComponent(parsed.requestedRef)}`,
resolvedRevision,
resolvedVersion: parsed.requestedRef,
};
}
const latestRelease = await tryFetchJson(`${repoBase}/releases/latest`, headers);
if (latestRelease) {
const tarballUrl = asString(latestRelease.tarball_url);
if (tarballUrl) {
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: tarballUrl,
resolvedVersion: asString(latestRelease.tag_name),
resolvedRevision: asString(latestRelease.target_commitish),
};
}
}
const repoMeta = await fetchJson(repoBase, headers);
const defaultBranch = asString(repoMeta.default_branch);
if (!defaultBranch) {
throw new NotFoundError(`Unable to resolve default branch for ${parsed.owner}/${parsed.repo}.`, "REGISTRY_RESPONSE_INVALID");
}
const commit = await tryFetchJson(`${repoBase}/commits/${encodeURIComponent(defaultBranch)}`, headers);
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: `${repoBase}/tarball/${encodeURIComponent(defaultBranch)}`,
resolvedVersion: defaultBranch,
resolvedRevision: asString(commit?.sha) ?? defaultBranch,
};
}
function resolveGitRevisionFromRemote(url, requestedRef, credential) {
validateGitUrl(url);
const ref = requestedRef ?? "HEAD";
if (requestedRef)
validateGitRef(requestedRef);
const result = spawnSync("git", ["ls-remote", url, ref], {
encoding: "utf8",
timeout: 30_000,
env: gitCredentialEnvironment(credential),
});
if (result.status !== 0)
return undefined;
const firstLine = result.stdout.trim().split(/\r?\n/)[0];
return firstLine?.split(/\s/)[0] || undefined;
}
async function resolveGitArtifact(parsed, credential) {
const resolvedRevision = resolveGitRevisionFromRemote(parsed.url, parsed.requestedRef, credential);
if (!resolvedRevision) {
// Unlike the GitHub path (which falls back to the REST API on a failed
// `git ls-remote`), a plain git source has no fallback resolver — an
// unresolved revision here would silently disable the post-clone
// revision-integrity check in `verifyClonedRevision` (R-011).
throw new NotFoundError(`Unable to resolve ${parsed.requestedRef ?? "HEAD"} for ${parsed.url} via 'git ls-remote'; refusing to install without a verifiable revision.`, "REGISTRY_NOT_FOUND");
}
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: parsed.url,
resolvedVersion: parsed.requestedRef,
resolvedRevision,
};
}
async function resolveLocalArtifact(parsed) {
return {
id: parsed.id,
source: parsed.source,
ref: parsed.ref,
artifactUrl: pathToFileURL(parsed.sourcePath).toString(),
resolvedRevision: parsed.repoRoot ? readGitValue(parsed.repoRoot, "rev-parse", "HEAD") : undefined,
resolvedVersion: parsed.repoRoot ? readGitValue(parsed.repoRoot, "rev-parse", "--abbrev-ref", "HEAD") : undefined,
};
}
function splitNpmNameAndVersion(input) {
if (input.startsWith("@")) {
const secondAt = input.indexOf("@", 1);
if (secondAt > 0) {
return {
packageName: input.slice(0, secondAt),
requestedVersionOrTag: input.slice(secondAt + 1) || undefined,
};
}
return { packageName: input };
}
const at = input.lastIndexOf("@");
if (at > 0) {
return {
packageName: input.slice(0, at),
requestedVersionOrTag: input.slice(at + 1) || undefined,
};
}
return { packageName: input };
}
function validateNpmPackageName(name) {
if (!name)
throw new UsageError("Invalid npm package name: name is required.");
if (name.length > 214)
throw new UsageError(`Invalid npm package name: "${name}" exceeds 214 characters.`);
if (name !== name.toLowerCase() && !name.startsWith("@")) {
throw new UsageError(`Invalid npm package name: "${name}" must be lowercase.`);
}
if (name.startsWith(".") || name.startsWith("_")) {
throw new UsageError(`Invalid npm package name: "${name}" cannot start with . or _.`);
}
if (/[~'!()*]/.test(name) ||
name.includes(" ") ||
encodeURIComponent(name)
.replace(/%40/g, "@")
.replace(/%2[Ff]/g, "/") !== name) {
throw new UsageError(`Invalid npm package name: "${name}" contains invalid characters.`);
}
}
function looksLikeGithubOwnerRepo(ref) {
const [repoPart] = splitRefSuffix(ref);
const parts = repoPart.split("/").filter(Boolean);
return parts.length === 2;
}
function splitRefSuffix(value) {
const hash = value.indexOf("#");
if (hash < 0)
return [value, undefined];
return [value.slice(0, hash), value.slice(hash + 1) || undefined];
}
/**
* Strip the `git+` transport prefix from a ref, returning the inner URL.
* Handles `git+https://...`, `git+ssh://...`, `git+http://...`, etc.
*/
function stripGitTransport(ref) {
return ref.slice(4); // strip "git+"
}
/**
* Convert a `file:` URI to a local filesystem path.
*
* Standard `file:///absolute` forms are handled by Node's `fileURLToPath`.
* Non-standard `file:./relative` and `file:../relative` shorthand forms
* (not a valid RFC 8089 URL) are handled with a custom fallback.
*/
function fileUriToPath(ref) {
const after = ref.slice(5); // strip "file:"
// Standard file:///absolute/path — delegate to Node's implementation
if (after.startsWith("//")) {
try {
return fileURLToPath(ref);
}
catch {
// Fall through to custom handling
}
}
// Non-standard file:./relative or file:../relative or file:/absolute
return after;
}
/**
* Build a human-readable local ID from an absolute path.
* /home/user/akm/skills → ~/akm/skills
* /tmp/my-stash → /tmp/my-stash
*/
function toReadableLocalId(absolutePath) {
const home = os.homedir();
if (absolutePath === home)
return "~";
if (absolutePath.startsWith(home + path.sep)) {
return `~/${absolutePath.slice(home.length + 1)}`;
}
return absolutePath;
}
function findGitRepoRoot(startDir) {
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, ".git"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current)
return undefined;
current = parent;
}
}
function readGitValue(repoRoot, ...args) {
const result = spawnSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" });
if (result.status !== 0)
return undefined;
const value = result.stdout.trim();
return value || undefined;
}
// npm package manifests and GitHub API responses are typically a few KB; 10 MB
// is the cap on what one is allowed to send.
const REGISTRY_JSON_BYTE_CAP = 10 * 1024 * 1024;
async function fetchJson(url, headers) {
return fetchRegistryJson(url, { headers, timeoutMs: 30_000, maxBytes: REGISTRY_JSON_BYTE_CAP });
}
/** `fetchJson` for optional GitHub lookups: a not-found / unusable answer is `null`, a transient failure still throws. */
async function tryFetchJson(url, headers) {
try {
return await fetchJson(url, headers);
}
catch (error) {
if (error instanceof NotFoundError)
return null;
throw error;
}
}