Add ACP registry browsing and install

Pulls the official Agent Client Protocol registry catalog and lets users browse it and install an agent into sigit.agents. - catalog.ts parses the registry schema and resolves each agent's distribution to a launch command, preferring npx and uvx and falling back to a platform binary the user installs manually - registry.ts fetches the catalog, caches the last good copy for offline use, and writes a chosen agent into user settings - two commands: Browse Agent Registry and Refresh Agent Registry - the agent picker gains a Browse registry entry - sigit.registry.url points at the published catalog - registry.mjs covers the parser Installing only registers a launch command. siGit never downloads or runs an agent binary on the user's behalf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

paydii committed Jun 26, 2026 at 00:25 UTC 8ccbf269663558459e72ebe504331c93d4515f03
6 files changed +723 -10
package.json
+17
index 5918e1c..c48775f 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,16 @@ "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": { @@ -88,6 +98,11 @@ "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.", @@ -155,6 +170,8 @@ "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": {
src/catalog.ts
+275
new file mode 100644 index 0000000..93e3229 --- /dev/null +++ b/src/catalog.ts @@ -0,0 +1,275 @@ +/** + * 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 }; +}
src/chatView.ts
+22 -9
index 09249d4..5daf183 100644 --- a/src/chatView.ts +++ b/src/chatView.ts @@ -69,26 +69,39 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { 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> {
src/extension.ts
+110 -1
index 1caf553..67a0899 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,7 @@ 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); @@ -17,10 +19,117 @@ export function activate(context: vscode.ExtensionContext): void { }), 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}`); + } +}
src/registry.ts
+114
new file mode 100644 index 0000000..7330931 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,114 @@ +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); +}
test/registry.mjs
+185
new file mode 100644 index 0000000..482c245 --- /dev/null +++ b/test/registry.mjs @@ -0,0 +1,185 @@ +#!/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");