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
94 lines (93 loc) • 4.63 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 { abortableDelay, backoffDelay, computeRetryDelay, jsonWithByteCap, ResponseTooLargeError, shouldRetry, toErrorMessage, } from "../core/common.js";
import { ConfigError, NotFoundError, TransientError } from "../core/errors.js";
import { formatRegistryUrl } from "../core/registry-url.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRIES = 2;
const DEFAULT_JSON_BYTE_CAP = 10 * 1024 * 1024;
const MAX_RETRY_DELAY_MS = 30_000;
/** Fetch a registry URL. Resolves only to a 2xx response; every other outcome throws an `AkmError`. */
export async function fetchRegistry(rawUrl, options = {}) {
const url = parseRegistryUrl(rawUrl);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = options.retries ?? DEFAULT_RETRIES;
for (let attempt = 0;; attempt += 1) {
// AbortSignal.timeout() does not keep the event loop alive, and it keeps
// covering the body after this function returns.
const timeout = AbortSignal.timeout(timeoutMs);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
let response;
try {
response = await fetch(rawUrl, { headers: options.headers, signal, redirect: "follow" });
}
catch (error) {
if (options.signal?.aborted)
throw error;
if (attempt < retries) {
await abortableDelay(backoffDelay(attempt, undefined, MAX_RETRY_DELAY_MS), options.signal);
continue;
}
const reason = timeout.aborted ? `timed out after ${timeoutMs}ms` : `failed: ${toErrorMessage(error)}`;
throw new TransientError(`Registry request to ${url.host} ${reason} (${rawUrl})`, "REGISTRY_UNREACHABLE");
}
if (attempt < retries && shouldRetry(response.status)) {
await cancelBody(response);
await abortableDelay(computeRetryDelay(response, attempt, { maxDelayMs: MAX_RETRY_DELAY_MS }), options.signal);
continue;
}
if (response.ok)
return response;
await cancelBody(response);
throw statusError(response.status, rawUrl);
}
}
/** `fetchRegistry` plus a JSON body read under a byte cap. */
export async function fetchRegistryJson(rawUrl, options = {}) {
const response = await fetchRegistry(rawUrl, options);
try {
return await jsonWithByteCap(response, options.maxBytes ?? DEFAULT_JSON_BYTE_CAP, {
bodyTimeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
signal: options.signal,
});
}
catch (error) {
if (options.signal?.aborted)
throw error;
if (error instanceof ResponseTooLargeError || error instanceof SyntaxError) {
throw new NotFoundError(`Registry response from ${rawUrl} is not usable: ${toErrorMessage(error)}`, "REGISTRY_RESPONSE_INVALID");
}
throw new TransientError(`Registry response from ${rawUrl} could not be read: ${toErrorMessage(error)}`, "REGISTRY_UNREACHABLE");
}
}
function parseRegistryUrl(rawUrl) {
let url;
try {
url = new URL(rawUrl);
}
catch {
throw new ConfigError(`Registry URL is not a valid URL: ${formatRegistryUrl(rawUrl)}`, "REGISTRY_URL_INVALID");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new ConfigError(`Registry URL must use http(s), not ${url.protocol || "an empty scheme"}: ${formatRegistryUrl(rawUrl)}`, "REGISTRY_URL_INVALID");
}
if (url.username || url.password) {
throw new ConfigError(`Registry URL must not embed credentials: ${url.origin}`, "REGISTRY_URL_INVALID");
}
return url;
}
function statusError(status, rawUrl) {
const message = `Registry request failed (HTTP ${status}) for ${rawUrl}`;
if (shouldRetry(status))
return new TransientError(message, "REGISTRY_UNREACHABLE");
if (status === 404 || status === 410)
return new NotFoundError(message, "REGISTRY_NOT_FOUND");
const hint = status === 401 || status === 403
? "The registry refused the request. For GitHub, set GITHUB_TOKEN or sign in with `gh auth login`; akm's registry providers send no other credentials."
: undefined;
return new NotFoundError(message, "REGISTRY_RESPONSE_INVALID", hint);
}
async function cancelBody(response) {
await response.body?.cancel().catch(() => undefined);
}