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
+}