+17
index 5918e1c..c48775f 100644
--- a/package.json
+++ b/package.json
"command": "sigit.restartAgent",
"title": "siGit: Restart Agent",
"category": "siGit"
+ },
+ {
+ "command": "sigit.browseRegistry",
+ "title": "siGit: Browse Agent Registry",
+ "category": "siGit"
+ },
+ {
+ "command": "sigit.refreshRegistry",
+ "title": "siGit: Refresh Agent Registry",
+ "category": "siGit"
}
],
"viewsContainers": {
"default": "sigit",
"markdownDescription": "Key of the agent to use by default, looked up in `#sigit.agents#`. Defaults to the on-device `sigit` binary."
},
+ "sigit.registry.url": {
+ "type": "string",
+ "default": "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json",
+ "markdownDescription": "URL of the [Agent Client Protocol registry](https://github.com/agentclientprotocol/registry) catalog used by **siGit: Browse Agent Registry**. Installing an agent only registers a launch command in `#sigit.agents#` (via `npx`/`uvx`, or a binary you place on your PATH); siGit never downloads or runs agent binaries on your behalf."
+ },
"sigit.agents": {
"type": "object",
"markdownDescription": "Registry of ACP agents that siGit Code can spawn. Each entry maps a key to an agent definition. The on-device `sigit` agent is the local-first default; add other ACP-compatible agents here.",
"compile": "tsc --noEmit",
"lint": "eslint src --ext ts",
"test:smoke": "node test/smoke.mjs",
+ "test:registry": "node test/registry.mjs",
+ "test": "npm run test:registry && npm run test:smoke",
"package": "vsce package"
},
"devDependencies": {
+275
new file mode 100644
index 0000000..93e3229
--- /dev/null
+++ b/src/catalog.ts
+/**
+ * The ACP agent catalog: parsing for the official Agent Client Protocol
+ * registry (https://github.com/agentclientprotocol/registry) and the logic that
+ * resolves a registry entry's distribution into a runnable launch command.
+ *
+ * This module deliberately has no `vscode` dependency so it can be unit-tested
+ * in isolation (see `test/registry.mjs`). All network, configuration, and
+ * installation concerns live in `registry.ts`.
+ *
+ * Registry documents follow the published schema:
+ * https://cdn.agentclientprotocol.com/registry/v1/latest/agent.schema.json
+ * Each agent declares one or more distribution methods (`npx`, `uvx`, or
+ * per-platform `binary`). siGit "installs" an agent by registering a launch
+ * command derived from that distribution — it never downloads or runs a binary
+ * on the user's behalf. `npx`/`uvx` agents are fetched on demand by the package
+ * runner at spawn time; `binary` agents are registered with a manual-install
+ * hint so the user can place the binary on their PATH.
+ */
+
+export type DistributionKind = "npx" | "uvx" | "binary";
+
+export interface RegistryAgent {
+ /** Stable registry id (used as the `sigit.agents` key). */
+ key: string;
+ name: string;
+ /** Resolved launch command (e.g. `npx`). */
+ command: string;
+ args: string[];
+ env: Record<string, string>;
+ description?: string;
+ version?: string;
+ website?: string;
+ repository?: string;
+ /** Which distribution method the launch command was resolved from. */
+ distribution: DistributionKind;
+ /** True when the user must install a binary themselves before first use. */
+ manualInstall: boolean;
+ /** Human-readable install hint (download URL, etc.); never executed. */
+ install?: string;
+}
+
+export interface Catalog {
+ version: string;
+ agents: RegistryAgent[];
+}
+
+/** Platform keys used by the registry's `binary` distribution targets. */
+export type PlatformKey =
+ | "darwin-aarch64"
+ | "darwin-x86_64"
+ | "linux-aarch64"
+ | "linux-x86_64"
+ | "windows-aarch64"
+ | "windows-x86_64";
+
+const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
+
+/** The registry platform key for the host running this process. */
+export function currentPlatformKey(): PlatformKey | undefined {
+ const os =
+ process.platform === "darwin"
+ ? "darwin"
+ : process.platform === "win32"
+ ? "windows"
+ : process.platform === "linux"
+ ? "linux"
+ : undefined;
+ const arch =
+ process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : undefined;
+ if (!os || !arch) {
+ return undefined;
+ }
+ return `${os}-${arch}` as PlatformKey;
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
+}
+
+function isStringRecord(value: unknown): value is Record<string, string> {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ Object.values(value).every((v) => typeof v === "string")
+ );
+}
+
+function optionalString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
+}
+
+interface PackageDistribution {
+ package: string;
+ args: string[];
+ env: Record<string, string>;
+}
+
+function readPackage(raw: unknown): PackageDistribution | null {
+ if (typeof raw !== "object" || raw === null) {
+ return null;
+ }
+ const obj = raw as { package?: unknown; args?: unknown; env?: unknown };
+ if (typeof obj.package !== "string" || obj.package.trim() === "") {
+ return null;
+ }
+ if (obj.args !== undefined && !isStringArray(obj.args)) {
+ return null;
+ }
+ if (obj.env !== undefined && !isStringRecord(obj.env)) {
+ return null;
+ }
+ return {
+ package: obj.package,
+ args: isStringArray(obj.args) ? obj.args : [],
+ env: isStringRecord(obj.env) ? obj.env : {}
+ };
+}
+
+/** The launch fields resolved from a distribution method. */
+interface ResolvedLaunch {
+ command: string;
+ args: string[];
+ env: Record<string, string>;
+ distribution: DistributionKind;
+ manualInstall: boolean;
+ install?: string;
+}
+
+/** Strip any directory prefix from a binary `cmd` (`./amp-acp` → `amp-acp`). */
+function commandBasename(cmd: string): string {
+ const cleaned = cmd.replace(/^\.\//, "");
+ const parts = cleaned.split(/[\\/]/);
+ return parts[parts.length - 1] || cleaned;
+}
+
+/**
+ * Resolve a `distribution` object into a runnable launch, preferring the
+ * zero-manual-step package runners (`npx`, then `uvx`) and falling back to the
+ * platform `binary` target. Returns `null` when nothing is usable on this
+ * platform.
+ */
+function resolveDistribution(
+ distribution: unknown,
+ platform: PlatformKey | undefined
+): ResolvedLaunch | null {
+ if (typeof distribution !== "object" || distribution === null) {
+ return null;
+ }
+ const dist = distribution as { npx?: unknown; uvx?: unknown; binary?: unknown };
+
+ const npx = readPackage(dist.npx);
+ if (npx) {
+ return {
+ command: "npx",
+ args: ["-y", npx.package, ...npx.args],
+ env: npx.env,
+ distribution: "npx",
+ manualInstall: false
+ };
+ }
+
+ const uvx = readPackage(dist.uvx);
+ if (uvx) {
+ return {
+ command: "uvx",
+ args: [uvx.package, ...uvx.args],
+ env: uvx.env,
+ distribution: "uvx",
+ manualInstall: false
+ };
+ }
+
+ if (platform && typeof dist.binary === "object" && dist.binary !== null) {
+ const target = (dist.binary as Record<string, unknown>)[platform];
+ if (typeof target === "object" && target !== null) {
+ const t = target as { archive?: unknown; cmd?: unknown; args?: unknown; env?: unknown };
+ if (typeof t.cmd === "string" && t.cmd.trim() !== "") {
+ const archive = optionalString(t.archive);
+ return {
+ command: commandBasename(t.cmd),
+ args: isStringArray(t.args) ? t.args : [],
+ env: isStringRecord(t.env) ? t.env : {},
+ distribution: "binary",
+ manualInstall: true,
+ install: archive
+ ? `Manual install required: download ${archive} and ensure "${commandBasename(
+ t.cmd
+ )}" is on your PATH.`
+ : undefined
+ };
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Validate and resolve a single raw registry entry. Returns a normalized
+ * {@link RegistryAgent}, or `null` when required fields are missing or no
+ * distribution is usable on the given platform. Invalid entries are skipped
+ * rather than failing the whole catalog.
+ */
+export function validateEntry(raw: unknown, platform = currentPlatformKey()): RegistryAgent | null {
+ if (typeof raw !== "object" || raw === null) {
+ return null;
+ }
+ const entry = raw as { id?: unknown; name?: unknown; distribution?: unknown } & Record<
+ string,
+ unknown
+ >;
+ if (typeof entry.id !== "string" || !ID_PATTERN.test(entry.id)) {
+ return null;
+ }
+ if (typeof entry.name !== "string" || entry.name.trim() === "") {
+ return null;
+ }
+ const launch = resolveDistribution(entry.distribution, platform);
+ if (!launch) {
+ return null;
+ }
+
+ return {
+ key: entry.id,
+ name: entry.name,
+ command: launch.command,
+ args: launch.args,
+ env: launch.env,
+ description: optionalString(entry.description),
+ version: optionalString(entry.version),
+ website: optionalString(entry.website),
+ repository: optionalString(entry.repository),
+ distribution: launch.distribution,
+ manualInstall: launch.manualInstall,
+ install: launch.install
+ };
+}
+
+/**
+ * Parse the raw registry document text into a {@link Catalog}. Throws when the
+ * document is not valid JSON, has the wrong top-level shape, or declares an
+ * unsupported major version; individual unresolvable entries are dropped (see
+ * {@link validateEntry}).
+ */
+export function parseCatalog(text: string, platform = currentPlatformKey()): Catalog {
+ let doc: unknown;
+ try {
+ doc = JSON.parse(text);
+ } catch (err) {
+ throw new Error(`Registry is not valid JSON: ${(err as Error).message}`);
+ }
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
+ throw new Error("Registry must be a JSON object with an `agents` array.");
+ }
+ const obj = doc as { version?: unknown; agents?: unknown };
+ if (!Array.isArray(obj.agents)) {
+ throw new Error("Registry is missing the `agents` array.");
+ }
+ const version = typeof obj.version === "string" ? obj.version : "1.0.0";
+ if (!/^1\b|^1\./.test(version)) {
+ throw new Error(`Unsupported registry version "${version}"; expected 1.x.`);
+ }
+
+ const seen = new Set<string>();
+ const agents: RegistryAgent[] = [];
+ for (const raw of obj.agents) {
+ const agent = validateEntry(raw, platform);
+ if (agent && !seen.has(agent.key)) {
+ seen.add(agent.key);
+ agents.push(agent);
+ }
+ }
+ return { version, agents };
+}
+22
-9
index 09249d4..5daf183 100644
--- a/src/chatView.ts
+++ b/src/chatView.ts
this.postStatus(`Agent restarted: ${this.activeAgentKey}`);
}
- /** Pick an agent from the registry and switch to it. */
+ /** Pick a configured agent — or browse the registry — and switch to it. */
async selectAgent(): Promise<void> {
+ const browse = "$(cloud-download) Browse registry…";
const agents = listAgents();
const picked = await vscode.window.showQuickPick(
- agents.map((agent) => ({
- label: agent.name,
- description: agent.key,
- detail: `${agent.command} ${agent.args.join(" ")}`.trim(),
- agent
- })),
+ [
+ ...agents.map((agent) => ({
+ label: agent.name,
+ description: agent.key,
+ detail: `${agent.command} ${agent.args.join(" ")}`.trim(),
+ key: agent.key
+ })),
+ { label: browse, description: "", detail: "Discover and install ACP agents", key: undefined }
+ ],
{ placeHolder: "Select an ACP agent" }
);
if (!picked) {
return;
}
- this.activeAgentKey = picked.agent.key;
+ if (picked.key === undefined) {
+ await vscode.commands.executeCommand("sigit.browseRegistry");
+ return;
+ }
+ await this.useAgent(picked.key);
+ }
+
+ /** Switch the active agent to `key`, restarting the session. */
+ async useAgent(key: string): Promise<void> {
+ this.activeAgentKey = key;
this.disposeClient();
this.post({ type: "clear" });
await this.ensureClient();
- this.postStatus(`Agent: ${picked.agent.name}`);
+ this.postStatus(`Agent: ${resolveAgent(key).name}`);
}
private async handlePrompt(text: string): Promise<void> {
+110
-1
index 1caf553..67a0899 100644
--- a/src/extension.ts
+++ b/src/extension.ts
import * as vscode from "vscode";
import { ChatViewProvider } from "./chatView";
+import { RegistryAgent } from "./catalog";
+import { fetchCatalog, installAgent, installedKeys, setDefaultAgent } from "./registry";
export function activate(context: vscode.ExtensionContext): void {
const provider = new ChatViewProvider(context);
}),
vscode.commands.registerCommand("sigit.newSession", () => provider.newSession()),
vscode.commands.registerCommand("sigit.selectAgent", () => provider.selectAgent()),
- vscode.commands.registerCommand("sigit.restartAgent", () => provider.restart())
+ vscode.commands.registerCommand("sigit.restartAgent", () => provider.restart()),
+ vscode.commands.registerCommand("sigit.browseRegistry", () => browseRegistry(context, provider)),
+ vscode.commands.registerCommand("sigit.refreshRegistry", () => refreshRegistry(context))
);
}
export function deactivate(): void {
// Resources are tied to context.subscriptions and disposed automatically.
}
+
+/** Fetch the catalog, let the user pick an agent, and install it. */
+async function browseRegistry(
+ context: vscode.ExtensionContext,
+ provider: ChatViewProvider
+): Promise<void> {
+ let agents: RegistryAgent[];
+ try {
+ const result = await vscode.window.withProgress(
+ { location: vscode.ProgressLocation.Notification, title: "Fetching ACP registry…" },
+ () => fetchCatalog(context)
+ );
+ agents = result.agents;
+ if (result.fromCache) {
+ void vscode.window.showWarningMessage(
+ `Showing cached registry — fetch failed: ${result.error}`
+ );
+ }
+ } catch (err) {
+ void vscode.window.showErrorMessage(`Could not load the ACP registry: ${(err as Error).message}`);
+ return;
+ }
+
+ if (agents.length === 0) {
+ void vscode.window.showInformationMessage("The ACP registry is empty.");
+ return;
+ }
+
+ const installed = installedKeys();
+ const tag = (agent: RegistryAgent): string => {
+ const parts: string[] = [agent.distribution];
+ if (agent.version) {
+ parts.unshift(`v${agent.version}`);
+ }
+ if (agent.manualInstall) {
+ parts.push("manual install");
+ }
+ if (installed.has(agent.key)) {
+ parts.push("installed");
+ }
+ return parts.join(" · ");
+ };
+ const picked = await vscode.window.showQuickPick(
+ agents.map((agent) => ({
+ label: installed.has(agent.key) ? `$(check) ${agent.name}` : agent.name,
+ description: `${agent.key} · ${tag(agent)}`,
+ detail: agent.description ?? `${agent.command} ${agent.args.join(" ")}`.trim(),
+ agent
+ })),
+ { placeHolder: "Select an ACP agent to install", matchOnDescription: true, matchOnDetail: true }
+ );
+ if (!picked) {
+ return;
+ }
+
+ const agent = picked.agent;
+ if (!installed.has(agent.key)) {
+ const added = await installAgent(agent);
+ if (added && agent.manualInstall && agent.install) {
+ void vscode.window.showWarningMessage(`Installed "${agent.name}". ${agent.install}`);
+ } else if (added) {
+ void vscode.window.showInformationMessage(
+ `Installed "${agent.name}" — launches via ${agent.distribution}.`
+ );
+ }
+ }
+
+ await offerToUse(agent, provider);
+}
+
+/** After install, offer to switch to the agent and/or make it the default. */
+async function offerToUse(agent: RegistryAgent, provider: ChatViewProvider): Promise<void> {
+ const useNow = "Use now";
+ const setDefault = "Set as default";
+ const choice = await vscode.window.showInformationMessage(
+ `Use "${agent.name}"?`,
+ useNow,
+ setDefault
+ );
+ if (choice === setDefault) {
+ await setDefaultAgent(agent.key);
+ }
+ if (choice === useNow || choice === setDefault) {
+ await provider.useAgent(agent.key);
+ }
+}
+
+/** Force a fresh fetch of the registry and report how many agents are available. */
+async function refreshRegistry(context: vscode.ExtensionContext): Promise<void> {
+ try {
+ const result = await vscode.window.withProgress(
+ { location: vscode.ProgressLocation.Notification, title: "Refreshing ACP registry…" },
+ () => fetchCatalog(context)
+ );
+ if (result.fromCache) {
+ void vscode.window.showWarningMessage(`Registry refresh failed: ${result.error}`);
+ } else {
+ void vscode.window.showInformationMessage(
+ `ACP registry refreshed — ${result.agents.length} agent(s) available.`
+ );
+ }
+ } catch (err) {
+ void vscode.window.showErrorMessage(`Could not refresh the ACP registry: ${(err as Error).message}`);
+ }
+}
+114
new file mode 100644
index 0000000..7330931
--- /dev/null
+++ b/src/registry.ts
+import * as vscode from "vscode";
+import { Catalog, RegistryAgent, parseCatalog } from "./catalog";
+
+/**
+ * The remote ACP agent registry: fetches a curated catalog of ACP-compatible
+ * agents, caches the last good copy for offline use, and installs a chosen
+ * agent by writing its definition into the user's `sigit.agents` configuration.
+ *
+ * "Install" here means *register* — siGit never downloads or runs an agent
+ * binary on the user's behalf. Each catalog entry may carry a human-readable
+ * `install` hint that we surface but never execute.
+ */
+
+const DEFAULT_REGISTRY_URL =
+ "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
+const CACHE_KEY = "sigit.registry.cache";
+const FETCH_TIMEOUT_MS = 10_000;
+
+interface CacheEntry {
+ url: string;
+ fetchedAt: number;
+ agents: RegistryAgent[];
+}
+
+export interface FetchResult {
+ agents: RegistryAgent[];
+ /** True when the network fetch failed and cached data was served instead. */
+ fromCache: boolean;
+ /** The network error, present only when `fromCache` is true. */
+ error?: string;
+}
+
+function config(): vscode.WorkspaceConfiguration {
+ return vscode.workspace.getConfiguration("sigit");
+}
+
+/** The configured registry URL, or the built-in siGit catalog. */
+export function registryUrl(): string {
+ const configured = config().get<string>("registry.url");
+ return configured && configured.trim() !== "" ? configured.trim() : DEFAULT_REGISTRY_URL;
+}
+
+/** Keys of agents already present in the `sigit.agents` configuration. */
+export function installedKeys(): Set<string> {
+ const registry = config().get<Record<string, unknown>>("agents") ?? {};
+ return new Set(Object.keys(registry));
+}
+
+async function fetchFromNetwork(url: string): Promise<Catalog> {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: { accept: "application/json" }
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
+ }
+ return parseCatalog(await response.text());
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Fetch the agent catalog. On success the result is cached in global state. On
+ * failure the last cached catalog (for the same URL) is served when available,
+ * with `fromCache: true`; otherwise the error is re-thrown.
+ */
+export async function fetchCatalog(context: vscode.ExtensionContext): Promise<FetchResult> {
+ const url = registryUrl();
+ try {
+ const catalog = await fetchFromNetwork(url);
+ const entry: CacheEntry = { url, fetchedAt: Date.now(), agents: catalog.agents };
+ await context.globalState.update(CACHE_KEY, entry);
+ return { agents: catalog.agents, fromCache: false };
+ } catch (err) {
+ const cached = context.globalState.get<CacheEntry>(CACHE_KEY);
+ if (cached && cached.url === url) {
+ return {
+ agents: cached.agents,
+ fromCache: true,
+ error: (err as Error).message
+ };
+ }
+ throw err;
+ }
+}
+
+/**
+ * Register a catalog agent into the user's `sigit.agents` configuration. Returns
+ * `false` without writing when an agent with the same key is already installed.
+ */
+export async function installAgent(agent: RegistryAgent): Promise<boolean> {
+ const cfg = config();
+ const registry = { ...(cfg.get<Record<string, unknown>>("agents") ?? {}) };
+ if (Object.prototype.hasOwnProperty.call(registry, agent.key)) {
+ return false;
+ }
+ registry[agent.key] = {
+ name: agent.name,
+ command: agent.command,
+ args: agent.args,
+ env: agent.env
+ };
+ await cfg.update("agents", registry, vscode.ConfigurationTarget.Global);
+ return true;
+}
+
+/** Make `key` the default agent (`sigit.agent.default`), user-wide. */
+export async function setDefaultAgent(key: string): Promise<void> {
+ await config().update("agent.default", key, vscode.ConfigurationTarget.Global);
+}
+185
new file mode 100644
index 0000000..482c245
--- /dev/null
+++ b/test/registry.mjs
+#!/usr/bin/env node
+// Unit tests for the ACP registry catalog parser.
+//
+// Bundles the pure `src/catalog.ts` module (no `vscode` dependency) and
+// exercises parseCatalog/validateEntry against the official registry schema
+// (https://github.com/agentclientprotocol/registry), with a fixed platform so
+// binary-distribution resolution is deterministic.
+//
+// Run: node test/registry.mjs
+
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { build } from "esbuild";
+import { createRequire } from "node:module";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(__dirname, "..");
+
+const bundle = await build({
+ entryPoints: [join(repoRoot, "src", "catalog.ts")],
+ bundle: true,
+ write: false,
+ format: "cjs",
+ platform: "node",
+ target: "node18",
+ logLevel: "silent"
+});
+
+const requireFromTest = createRequire(import.meta.url);
+const Module = requireFromTest("node:module");
+const compiled = eval(Module.wrap(bundle.outputFiles[0].text));
+const moduleObj = { exports: {} };
+compiled(moduleObj.exports, requireFromTest, moduleObj, "catalog.bundle.js", repoRoot);
+const { parseCatalog, validateEntry } = moduleObj.exports;
+
+const MAC = "darwin-aarch64";
+
+let failures = 0;
+function check(cond, msg) {
+ if (cond) {
+ console.log(`OK ${msg}`);
+ } else {
+ console.error(`FAIL ${msg}`);
+ failures++;
+ }
+}
+function throws(fn, msg) {
+ try {
+ fn();
+ console.error(`FAIL ${msg} (expected throw)`);
+ failures++;
+ } catch {
+ console.log(`OK ${msg}`);
+ }
+}
+const parse = (doc, platform = MAC) => parseCatalog(JSON.stringify(doc), platform);
+
+// npx distribution → `npx -y <package> [args]`.
+const npx = parse({
+ version: "1.0.0",
+ agents: [
+ {
+ id: "claude-acp",
+ name: "Claude Agent",
+ version: "0.51.0",
+ description: "ACP wrapper for Anthropic's Claude",
+ distribution: { npx: { package: "@agentclientprotocol/claude-agent-acp@0.51.0" } }
+ }
+ ]
+}).agents[0];
+check(npx.command === "npx", "npx → command is npx");
+check(
+ JSON.stringify(npx.args) ===
+ JSON.stringify(["-y", "@agentclientprotocol/claude-agent-acp@0.51.0"]),
+ "npx → args use -y + package"
+);
+check(npx.distribution === "npx" && npx.manualInstall === false, "npx → not manual install");
+check(npx.key === "claude-acp" && npx.version === "0.51.0", "carries id and version");
+
+// npx with extra args.
+const npxArgs = parse({
+ agents: [
+ { id: "a", name: "A", distribution: { npx: { package: "pkg@1", args: ["--acp"] } } }
+ ]
+}).agents[0];
+check(
+ JSON.stringify(npxArgs.args) === JSON.stringify(["-y", "pkg@1", "--acp"]),
+ "npx → appends distribution args"
+);
+
+// uvx distribution → `uvx <package> [args]`.
+const uvx = parse({
+ agents: [
+ { id: "fast", name: "Fast", distribution: { uvx: { package: "fast-agent-acp==0.7.22", args: ["-x"] } } }
+ ]
+}).agents[0];
+check(
+ uvx.command === "uvx" &&
+ JSON.stringify(uvx.args) === JSON.stringify(["fast-agent-acp==0.7.22", "-x"]),
+ "uvx → command + package + args"
+);
+
+// binary distribution for the current platform → basename cmd + manual install.
+const binDoc = {
+ agents: [
+ {
+ id: "amp-acp",
+ name: "Amp",
+ distribution: {
+ binary: {
+ "darwin-aarch64": {
+ archive: "https://example.com/amp-darwin-aarch64.tar.gz",
+ cmd: "./amp-acp"
+ }
+ }
+ }
+ }
+ ]
+};
+const bin = parse(binDoc).agents[0];
+check(bin.command === "amp-acp", "binary → command is cmd basename (./amp-acp → amp-acp)");
+check(bin.distribution === "binary" && bin.manualInstall === true, "binary → manual install");
+check(typeof bin.install === "string" && bin.install.includes("example.com"), "binary → install hint with archive URL");
+
+// binary with no target for this platform → dropped (unrunnable here).
+check(parse(binDoc, "linux-x86_64").agents.length === 0, "binary → dropped when no platform target");
+
+// binary + npx prefers npx (zero manual steps).
+const both = parse({
+ agents: [
+ {
+ id: "dual",
+ name: "Dual",
+ distribution: {
+ npx: { package: "dual@1" },
+ binary: { "darwin-aarch64": { archive: "https://x/y.tgz", cmd: "./dual" } }
+ }
+ }
+ ]
+}).agents[0];
+check(both.command === "npx" && both.distribution === "npx", "binary+npx → prefers npx");
+
+// Invalid / unusable entries are dropped, not fatal.
+const filtered = parse({
+ agents: [
+ { name: "no id", distribution: { npx: { package: "x" } } },
+ { id: "Bad-Id", name: "bad", distribution: { npx: { package: "x" } } },
+ { id: "nodist", name: "No dist" },
+ { id: "emptydist", name: "Empty", distribution: {} },
+ { id: "good", name: "Good", distribution: { npx: { package: "good@1" } } }
+ ]
+});
+check(
+ filtered.agents.length === 1 && filtered.agents[0].key === "good",
+ "drops entries missing id/name/usable distribution"
+);
+
+// Duplicate ids: first wins.
+const deduped = parse({
+ agents: [
+ { id: "dup", name: "First", distribution: { npx: { package: "first@1" } } },
+ { id: "dup", name: "Second", distribution: { npx: { package: "second@1" } } }
+ ]
+});
+check(deduped.agents.length === 1 && deduped.agents[0].name === "First", "dedupes by id (first wins)");
+
+// validateEntry on its own.
+check(
+ validateEntry({ id: "k", name: "K", distribution: { npx: { package: "p" } } }, MAC) !== null,
+ "validateEntry accepts a valid npx entry"
+);
+check(validateEntry({ id: "k", name: "K", distribution: {} }, MAC) === null, "validateEntry rejects empty distribution");
+check(validateEntry(null, MAC) === null, "validateEntry rejects null");
+
+// Structural failures throw.
+throws(() => parseCatalog("not json", MAC), "throws on invalid JSON");
+throws(() => parseCatalog("[]", MAC), "throws on non-object top level");
+throws(() => parseCatalog(JSON.stringify({ version: "1.0.0" }), MAC), "throws when agents array missing");
+throws(() => parseCatalog(JSON.stringify({ version: "2.0.0", agents: [] }), MAC), "throws on unsupported major version");
+
+if (failures > 0) {
+ console.error(`\n${failures} CHECK(S) FAILED`);
+ process.exit(1);
+}
+console.log("\nALL REGISTRY CHECKS PASSED");