UNPKG

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
// 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; } }