@sigit / sigit-vscode / commits / 8ccbf26

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
@@ -60,6 +60,16 @@
60 "command": "sigit.restartAgent",
61 "title": "siGit: Restart Agent",
62 "category": "siGit"
63 + },
64 + {
65 + "command": "sigit.browseRegistry",
66 + "title": "siGit: Browse Agent Registry",
67 + "category": "siGit"
68 + },
69 + {
70 + "command": "sigit.refreshRegistry",
71 + "title": "siGit: Refresh Agent Registry",
72 + "category": "siGit"
73 }
74 ],
75 "viewsContainers": {
@@ -88,6 +98,11 @@
98 "default": "sigit",
99 "markdownDescription": "Key of the agent to use by default, looked up in `#sigit.agents#`. Defaults to the on-device `sigit` binary."
100 },
101 + "sigit.registry.url": {
102 + "type": "string",
103 + "default": "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json",
104 + "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."
105 + },
106 "sigit.agents": {
107 "type": "object",
108 "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 @@
170 "compile": "tsc --noEmit",
171 "lint": "eslint src --ext ts",
172 "test:smoke": "node test/smoke.mjs",
173 + "test:registry": "node test/registry.mjs",
174 + "test": "npm run test:registry && npm run test:smoke",
175 "package": "vsce package"
176 },
177 "devDependencies": {
src/catalog.ts new
+275
@@ -0,0 +1,275 @@
1 +/**
2 + * The ACP agent catalog: parsing for the official Agent Client Protocol
3 + * registry (https://github.com/agentclientprotocol/registry) and the logic that
4 + * resolves a registry entry's distribution into a runnable launch command.
5 + *
6 + * This module deliberately has no `vscode` dependency so it can be unit-tested
7 + * in isolation (see `test/registry.mjs`). All network, configuration, and
8 + * installation concerns live in `registry.ts`.
9 + *
10 + * Registry documents follow the published schema:
11 + * https://cdn.agentclientprotocol.com/registry/v1/latest/agent.schema.json
12 + * Each agent declares one or more distribution methods (`npx`, `uvx`, or
13 + * per-platform `binary`). siGit "installs" an agent by registering a launch
14 + * command derived from that distribution — it never downloads or runs a binary
15 + * on the user's behalf. `npx`/`uvx` agents are fetched on demand by the package
16 + * runner at spawn time; `binary` agents are registered with a manual-install
17 + * hint so the user can place the binary on their PATH.
18 + */
19 +
20 +export type DistributionKind = "npx" | "uvx" | "binary";
21 +
22 +export interface RegistryAgent {
23 + /** Stable registry id (used as the `sigit.agents` key). */
24 + key: string;
25 + name: string;
26 + /** Resolved launch command (e.g. `npx`). */
27 + command: string;
28 + args: string[];
29 + env: Record<string, string>;
30 + description?: string;
31 + version?: string;
32 + website?: string;
33 + repository?: string;
34 + /** Which distribution method the launch command was resolved from. */
35 + distribution: DistributionKind;
36 + /** True when the user must install a binary themselves before first use. */
37 + manualInstall: boolean;
38 + /** Human-readable install hint (download URL, etc.); never executed. */
39 + install?: string;
40 +}
41 +
42 +export interface Catalog {
43 + version: string;
44 + agents: RegistryAgent[];
45 +}
46 +
47 +/** Platform keys used by the registry's `binary` distribution targets. */
48 +export type PlatformKey =
49 + | "darwin-aarch64"
50 + | "darwin-x86_64"
51 + | "linux-aarch64"
52 + | "linux-x86_64"
53 + | "windows-aarch64"
54 + | "windows-x86_64";
55 +
56 +const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
57 +
58 +/** The registry platform key for the host running this process. */
59 +export function currentPlatformKey(): PlatformKey | undefined {
60 + const os =
61 + process.platform === "darwin"
62 + ? "darwin"
63 + : process.platform === "win32"
64 + ? "windows"
65 + : process.platform === "linux"
66 + ? "linux"
67 + : undefined;
68 + const arch =
69 + process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : undefined;
70 + if (!os || !arch) {
71 + return undefined;
72 + }
73 + return `${os}-${arch}` as PlatformKey;
74 +}
75 +
76 +function isStringArray(value: unknown): value is string[] {
77 + return Array.isArray(value) && value.every((item) => typeof item === "string");
78 +}
79 +
80 +function isStringRecord(value: unknown): value is Record<string, string> {
81 + return (
82 + typeof value === "object" &&
83 + value !== null &&
84 + !Array.isArray(value) &&
85 + Object.values(value).every((v) => typeof v === "string")
86 + );
87 +}
88 +
89 +function optionalString(value: unknown): string | undefined {
90 + return typeof value === "string" && value.trim() !== "" ? value : undefined;
91 +}
92 +
93 +interface PackageDistribution {
94 + package: string;
95 + args: string[];
96 + env: Record<string, string>;
97 +}
98 +
99 +function readPackage(raw: unknown): PackageDistribution | null {
100 + if (typeof raw !== "object" || raw === null) {
101 + return null;
102 + }
103 + const obj = raw as { package?: unknown; args?: unknown; env?: unknown };
104 + if (typeof obj.package !== "string" || obj.package.trim() === "") {
105 + return null;
106 + }
107 + if (obj.args !== undefined && !isStringArray(obj.args)) {
108 + return null;
109 + }
110 + if (obj.env !== undefined && !isStringRecord(obj.env)) {
111 + return null;
112 + }
113 + return {
114 + package: obj.package,
115 + args: isStringArray(obj.args) ? obj.args : [],
116 + env: isStringRecord(obj.env) ? obj.env : {}
117 + };
118 +}
119 +
120 +/** The launch fields resolved from a distribution method. */
121 +interface ResolvedLaunch {
122 + command: string;
123 + args: string[];
124 + env: Record<string, string>;
125 + distribution: DistributionKind;
126 + manualInstall: boolean;
127 + install?: string;
128 +}
129 +
130 +/** Strip any directory prefix from a binary `cmd` (`./amp-acp` → `amp-acp`). */
131 +function commandBasename(cmd: string): string {
132 + const cleaned = cmd.replace(/^\.\//, "");
133 + const parts = cleaned.split(/[\\/]/);
134 + return parts[parts.length - 1] || cleaned;
135 +}
136 +
137 +/**
138 + * Resolve a `distribution` object into a runnable launch, preferring the
139 + * zero-manual-step package runners (`npx`, then `uvx`) and falling back to the
140 + * platform `binary` target. Returns `null` when nothing is usable on this
141 + * platform.
142 + */
143 +function resolveDistribution(
144 + distribution: unknown,
145 + platform: PlatformKey | undefined
146 +): ResolvedLaunch | null {
147 + if (typeof distribution !== "object" || distribution === null) {
148 + return null;
149 + }
150 + const dist = distribution as { npx?: unknown; uvx?: unknown; binary?: unknown };
151 +
152 + const npx = readPackage(dist.npx);
153 + if (npx) {
154 + return {
155 + command: "npx",
156 + args: ["-y", npx.package, ...npx.args],
157 + env: npx.env,
158 + distribution: "npx",
159 + manualInstall: false
160 + };
161 + }
162 +
163 + const uvx = readPackage(dist.uvx);
164 + if (uvx) {
165 + return {
166 + command: "uvx",
167 + args: [uvx.package, ...uvx.args],
168 + env: uvx.env,
169 + distribution: "uvx",
170 + manualInstall: false
171 + };
172 + }
173 +
174 + if (platform && typeof dist.binary === "object" && dist.binary !== null) {
175 + const target = (dist.binary as Record<string, unknown>)[platform];
176 + if (typeof target === "object" && target !== null) {
177 + const t = target as { archive?: unknown; cmd?: unknown; args?: unknown; env?: unknown };
178 + if (typeof t.cmd === "string" && t.cmd.trim() !== "") {
179 + const archive = optionalString(t.archive);
180 + return {
181 + command: commandBasename(t.cmd),
182 + args: isStringArray(t.args) ? t.args : [],
183 + env: isStringRecord(t.env) ? t.env : {},
184 + distribution: "binary",
185 + manualInstall: true,
186 + install: archive
187 + ? `Manual install required: download ${archive} and ensure "${commandBasename(
188 + t.cmd
189 + )}" is on your PATH.`
190 + : undefined
191 + };
192 + }
193 + }
194 + }
195 +
196 + return null;
197 +}
198 +
199 +/**
200 + * Validate and resolve a single raw registry entry. Returns a normalized
201 + * {@link RegistryAgent}, or `null` when required fields are missing or no
202 + * distribution is usable on the given platform. Invalid entries are skipped
203 + * rather than failing the whole catalog.
204 + */
205 +export function validateEntry(raw: unknown, platform = currentPlatformKey()): RegistryAgent | null {
206 + if (typeof raw !== "object" || raw === null) {
207 + return null;
208 + }
209 + const entry = raw as { id?: unknown; name?: unknown; distribution?: unknown } & Record<
210 + string,
211 + unknown
212 + >;
213 + if (typeof entry.id !== "string" || !ID_PATTERN.test(entry.id)) {
214 + return null;
215 + }
216 + if (typeof entry.name !== "string" || entry.name.trim() === "") {
217 + return null;
218 + }
219 + const launch = resolveDistribution(entry.distribution, platform);
220 + if (!launch) {
221 + return null;
222 + }
223 +
224 + return {
225 + key: entry.id,
226 + name: entry.name,
227 + command: launch.command,
228 + args: launch.args,
229 + env: launch.env,
230 + description: optionalString(entry.description),
231 + version: optionalString(entry.version),
232 + website: optionalString(entry.website),
233 + repository: optionalString(entry.repository),
234 + distribution: launch.distribution,
235 + manualInstall: launch.manualInstall,
236 + install: launch.install
237 + };
238 +}
239 +
240 +/**
241 + * Parse the raw registry document text into a {@link Catalog}. Throws when the
242 + * document is not valid JSON, has the wrong top-level shape, or declares an
243 + * unsupported major version; individual unresolvable entries are dropped (see
244 + * {@link validateEntry}).
245 + */
246 +export function parseCatalog(text: string, platform = currentPlatformKey()): Catalog {
247 + let doc: unknown;
248 + try {
249 + doc = JSON.parse(text);
250 + } catch (err) {
251 + throw new Error(`Registry is not valid JSON: ${(err as Error).message}`);
252 + }
253 + if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
254 + throw new Error("Registry must be a JSON object with an `agents` array.");
255 + }
256 + const obj = doc as { version?: unknown; agents?: unknown };
257 + if (!Array.isArray(obj.agents)) {
258 + throw new Error("Registry is missing the `agents` array.");
259 + }
260 + const version = typeof obj.version === "string" ? obj.version : "1.0.0";
261 + if (!/^1\b|^1\./.test(version)) {
262 + throw new Error(`Unsupported registry version "${version}"; expected 1.x.`);
263 + }
264 +
265 + const seen = new Set<string>();
266 + const agents: RegistryAgent[] = [];
267 + for (const raw of obj.agents) {
268 + const agent = validateEntry(raw, platform);
269 + if (agent && !seen.has(agent.key)) {
270 + seen.add(agent.key);
271 + agents.push(agent);
272 + }
273 + }
274 + return { version, agents };
275 +}
src/chatView.ts
+22 -9
@@ -69,26 +69,39 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
69 this.postStatus(`Agent restarted: ${this.activeAgentKey}`);
70 }
71
72 - /** Pick an agent from the registry and switch to it. */
72 + /** Pick a configured agent — or browse the registry — and switch to it. */
73 async selectAgent(): Promise<void> {
74 + const browse = "$(cloud-download) Browse registry…";
75 const agents = listAgents();
76 const picked = await vscode.window.showQuickPick(
76 - agents.map((agent) => ({
77 - label: agent.name,
78 - description: agent.key,
79 - detail: `${agent.command} ${agent.args.join(" ")}`.trim(),
80 - agent
81 - })),
77 + [
78 + ...agents.map((agent) => ({
79 + label: agent.name,
80 + description: agent.key,
81 + detail: `${agent.command} ${agent.args.join(" ")}`.trim(),
82 + key: agent.key
83 + })),
84 + { label: browse, description: "", detail: "Discover and install ACP agents", key: undefined }
85 + ],
86 { placeHolder: "Select an ACP agent" }
87 );
88 if (!picked) {
89 return;
90 }
87 - this.activeAgentKey = picked.agent.key;
91 + if (picked.key === undefined) {
92 + await vscode.commands.executeCommand("sigit.browseRegistry");
93 + return;
94 + }
95 + await this.useAgent(picked.key);
96 + }
97 +
98 + /** Switch the active agent to `key`, restarting the session. */
99 + async useAgent(key: string): Promise<void> {
100 + this.activeAgentKey = key;
101 this.disposeClient();
102 this.post({ type: "clear" });
103 await this.ensureClient();
91 - this.postStatus(`Agent: ${picked.agent.name}`);
104 + this.postStatus(`Agent: ${resolveAgent(key).name}`);
105 }
106
107 private async handlePrompt(text: string): Promise<void> {
src/extension.ts
+110 -1
@@ -1,5 +1,7 @@
1 import * as vscode from "vscode";
2 import { ChatViewProvider } from "./chatView";
3 +import { RegistryAgent } from "./catalog";
4 +import { fetchCatalog, installAgent, installedKeys, setDefaultAgent } from "./registry";
5
6 export function activate(context: vscode.ExtensionContext): void {
7 const provider = new ChatViewProvider(context);
@@ -17,10 +19,117 @@ export function activate(context: vscode.ExtensionContext): void {
19 }),
20 vscode.commands.registerCommand("sigit.newSession", () => provider.newSession()),
21 vscode.commands.registerCommand("sigit.selectAgent", () => provider.selectAgent()),
20 - vscode.commands.registerCommand("sigit.restartAgent", () => provider.restart())
22 + vscode.commands.registerCommand("sigit.restartAgent", () => provider.restart()),
23 + vscode.commands.registerCommand("sigit.browseRegistry", () => browseRegistry(context, provider)),
24 + vscode.commands.registerCommand("sigit.refreshRegistry", () => refreshRegistry(context))
25 );
26 }
27
28 export function deactivate(): void {
29 // Resources are tied to context.subscriptions and disposed automatically.
30 }
31 +
32 +/** Fetch the catalog, let the user pick an agent, and install it. */
33 +async function browseRegistry(
34 + context: vscode.ExtensionContext,
35 + provider: ChatViewProvider
36 +): Promise<void> {
37 + let agents: RegistryAgent[];
38 + try {
39 + const result = await vscode.window.withProgress(
40 + { location: vscode.ProgressLocation.Notification, title: "Fetching ACP registry…" },
41 + () => fetchCatalog(context)
42 + );
43 + agents = result.agents;
44 + if (result.fromCache) {
45 + void vscode.window.showWarningMessage(
46 + `Showing cached registry — fetch failed: ${result.error}`
47 + );
48 + }
49 + } catch (err) {
50 + void vscode.window.showErrorMessage(`Could not load the ACP registry: ${(err as Error).message}`);
51 + return;
52 + }
53 +
54 + if (agents.length === 0) {
55 + void vscode.window.showInformationMessage("The ACP registry is empty.");
56 + return;
57 + }
58 +
59 + const installed = installedKeys();
60 + const tag = (agent: RegistryAgent): string => {
61 + const parts: string[] = [agent.distribution];
62 + if (agent.version) {
63 + parts.unshift(`v${agent.version}`);
64 + }
65 + if (agent.manualInstall) {
66 + parts.push("manual install");
67 + }
68 + if (installed.has(agent.key)) {
69 + parts.push("installed");
70 + }
71 + return parts.join(" · ");
72 + };
73 + const picked = await vscode.window.showQuickPick(
74 + agents.map((agent) => ({
75 + label: installed.has(agent.key) ? `$(check) ${agent.name}` : agent.name,
76 + description: `${agent.key} · ${tag(agent)}`,
77 + detail: agent.description ?? `${agent.command} ${agent.args.join(" ")}`.trim(),
78 + agent
79 + })),
80 + { placeHolder: "Select an ACP agent to install", matchOnDescription: true, matchOnDetail: true }
81 + );
82 + if (!picked) {
83 + return;
84 + }
85 +
86 + const agent = picked.agent;
87 + if (!installed.has(agent.key)) {
88 + const added = await installAgent(agent);
89 + if (added && agent.manualInstall && agent.install) {
90 + void vscode.window.showWarningMessage(`Installed "${agent.name}". ${agent.install}`);
91 + } else if (added) {
92 + void vscode.window.showInformationMessage(
93 + `Installed "${agent.name}" — launches via ${agent.distribution}.`
94 + );
95 + }
96 + }
97 +
98 + await offerToUse(agent, provider);
99 +}
100 +
101 +/** After install, offer to switch to the agent and/or make it the default. */
102 +async function offerToUse(agent: RegistryAgent, provider: ChatViewProvider): Promise<void> {
103 + const useNow = "Use now";
104 + const setDefault = "Set as default";
105 + const choice = await vscode.window.showInformationMessage(
106 + `Use "${agent.name}"?`,
107 + useNow,
108 + setDefault
109 + );
110 + if (choice === setDefault) {
111 + await setDefaultAgent(agent.key);
112 + }
113 + if (choice === useNow || choice === setDefault) {
114 + await provider.useAgent(agent.key);
115 + }
116 +}
117 +
118 +/** Force a fresh fetch of the registry and report how many agents are available. */
119 +async function refreshRegistry(context: vscode.ExtensionContext): Promise<void> {
120 + try {
121 + const result = await vscode.window.withProgress(
122 + { location: vscode.ProgressLocation.Notification, title: "Refreshing ACP registry…" },
123 + () => fetchCatalog(context)
124 + );
125 + if (result.fromCache) {
126 + void vscode.window.showWarningMessage(`Registry refresh failed: ${result.error}`);
127 + } else {
128 + void vscode.window.showInformationMessage(
129 + `ACP registry refreshed — ${result.agents.length} agent(s) available.`
130 + );
131 + }
132 + } catch (err) {
133 + void vscode.window.showErrorMessage(`Could not refresh the ACP registry: ${(err as Error).message}`);
134 + }
135 +}
src/registry.ts new
+114
@@ -0,0 +1,114 @@
1 +import * as vscode from "vscode";
2 +import { Catalog, RegistryAgent, parseCatalog } from "./catalog";
3 +
4 +/**
5 + * The remote ACP agent registry: fetches a curated catalog of ACP-compatible
6 + * agents, caches the last good copy for offline use, and installs a chosen
7 + * agent by writing its definition into the user's `sigit.agents` configuration.
8 + *
9 + * "Install" here means *register* — siGit never downloads or runs an agent
10 + * binary on the user's behalf. Each catalog entry may carry a human-readable
11 + * `install` hint that we surface but never execute.
12 + */
13 +
14 +const DEFAULT_REGISTRY_URL =
15 + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
16 +const CACHE_KEY = "sigit.registry.cache";
17 +const FETCH_TIMEOUT_MS = 10_000;
18 +
19 +interface CacheEntry {
20 + url: string;
21 + fetchedAt: number;
22 + agents: RegistryAgent[];
23 +}
24 +
25 +export interface FetchResult {
26 + agents: RegistryAgent[];
27 + /** True when the network fetch failed and cached data was served instead. */
28 + fromCache: boolean;
29 + /** The network error, present only when `fromCache` is true. */
30 + error?: string;
31 +}
32 +
33 +function config(): vscode.WorkspaceConfiguration {
34 + return vscode.workspace.getConfiguration("sigit");
35 +}
36 +
37 +/** The configured registry URL, or the built-in siGit catalog. */
38 +export function registryUrl(): string {
39 + const configured = config().get<string>("registry.url");
40 + return configured && configured.trim() !== "" ? configured.trim() : DEFAULT_REGISTRY_URL;
41 +}
42 +
43 +/** Keys of agents already present in the `sigit.agents` configuration. */
44 +export function installedKeys(): Set<string> {
45 + const registry = config().get<Record<string, unknown>>("agents") ?? {};
46 + return new Set(Object.keys(registry));
47 +}
48 +
49 +async function fetchFromNetwork(url: string): Promise<Catalog> {
50 + const controller = new AbortController();
51 + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
52 + try {
53 + const response = await fetch(url, {
54 + signal: controller.signal,
55 + headers: { accept: "application/json" }
56 + });
57 + if (!response.ok) {
58 + throw new Error(`HTTP ${response.status} ${response.statusText}`);
59 + }
60 + return parseCatalog(await response.text());
61 + } finally {
62 + clearTimeout(timer);
63 + }
64 +}
65 +
66 +/**
67 + * Fetch the agent catalog. On success the result is cached in global state. On
68 + * failure the last cached catalog (for the same URL) is served when available,
69 + * with `fromCache: true`; otherwise the error is re-thrown.
70 + */
71 +export async function fetchCatalog(context: vscode.ExtensionContext): Promise<FetchResult> {
72 + const url = registryUrl();
73 + try {
74 + const catalog = await fetchFromNetwork(url);
75 + const entry: CacheEntry = { url, fetchedAt: Date.now(), agents: catalog.agents };
76 + await context.globalState.update(CACHE_KEY, entry);
77 + return { agents: catalog.agents, fromCache: false };
78 + } catch (err) {
79 + const cached = context.globalState.get<CacheEntry>(CACHE_KEY);
80 + if (cached && cached.url === url) {
81 + return {
82 + agents: cached.agents,
83 + fromCache: true,
84 + error: (err as Error).message
85 + };
86 + }
87 + throw err;
88 + }
89 +}
90 +
91 +/**
92 + * Register a catalog agent into the user's `sigit.agents` configuration. Returns
93 + * `false` without writing when an agent with the same key is already installed.
94 + */
95 +export async function installAgent(agent: RegistryAgent): Promise<boolean> {
96 + const cfg = config();
97 + const registry = { ...(cfg.get<Record<string, unknown>>("agents") ?? {}) };
98 + if (Object.prototype.hasOwnProperty.call(registry, agent.key)) {
99 + return false;
100 + }
101 + registry[agent.key] = {
102 + name: agent.name,
103 + command: agent.command,
104 + args: agent.args,
105 + env: agent.env
106 + };
107 + await cfg.update("agents", registry, vscode.ConfigurationTarget.Global);
108 + return true;
109 +}
110 +
111 +/** Make `key` the default agent (`sigit.agent.default`), user-wide. */
112 +export async function setDefaultAgent(key: string): Promise<void> {
113 + await config().update("agent.default", key, vscode.ConfigurationTarget.Global);
114 +}
test/registry.mjs new
+185
@@ -0,0 +1,185 @@
1 +#!/usr/bin/env node
2 +// Unit tests for the ACP registry catalog parser.
3 +//
4 +// Bundles the pure `src/catalog.ts` module (no `vscode` dependency) and
5 +// exercises parseCatalog/validateEntry against the official registry schema
6 +// (https://github.com/agentclientprotocol/registry), with a fixed platform so
7 +// binary-distribution resolution is deterministic.
8 +//
9 +// Run: node test/registry.mjs
10 +
11 +import { fileURLToPath } from "node:url";
12 +import { dirname, join } from "node:path";
13 +import { build } from "esbuild";
14 +import { createRequire } from "node:module";
15 +
16 +const __dirname = dirname(fileURLToPath(import.meta.url));
17 +const repoRoot = join(__dirname, "..");
18 +
19 +const bundle = await build({
20 + entryPoints: [join(repoRoot, "src", "catalog.ts")],
21 + bundle: true,
22 + write: false,
23 + format: "cjs",
24 + platform: "node",
25 + target: "node18",
26 + logLevel: "silent"
27 +});
28 +
29 +const requireFromTest = createRequire(import.meta.url);
30 +const Module = requireFromTest("node:module");
31 +const compiled = eval(Module.wrap(bundle.outputFiles[0].text));
32 +const moduleObj = { exports: {} };
33 +compiled(moduleObj.exports, requireFromTest, moduleObj, "catalog.bundle.js", repoRoot);
34 +const { parseCatalog, validateEntry } = moduleObj.exports;
35 +
36 +const MAC = "darwin-aarch64";
37 +
38 +let failures = 0;
39 +function check(cond, msg) {
40 + if (cond) {
41 + console.log(`OK ${msg}`);
42 + } else {
43 + console.error(`FAIL ${msg}`);
44 + failures++;
45 + }
46 +}
47 +function throws(fn, msg) {
48 + try {
49 + fn();
50 + console.error(`FAIL ${msg} (expected throw)`);
51 + failures++;
52 + } catch {
53 + console.log(`OK ${msg}`);
54 + }
55 +}
56 +const parse = (doc, platform = MAC) => parseCatalog(JSON.stringify(doc), platform);
57 +
58 +// npx distribution → `npx -y <package> [args]`.
59 +const npx = parse({
60 + version: "1.0.0",
61 + agents: [
62 + {
63 + id: "claude-acp",
64 + name: "Claude Agent",
65 + version: "0.51.0",
66 + description: "ACP wrapper for Anthropic's Claude",
67 + distribution: { npx: { package: "@agentclientprotocol/claude-agent-acp@0.51.0" } }
68 + }
69 + ]
70 +}).agents[0];
71 +check(npx.command === "npx", "npx → command is npx");
72 +check(
73 + JSON.stringify(npx.args) ===
74 + JSON.stringify(["-y", "@agentclientprotocol/claude-agent-acp@0.51.0"]),
75 + "npx → args use -y + package"
76 +);
77 +check(npx.distribution === "npx" && npx.manualInstall === false, "npx → not manual install");
78 +check(npx.key === "claude-acp" && npx.version === "0.51.0", "carries id and version");
79 +
80 +// npx with extra args.
81 +const npxArgs = parse({
82 + agents: [
83 + { id: "a", name: "A", distribution: { npx: { package: "pkg@1", args: ["--acp"] } } }
84 + ]
85 +}).agents[0];
86 +check(
87 + JSON.stringify(npxArgs.args) === JSON.stringify(["-y", "pkg@1", "--acp"]),
88 + "npx → appends distribution args"
89 +);
90 +
91 +// uvx distribution → `uvx <package> [args]`.
92 +const uvx = parse({
93 + agents: [
94 + { id: "fast", name: "Fast", distribution: { uvx: { package: "fast-agent-acp==0.7.22", args: ["-x"] } } }
95 + ]
96 +}).agents[0];
97 +check(
98 + uvx.command === "uvx" &&
99 + JSON.stringify(uvx.args) === JSON.stringify(["fast-agent-acp==0.7.22", "-x"]),
100 + "uvx → command + package + args"
101 +);
102 +
103 +// binary distribution for the current platform → basename cmd + manual install.
104 +const binDoc = {
105 + agents: [
106 + {
107 + id: "amp-acp",
108 + name: "Amp",
109 + distribution: {
110 + binary: {
111 + "darwin-aarch64": {
112 + archive: "https://example.com/amp-darwin-aarch64.tar.gz",
113 + cmd: "./amp-acp"
114 + }
115 + }
116 + }
117 + }
118 + ]
119 +};
120 +const bin = parse(binDoc).agents[0];
121 +check(bin.command === "amp-acp", "binary → command is cmd basename (./amp-acp → amp-acp)");
122 +check(bin.distribution === "binary" && bin.manualInstall === true, "binary → manual install");
123 +check(typeof bin.install === "string" && bin.install.includes("example.com"), "binary → install hint with archive URL");
124 +
125 +// binary with no target for this platform → dropped (unrunnable here).
126 +check(parse(binDoc, "linux-x86_64").agents.length === 0, "binary → dropped when no platform target");
127 +
128 +// binary + npx prefers npx (zero manual steps).
129 +const both = parse({
130 + agents: [
131 + {
132 + id: "dual",
133 + name: "Dual",
134 + distribution: {
135 + npx: { package: "dual@1" },
136 + binary: { "darwin-aarch64": { archive: "https://x/y.tgz", cmd: "./dual" } }
137 + }
138 + }
139 + ]
140 +}).agents[0];
141 +check(both.command === "npx" && both.distribution === "npx", "binary+npx → prefers npx");
142 +
143 +// Invalid / unusable entries are dropped, not fatal.
144 +const filtered = parse({
145 + agents: [
146 + { name: "no id", distribution: { npx: { package: "x" } } },
147 + { id: "Bad-Id", name: "bad", distribution: { npx: { package: "x" } } },
148 + { id: "nodist", name: "No dist" },
149 + { id: "emptydist", name: "Empty", distribution: {} },
150 + { id: "good", name: "Good", distribution: { npx: { package: "good@1" } } }
151 + ]
152 +});
153 +check(
154 + filtered.agents.length === 1 && filtered.agents[0].key === "good",
155 + "drops entries missing id/name/usable distribution"
156 +);
157 +
158 +// Duplicate ids: first wins.
159 +const deduped = parse({
160 + agents: [
161 + { id: "dup", name: "First", distribution: { npx: { package: "first@1" } } },
162 + { id: "dup", name: "Second", distribution: { npx: { package: "second@1" } } }
163 + ]
164 +});
165 +check(deduped.agents.length === 1 && deduped.agents[0].name === "First", "dedupes by id (first wins)");
166 +
167 +// validateEntry on its own.
168 +check(
169 + validateEntry({ id: "k", name: "K", distribution: { npx: { package: "p" } } }, MAC) !== null,
170 + "validateEntry accepts a valid npx entry"
171 +);
172 +check(validateEntry({ id: "k", name: "K", distribution: {} }, MAC) === null, "validateEntry rejects empty distribution");
173 +check(validateEntry(null, MAC) === null, "validateEntry rejects null");
174 +
175 +// Structural failures throw.
176 +throws(() => parseCatalog("not json", MAC), "throws on invalid JSON");
177 +throws(() => parseCatalog("[]", MAC), "throws on non-object top level");
178 +throws(() => parseCatalog(JSON.stringify({ version: "1.0.0" }), MAC), "throws when agents array missing");
179 +throws(() => parseCatalog(JSON.stringify({ version: "2.0.0", agents: [] }), MAC), "throws on unsupported major version");
180 +
181 +if (failures > 0) {
182 + console.error(`\n${failures} CHECK(S) FAILED`);
183 + process.exit(1);
184 +}
185 +console.log("\nALL REGISTRY CHECKS PASSED");