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 }