299 lines · 9.7 KB · TypeScript
RawDownload
#!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import { Command, Flag } from "effect/unstable/cli";import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const CURRENT_SCHEMA_RELEASE = "v0.11.3"; interface GenerateCommandError {  readonly _tag: "GenerateCommandError";  readonly message: string;} interface GeneratedPaths {  readonly generatedDir: string;  readonly upstreamSchemaPath: string;  readonly upstreamMetaPath: string;  readonly schemaOutputPath: string;  readonly metaOutputPath: string;} const UpstreamJsonSchemaSchema = Schema.Struct({  $defs: Schema.Record(Schema.String, Schema.Json),});const MetaJsonSchema = Schema.Struct({  agentMethods: Schema.Record(Schema.String, Schema.String),  clientMethods: Schema.Record(Schema.String, Schema.String),  version: Schema.Union([Schema.Number, Schema.String]),});const encodeAgentMethods = Schema.encodeEffect(  Schema.fromJsonString(MetaJsonSchema.fields.agentMethods),);const encodeClientMethods = Schema.encodeEffect(  Schema.fromJsonString(MetaJsonSchema.fields.clientMethods),);const encodeVersion = Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.version)); const decodeUpstreamSchema = Schema.decodeEffect(Schema.fromJsonString(UpstreamJsonSchemaSchema));const decodeMetaJson = Schema.decodeEffect(Schema.fromJsonString(MetaJsonSchema)); const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () {  const path = yield* Path.Path;  const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated");  return {    generatedDir,    upstreamSchemaPath: path.join(generatedDir, "upstream-schema.json"),    upstreamMetaPath: path.join(generatedDir, "upstream-meta.json"),    schemaOutputPath: path.join(generatedDir, "schema.gen.ts"),    metaOutputPath: path.join(generatedDir, "meta.gen.ts"),  } satisfies GeneratedPaths;}); const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () {  const fs = yield* FileSystem.FileSystem;  const { generatedDir } = yield* getGeneratedPaths();   yield* fs.makeDirectory(generatedDir, { recursive: true });}); const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) {  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });   const text = yield* HttpClient.get(url).pipe(    Effect.flatMap(HttpClientResponse.filterStatusOk),    Effect.flatMap((response) => response.text),  );   yield* fs.writeFileString(outputPath, text);}); const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) {  const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();  const fs = yield* FileSystem.FileSystem;  const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`;   yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath);  yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath);   yield* Effect.addFinalizer(() =>    Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe(      Effect.ignoreCause({ log: true }),    ),  );}); const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) {  const fs = yield* FileSystem.FileSystem;  return yield* fs.readFileString(filePath);}); const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* (  schemaOutput: string,  metaOutput: string,) {  const fs = yield* FileSystem.FileSystem;  const { metaOutputPath, schemaOutputPath } = yield* getGeneratedPaths();   yield* fs.writeFileString(schemaOutputPath, schemaOutput);  yield* fs.writeFileString(metaOutputPath, metaOutput);}); function collectSchemaEntries(  chunk: string,): ReadonlyArray<{ readonly name: string; readonly code: string }> {  const lines = chunk    .split("\n")    .map((line) => line.trim())    .filter((line) => line.length > 0 && !line.startsWith("//"));  const entries: Array<{ name: string; code: string }> = [];   for (let index = 0; index < lines.length; index += 1) {    const typeLine = lines[index];    if (!typeLine?.startsWith("export type ")) {      continue;    }     const constLine = lines[index + 1];    if (!constLine?.startsWith("export const ")) {      throw new Error(`Malformed generator output near: ${typeLine}`);    }     const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine);    if (!match?.[1]) {      throw new Error(`Could not extract schema name from: ${typeLine}`);    }     entries.push({      name: match[1],      code: `${typeLine}\n${constLine}`,    });    index += 1;  }   return entries;} function normalizeNullableTypes(value: Schema.Json): Schema.Json {  if (Array.isArray(value)) {    return value.map(normalizeNullableTypes);  }  if (value === null || typeof value !== "object") {    return value;  }   const normalizedEntries = Object.entries(value).map(([key, child]) => [    key,    normalizeNullableTypes(child),  ]);  const normalizedObject = Object.fromEntries(normalizedEntries) as Record<string, Schema.Json>;  const typeValue = normalizedObject.type;   if (!Array.isArray(typeValue)) {    return normalizedObject;  }   const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string");  if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) {    return normalizedObject;  }   const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null");  if (nonNullTypes.length !== 1) {    return normalizedObject;  }  const nonNullType = nonNullTypes[0]!;   const nextObject: Record<string, Schema.Json> = {};  for (const [key, child] of Object.entries(normalizedObject)) {    if (key !== "type") {      nextObject[key] = child;    }  }   return {    anyOf: [      {        ...nextObject,        type: nonNullType,      },      { type: "null" },    ],  };} const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) {  const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();   yield* ensureGeneratedDir();   if (!skipDownload) {    yield* Effect.log(`Downloading ACP schema assets for ${CURRENT_SCHEMA_RELEASE}`);    yield* downloadSchemas(CURRENT_SCHEMA_RELEASE);  }   const upstreamSchema = yield* readFileString(upstreamSchemaPath).pipe(    Effect.flatMap(decodeUpstreamSchema),  );  const upstreamMeta = yield* readFileString(upstreamMetaPath).pipe(Effect.flatMap(decodeMetaJson));  const normalizedDefinitions = Object.fromEntries(    Object.entries(upstreamSchema.$defs).map(([name, schema]) => [      name,      normalizeNullableTypes(schema),    ]),  );   const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) =>    left.localeCompare(right),  );  const generatedEntries = new Map<string, string>();  const generator = makeJsonSchemaGenerator();   for (const [name, schema] of sortedEntries) {    generator.addSchema(name, schema as never);  }   const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim();  if (output.length > 0) {    for (const entry of collectSchemaEntries(output)) {      if (!generatedEntries.has(entry.name)) {        generatedEntries.set(entry.name, entry.code);      }    }  }   const prelude = [    `// This file is generated by the effect-acp package. Do not edit manually.`,    `// Current ACP schema release: ${CURRENT_SCHEMA_RELEASE}`,    "",  ];   const schemaOutput = [    ...prelude,    'import * as Schema from "effect/Schema";',    "",    [...generatedEntries.values()].join("\n\n"),    "",  ].join("\n");   const metaOutput = [    ...prelude,    `export const AGENT_METHODS = ${yield* encodeAgentMethods(upstreamMeta.agentMethods)} as const;`,    "",    `export const CLIENT_METHODS = ${yield* encodeClientMethods(upstreamMeta.clientMethods)} as const;`,    "",    `export const PROTOCOL_VERSION = ${yield* encodeVersion(upstreamMeta.version)} as const;`,    "",  ].join("\n");   yield* writeGeneratedFiles(schemaOutput, metaOutput);  yield* Effect.log(    `Generated ${generatedEntries.size} ACP schemas from ${CURRENT_SCHEMA_RELEASE}`,  );   const { generatedDir } = yield* getGeneratedPaths();  yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe(    Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))),    Effect.flatMap((child) => child.exitCode),    Effect.tap((code) =>      code === 0        ? Effect.void        : Effect.fail<GenerateCommandError>({            _tag: "GenerateCommandError",            message: `oxfmt failed with exit code ${code}`,          }),    ),  );}); const generateCommand = Command.make(  "generate",  {    skipDownload: Flag.boolean("skip-download").pipe(Flag.withDefault(false)),  },  ({ skipDownload }) => generateSchemas(skipDownload),).pipe(Command.withDescription("Generate Effect ACP schemas from the pinned ACP release assets.")); const runtimeLayer = Layer.mergeAll(  Logger.layer([Logger.consolePretty()]),  NodeServices.layer,  FetchHttpClient.layer,); Command.run(generateCommand, { version: "0.0.0" }).pipe(  Effect.scoped,  Effect.provide(runtimeLayer),  NodeRuntime.runMain,);