1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { Command, Flag } from "effect/cli";
13import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15
16const V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.3";
17const V1_SCHEMA_RELEASE = "schema-v1.21.0";
18
19interface GenerateCommandError {
20 readonly _tag: "GenerateCommandError";
21 readonly message: string;
22}
23
24interface GeneratedPaths {
25 readonly generatedDir: string;
26 readonly upstreamSchemaPath: string;
27 readonly upstreamMetaPath: string;
28 readonly upstreamV1SchemaPath: string;
29 readonly upstreamV1MetaPath: string;
30 readonly schemaOutputPath: string;
31 readonly v1SchemaOutputPath: string;
32 readonly metaOutputPath: string;
33}
34
35const UpstreamJsonSchemaSchema = Schema.Struct({
36 $defs: Schema.Record(Schema.String, Schema.Json),
37});
38const MetaJsonSchema = Schema.Struct({
39 agentMethods: Schema.Record(Schema.String, Schema.String),
40 clientMethods: Schema.Record(Schema.String, Schema.String),
41 version: Schema.Union([Schema.Number, Schema.String]),
42});
43const encodeAgentMethods = Schema.encodeEffect(
44 Schema.fromJsonString(MetaJsonSchema.fields.agentMethods),
45);
46const encodeClientMethods = Schema.encodeEffect(
47 Schema.fromJsonString(MetaJsonSchema.fields.clientMethods),
48);
49const encodeVersion = Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.version));
50
51const decodeUpstreamSchema = Schema.decodeEffect(Schema.fromJsonString(UpstreamJsonSchemaSchema));
52const decodeMetaJson = Schema.decodeEffect(Schema.fromJsonString(MetaJsonSchema));
53
54const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () {
55 const path = yield* Path.Path;
56 const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated");
57 return {
58 generatedDir,
59 upstreamSchemaPath: path.join(generatedDir, "upstream-schema.json"),
60 upstreamMetaPath: path.join(generatedDir, "upstream-meta.json"),
61 upstreamV1SchemaPath: path.join(generatedDir, "upstream-schema-v1.json"),
62 upstreamV1MetaPath: path.join(generatedDir, "upstream-meta-v1.json"),
63 schemaOutputPath: path.join(generatedDir, "schema.gen.ts"),
64 v1SchemaOutputPath: path.join(generatedDir, "schema-v1.gen.ts"),
65 metaOutputPath: path.join(generatedDir, "meta.gen.ts"),
66 } satisfies GeneratedPaths;
67});
68
69const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () {
70 const fs = yield* FileSystem.FileSystem;
71 const { generatedDir } = yield* getGeneratedPaths();
72
73 yield* fs.makeDirectory(generatedDir, { recursive: true });
74});
75
76const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79
80 yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
81
82 const text = yield* HttpClient.get(url).pipe(
83 Effect.flatMap(HttpClientResponse.filterStatusOk),
84 Effect.flatMap((response) => response.text),
85 );
86
87 yield* fs.writeFileString(outputPath, text);
88});
89
90const downloadSchemas = Effect.fn("downloadSchemas")(function* (
91 tag: string,
92 paths: { readonly schema: string; readonly meta: string },
93) {
94 const fs = yield* FileSystem.FileSystem;
95 const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`;
96
97 yield* Effect.all(
98 [
99 downloadFile(`${baseUrl}/schema.unstable.json`, paths.schema),
100 downloadFile(`${baseUrl}/meta.unstable.json`, paths.meta),
101 ],
102 { concurrency: 2 },
103 );
104
105 yield* Effect.addFinalizer(() =>
106 Effect.all([fs.remove(paths.schema), fs.remove(paths.meta)]).pipe(
107 Effect.ignoreCause({ log: true }),
108 ),
109 );
110});
111
112const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) {
113 const fs = yield* FileSystem.FileSystem;
114 return yield* fs.readFileString(filePath);
115});
116
117const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* (
118 schemaOutput: string,
119 v1SchemaOutput: string,
120 metaOutput: string,
121) {
122 const fs = yield* FileSystem.FileSystem;
123 const { metaOutputPath, schemaOutputPath, v1SchemaOutputPath } = yield* getGeneratedPaths();
124
125 yield* fs.writeFileString(schemaOutputPath, schemaOutput);
126 yield* fs.writeFileString(v1SchemaOutputPath, v1SchemaOutput);
127 yield* fs.writeFileString(metaOutputPath, metaOutput);
128});
129
130function collectSchemaEntries(
131 chunk: string,
132): ReadonlyArray<{ readonly name: string; readonly code: string }> {
133 const lines = chunk
134 .split("\n")
135 .map((line) => line.trim())
136 .filter((line) => line.length > 0 && !line.startsWith("//"));
137 const entries: Array<{ name: string; code: string }> = [];
138
139 for (let index = 0; index < lines.length; index += 1) {
140 const typeLine = lines[index];
141 if (!typeLine?.startsWith("export type ")) {
142 continue;
143 }
144
145 const constLine = lines[index + 1];
146 if (!constLine?.startsWith("export const ")) {
147 throw new Error(`Malformed generator output near: ${typeLine}`);
148 }
149
150 const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine);
151 if (!match?.[1]) {
152 throw new Error(`Could not extract schema name from: ${typeLine}`);
153 }
154
155 entries.push({
156 name: match[1],
157 code: `${typeLine}\n${constLine}`,
158 });
159 index += 1;
160 }
161
162 return entries;
163}
164
165function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray<Schema.Json> {
166 return Array.isArray(value);
167}
168
169function normalizeSchemaKeywords(value: Schema.Json): Schema.Json {
170 if (isJsonArray(value)) {
171 return value.map(normalizeSchemaKeywords);
172 }
173 if (value === null || typeof value !== "object") {
174 return value;
175 }
176
177 const normalizedEntries = Object.entries(value).map(([key, child]) => [
178 key,
179 normalizeSchemaKeywords(child),
180 ]);
181 const normalizedObject = Object.fromEntries(normalizedEntries) as Record<string, Schema.Json>;
182 // ACP's forward-compatible variants exclude known discriminator values.
183 // The Effect generator does not support `not`; express this exact exclusion
184 // as a string pattern while preserving validation of the known variants.
185 if (normalizedObject.not !== undefined) {
186 const exclusion = normalizedObject.not;
187 if (
188 exclusion === null ||
189 typeof exclusion !== "object" ||
190 isJsonArray(exclusion) ||
191 !isJsonArray(exclusion.anyOf)
192 ) {
193 throw new Error("Unsupported ACP schema exclusion");
194 }
195 const excluded = exclusion.anyOf.map((variant) => {
196 if (variant === null || typeof variant !== "object" || isJsonArray(variant))
197 throw new Error("Invalid ACP excluded variant");
198 const properties = variant.properties;
199 if (properties === null || typeof properties !== "object" || isJsonArray(properties))
200 throw new Error("Invalid ACP excluded properties");
201 const entries = Object.entries(properties);
202 const entry = entries[0];
203 if (entries.length !== 1 || entry === undefined) throw new Error("Invalid ACP discriminator");
204 const [key, schema] = entry;
205 if (
206 schema === null ||
207 typeof schema !== "object" ||
208 isJsonArray(schema) ||
209 typeof schema.const !== "string"
210 )
211 throw new Error("Invalid ACP discriminator value");
212 return { key, value: schema.const };
213 });
214 const discriminator = excluded[0]?.key;
215 const properties = normalizedObject.properties;
216 if (
217 discriminator === undefined ||
218 excluded.some((entry) => entry.key !== discriminator) ||
219 properties === null ||
220 typeof properties !== "object" ||
221 isJsonArray(properties)
222 )
223 throw new Error("Unsupported ACP discriminator exclusion");
224 const property = properties[discriminator];
225 if (property === null || typeof property !== "object" || isJsonArray(property))
226 throw new Error("Missing ACP discriminator property");
227 normalizedObject.properties = {
228 ...properties,
229 [discriminator]: {
230 ...property,
231 pattern: `^(?!(?:${excluded.map(({ value }) => RegExp.escape(value)).join("|")})$)[\\s\\S]*$`,
232 },
233 };
234 delete normalizedObject.not;
235 }
236 if (normalizedObject.unevaluatedProperties !== undefined) {
237 if (normalizedObject.unevaluatedProperties !== true)
238 throw new Error("Unsupported ACP unevaluated properties constraint");
239 normalizedObject.additionalProperties = true;
240 delete normalizedObject.unevaluatedProperties;
241 }
242 if (isJsonArray(normalizedObject.oneOf)) {
243 const discriminator = normalizedObject.discriminator;
244 const propertyName =
245 discriminator !== null && typeof discriminator === "object" && !isJsonArray(discriminator)
246 ? discriminator.propertyName
247 : undefined;
248 const values = normalizedObject.oneOf.map((entry) => {
249 if (entry === null || typeof entry !== "object" || isJsonArray(entry)) return undefined;
250 if (typeof entry.const === "string") return entry.const;
251 const properties = entry.properties;
252 if (
253 typeof propertyName !== "string" ||
254 properties === null ||
255 typeof properties !== "object" ||
256 isJsonArray(properties)
257 )
258 return undefined;
259 const property = properties[propertyName];
260 return property !== null && typeof property === "object" && !isJsonArray(property)
261 ? property.const
262 : undefined;
263 });
264 if (
265 values.every((entry) => typeof entry === "string") &&
266 new Set(values).size === values.length
267 ) {
268 normalizedObject.anyOf = normalizedObject.oneOf;
269 delete normalizedObject.oneOf;
270 }
271 }
272 const typeValue = normalizedObject.type;
273
274 if (!isJsonArray(typeValue)) {
275 return normalizedObject;
276 }
277
278 const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string");
279 if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) {
280 return normalizedObject;
281 }
282
283 const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null");
284 if (nonNullTypes.length !== 1) {
285 return normalizedObject;
286 }
287 const nonNullType = nonNullTypes[0]!;
288
289 const nextObject: Record<string, Schema.Json> = {};
290 for (const [key, child] of Object.entries(normalizedObject)) {
291 if (key !== "type") {
292 nextObject[key] = child;
293 }
294 }
295
296 return {
297 anyOf: [
298 {
299 ...nextObject,
300 type: nonNullType,
301 },
302 { type: "null" },
303 ],
304 };
305}
306
307const isAnnotationKeyword = (key: string): boolean =>
308 ["description", "title", "$comment", "default", "examples", "discriminator"].includes(key) ||
309 key.startsWith("x-");
310
311// Distribute object intersections over unions before handing schemas to Effect.
312// ACP uses sibling `properties`/`anyOf` and `allOf` references for elicitation scopes.
313function flattenCompositions(
314 value: Schema.Json,
315 definitions: Readonly<Record<string, Schema.Json>>,
316): Schema.Json {
317 if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions));
318 if (value === null || typeof value !== "object") return value;
319 const normalized = Object.fromEntries(
320 Object.entries(value).map(([key, child]) => [key, flattenCompositions(child, definitions)]),
321 );
322 const { allOf, anyOf, ...base } = normalized;
323 const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => {
324 if (
325 left === null ||
326 typeof left !== "object" ||
327 isJsonArray(left) ||
328 right === null ||
329 typeof right !== "object" ||
330 isJsonArray(right)
331 )
332 throw new Error("Unsupported ACP schema intersection");
333 if (typeof right.$ref === "string") {
334 // Keep references when the sibling only describes the referenced schema.
335 // Inlining these repeats entire protocol unions in every request schema.
336 if (Object.keys(left).every(isAnnotationKeyword)) return { ...right, ...left };
337 const target = definitions[right.$ref.replace("#/$defs/", "")];
338 if (target === undefined) throw new Error(`Missing ACP definition ${right.$ref}`);
339 return merge(left, flattenCompositions(normalizeSchemaKeywords(target), definitions));
340 }
341 if (isJsonArray(left.anyOf)) return { anyOf: left.anyOf.map((branch) => merge(branch, right)) };
342 if (isJsonArray(right.anyOf))
343 return { anyOf: right.anyOf.map((branch) => merge(left, branch)) };
344 for (const key of Object.keys(left)) {
345 if (key === "properties" || key === "required" || isAnnotationKeyword(key)) continue;
346 if (right[key] !== undefined && JSON.stringify(left[key]) !== JSON.stringify(right[key])) {
347 throw new Error(`Unsupported conflicting ACP schema constraint: ${key}`);
348 }
349 }
350 const result = { ...left, ...right };
351 if (
352 left.properties &&
353 right.properties &&
354 typeof left.properties === "object" &&
355 !isJsonArray(left.properties) &&
356 typeof right.properties === "object" &&
357 !isJsonArray(right.properties)
358 ) {
359 for (const key of Object.keys(left.properties)) {
360 if (
361 right.properties[key] !== undefined &&
362 JSON.stringify(left.properties[key]) !== JSON.stringify(right.properties[key])
363 ) {
364 throw new Error(`Unsupported overlapping ACP schema property: ${key}`);
365 }
366 }
367 result.properties = { ...left.properties, ...right.properties };
368 }
369 if (isJsonArray(left.required) || isJsonArray(right.required)) {
370 result.required = [
371 ...new Set([
372 ...(isJsonArray(left.required) ? left.required : []),
373 ...(isJsonArray(right.required) ? right.required : []),
374 ]),
375 ];
376 }
377 return result;
378 };
379 let combined: Schema.Json = base;
380 if (isJsonArray(allOf)) for (const part of allOf) combined = merge(combined, part);
381 if (isJsonArray(anyOf) && !isJsonArray(allOf) && Object.keys(base).every(isAnnotationKeyword)) {
382 return { ...base, anyOf };
383 }
384 return isJsonArray(anyOf) ? merge(combined, { anyOf }) : combined;
385}
386
387function generateSchemaOutput(
388 upstreamSchema: { readonly $defs: Readonly<Record<string, Schema.Json>> },
389 release: string,
390): { readonly output: string; readonly count: number } {
391 const normalizedDefinitions = Object.fromEntries(
392 Object.entries(upstreamSchema.$defs).map(([name, schema]) => [
393 name,
394 flattenCompositions(normalizeSchemaKeywords(schema), upstreamSchema.$defs),
395 ]),
396 );
397 const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) =>
398 left.localeCompare(right),
399 );
400 const generatedEntries = new Map<string, string>();
401 const generator = makeJsonSchemaGenerator();
402
403 for (const [name, schema] of sortedEntries) {
404 generator.addSchema(name, schema as never);
405 }
406
407 const generated = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim();
408 if (generated.length > 0) {
409 for (const entry of collectSchemaEntries(generated)) {
410 if (!generatedEntries.has(entry.name)) {
411 generatedEntries.set(entry.name, entry.code);
412 }
413 }
414 }
415
416 return {
417 output: [
418 "// This file is generated by the effect-acp package. Do not edit manually.",
419 `// Current ACP schema release: ${release}`,
420 "",
421 'import * as Schema from "effect/Schema";',
422 "",
423 [...generatedEntries.values()].join("\n\n"),
424 "",
425 ].join("\n"),
426 count: generatedEntries.size,
427 };
428}
429
430const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) {
431 const { upstreamMetaPath, upstreamSchemaPath, upstreamV1MetaPath, upstreamV1SchemaPath } =
432 yield* getGeneratedPaths();
433
434 yield* ensureGeneratedDir();
435
436 if (!skipDownload) {
437 yield* Effect.log(
438 `Downloading ACP schema assets for ${V2_SCHEMA_RELEASE} and ${V1_SCHEMA_RELEASE}`,
439 );
440 yield* Effect.all(
441 [
442 downloadSchemas(V2_SCHEMA_RELEASE, {
443 schema: upstreamSchemaPath,
444 meta: upstreamMetaPath,
445 }),
446 downloadSchemas(V1_SCHEMA_RELEASE, {
447 schema: upstreamV1SchemaPath,
448 meta: upstreamV1MetaPath,
449 }),
450 ],
451 { concurrency: 2 },
452 );
453 }
454
455 const upstreamSchema = yield* readFileString(upstreamSchemaPath).pipe(
456 Effect.flatMap(decodeUpstreamSchema),
457 );
458 const upstreamV1Schema = yield* readFileString(upstreamV1SchemaPath).pipe(
459 Effect.flatMap(decodeUpstreamSchema),
460 );
461 const upstreamMeta = yield* readFileString(upstreamMetaPath).pipe(Effect.flatMap(decodeMetaJson));
462 const v2Schema = generateSchemaOutput(upstreamSchema, V2_SCHEMA_RELEASE);
463 const v1Schema = generateSchemaOutput(upstreamV1Schema, V1_SCHEMA_RELEASE);
464
465 const prelude = [
466 `// This file is generated by the effect-acp package. Do not edit manually.`,
467 `// Current ACP schema release: ${V2_SCHEMA_RELEASE}`,
468 "",
469 ];
470
471 const metaOutput = [
472 ...prelude,
473 `export const AGENT_METHODS = ${yield* encodeAgentMethods(upstreamMeta.agentMethods)} as const;`,
474 "",
475 `export const CLIENT_METHODS = ${yield* encodeClientMethods(upstreamMeta.clientMethods)} as const;`,
476 "",
477 `export const PROTOCOL_VERSION = ${yield* encodeVersion(upstreamMeta.version)} as const;`,
478 "",
479 ].join("\n");
480
481 yield* writeGeneratedFiles(v2Schema.output, v1Schema.output, metaOutput);
482 yield* Effect.log(
483 `Generated ${v2Schema.count} ACP v2 schemas and ${v1Schema.count} ACP v1 compatibility schemas`,
484 );
485
486 const { generatedDir } = yield* getGeneratedPaths();
487 yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe(
488 Effect.flatMap((spawner) =>
489 spawner.spawn(
490 ChildProcess.make("pnpm", ["exec", "vp", "fmt", generatedDir], {
491 stdin: "ignore",
492 stdout: "inherit",
493 stderr: "inherit",
494 }),
495 ),
496 ),
497 Effect.flatMap((child) => child.exitCode),
498 Effect.tap((code) =>
499 code === 0
500 ? Effect.void
501 : Effect.fail<GenerateCommandError>({
502 _tag: "GenerateCommandError",
503 message: `oxfmt failed with exit code ${code}`,
504 }),
505 ),
506 );
507});
508
509const generateCommand = Command.make(
510 "generate",
511 {
512 skipDownload: Flag.Boolean("skip-download").pipe(Flag.withDefault(false)),
513 },
514 ({ skipDownload }) => generateSchemas(skipDownload),
515).pipe(Command.withDescription("Generate Effect ACP schemas from the pinned ACP release assets."));
516
517const layerRuntime = Layer.mergeAll(
518 Logger.layer([Logger.consolePretty()]),
519 NodeServices.layer,
520 FetchHttpClient.layer,
521);
522
523Command.run(generateCommand, { version: "0.0.0" }).pipe(
524 Effect.scoped,
525 Effect.provide(layerRuntime),
526 NodeRuntime.runMain,
527);