import { createHash } from 'node:crypto' import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' const release = 'schema-v1.21.0' const legacyRelease = 'v0.11.6' const repository = 'https://github.com/agentclientprotocol/agent-client-protocol' const output = fileURLToPath(new URL('../../../src/main/acp/generated/', import.meta.url)) const outputFile = 'acp-protocol.generated.ts' // --check is offline (lint/CI): the header records every input digest and the body hash. // --check-online regenerates from the pinned downloads and compares byte-for-byte. const checkOnline = process.argv.includes('--check-online') const check = checkOnline || process.argv.includes('--check') const definitions = {} const inputs = { schema: '7f77702b34e0a0558e77220e9007bf8ee161a976bb8ac5021aba1b7e7b2c5708', legacy: 'b3cf8687d979c98c009f0fbcf8f0c237645b82ac2d2f0a3ebca683f963c3d581', license: 'f250d08cee4549b22b3b4aaaf3a743473336fd280316df5d0340717e5127a221' } const sha256 = (text) => createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex') const generatorDigest = sha256(await readFile(import.meta.filename, 'utf8')) const header = `// Generated by config/scripts/acp/generate-protocol.mjs; do not edit. Regenerate: pnpm run generate:acp-protocol\n// ACP ${release}, legacy model API ${legacyRelease}; SPDX-License-Identifier: Apache-2.0.\n// Inputs sha256: schema ${inputs.schema}, legacy ${inputs.legacy}, license ${inputs.license}, generator ${generatorDigest}\n` const bodyDigestPrefix = '// Body sha256: ' async function checkOffline() { const text = (await readFile(resolve(output, outputFile), 'utf8')).replace(/\r\n/g, '\n') if (!text.startsWith(header)) { throw new Error(`Stale generated file: ${outputFile} (inputs or generator changed; regenerate)`) } const rest = text.slice(header.length) const newline = rest.indexOf('\n') if (!rest.startsWith(bodyDigestPrefix) || newline === -1) { throw new Error(`Stale generated file: ${outputFile} (missing body digest)`) } if (rest.slice(bodyDigestPrefix.length, newline) !== sha256(rest.slice(newline + 1))) { throw new Error(`Generated file was edited by hand: ${outputFile}`) } for (const file of await readdir(output)) { if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { throw new Error(`Unexpected generated file: ${file}`) } } console.log(`Checked ${outputFile} against pinned inputs offline`) } if (check && !checkOnline) { await checkOffline() process.exit(0) } async function download(url, digest) { const response = await fetch(url) if (!response.ok) { throw new Error(`Download failed: ${url} (${response.status})`) } const text = await response.text() if (createHash('sha256').update(text).digest('hex') !== digest) { throw new Error(`Upstream content changed: ${url}`) } return text } const [schema, legacy, license] = await Promise.all([ download(`${repository}/releases/download/${release}/schema.unstable.json`, inputs.schema), download(`${repository}/releases/download/${legacyRelease}/schema.unstable.json`, inputs.legacy), download( `https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/${release}/LICENSE`, inputs.license ) ]) Object.assign(definitions, JSON.parse(legacy).$defs, JSON.parse(schema).$defs) const roots = [ 'InitializeRequest', 'InitializeResponse', 'AuthenticateRequest', 'AuthenticateResponse', 'NewSessionRequest', 'NewSessionResponse', 'LoadSessionRequest', 'LoadSessionResponse', 'ResumeSessionRequest', 'ResumeSessionResponse', 'PromptRequest', 'PromptResponse', 'CancelNotification', 'SessionNotification', 'RequestPermissionRequest', 'RequestPermissionResponse', 'SetSessionModeRequest', 'SetSessionModeResponse', 'SetSessionModelRequest', 'SetSessionModelResponse', 'SessionModelState', 'SetSessionConfigOptionRequest', 'SetSessionConfigOptionResponse', 'ReadTextFileRequest', 'ReadTextFileResponse', 'WriteTextFileRequest', 'WriteTextFileResponse', 'CreateTerminalRequest', 'CreateTerminalResponse', 'TerminalOutputRequest', 'TerminalOutputResponse', 'ReleaseTerminalRequest', 'ReleaseTerminalResponse', 'WaitForTerminalExitRequest', 'WaitForTerminalExitResponse', 'KillTerminalRequest', 'KillTerminalResponse' ] function references(value) { if (!value || typeof value !== 'object') { return [] } if (Array.isArray(value)) { return value.flatMap(references) } return [ ...(value.$ref ? [value.$ref.split('/').at(-1)] : []), ...Object.values(value).flatMap(references) ] } const ordered = [] const visiting = new Set() const visited = new Set() function visit(name) { if (visited.has(name)) { return } if (visiting.has(name)) { throw new Error(`Recursive schema needs an explicit type: ${name}`) } if (!definitions[name]) { throw new Error(`Missing definition: ${name}`) } visiting.add(name) for (const dependency of references(definitions[name])) { visit(dependency) } visiting.delete(name) visited.add(name) ordered.push(name) } roots.forEach(visit) // A named string enum: two or more string constants, optionally with an open `string` member. function isStringEnum(value) { const alternatives = value.oneOf ?? value.anyOf return ( Array.isArray(alternatives) && alternatives.filter((alternative) => typeof alternative.const === 'string').length > 1 && alternatives.every( (alternative) => typeof alternative.const === 'string' || (alternative.type === 'string' && Object.keys(alternative).every((key) => ['type', 'title', 'description'].includes(key))) ) ) } // Enums stay open so a newer or vendor value reaches the caller instead of failing the message. function openEnum(value) { const known = (value.oneOf ?? value.anyOf).filter( (alternative) => typeof alternative.const === 'string' ) return `z.union([${known.map((alternative) => `z.literal(${JSON.stringify(alternative.const)})`).join(',')},otherString])` } function expression(value) { if (value === true) { return 'z.unknown()' } if (value === false) { return 'z.never()' } if (value.$ref) { return `${value.$ref.split('/').at(-1)}Schema` } if ('const' in value) { return `z.literal(${JSON.stringify(value.const)})` } const alternatives = value.oneOf ?? value.anyOf if (alternatives || value.allOf) { const combined = alternatives ? `z.union([${alternatives.map(expression).join(',')}])` : value.allOf.map(expression).reduce((left, right) => `z.intersection(${left},${right})`) const siblings = { ...value } delete siblings.oneOf delete siblings.anyOf delete siblings.allOf return siblings.type || siblings.properties ? `z.intersection(${expression(siblings)},${combined})` : combined } if (Array.isArray(value.type)) { return `z.union([${value.type.map((type) => expression({ ...value, type })).join(',')}])` } let result switch (value.type) { case 'string': result = 'z.string()' break case 'integer': result = 'z.number().int()' break case 'number': result = 'z.number()' break case 'boolean': result = 'z.boolean()' break case 'null': result = 'z.null()' break case 'array': result = `z.array(${expression(value.items ?? true)})` break case 'object': { const properties = Object.entries(value.properties ?? {}).map( ([key, property]) => `${JSON.stringify(key)}:${expression(property)}${value.required?.includes(key) ? '' : '.optional()'}` ) result = `z.${value.additionalProperties === false ? 'strictObject' : 'looseObject'}({${properties.join(',')}})` if (typeof value.additionalProperties === 'object') { result += `.catchall(${expression(value.additionalProperties)})` } break } default: if ( Object.keys(value).some( (key) => !key.startsWith('x-') && !['description', 'title', 'default'].includes(key) ) ) { throw new Error(`Unsupported schema: ${JSON.stringify(value)}`) } result = 'z.unknown()' } if (['integer', 'number'].includes(value.type) && typeof value.minimum === 'number') { result += `.min(${value.minimum})` } if (['integer', 'number'].includes(value.type) && typeof value.maximum === 'number') { result += `.max(${value.maximum})` } if (value.not) { result += `.refine(value=>!${expression(value.not)}.safeParse(value).success)` } return result } const source = `${header}/*\n${license.trim()}\n*/\nimport { z } from 'zod'\nexport const ACP_SCHEMA_RELEASE = '${release}'\nexport const ACP_LEGACY_MODEL_SCHEMA_RELEASE = '${legacyRelease}'\nexport const ACP_PROTOCOL_VERSION = 1\n// An enum value this schema release does not name; \`string & {}\` keeps the known literals narrowable.\nconst otherString = z.custom((value) => typeof value === 'string')\n${ordered .map( (name) => `export const ${name}Schema = ${isStringEnum(definitions[name]) ? openEnum(definitions[name]) : expression(definitions[name])}\nexport type ${name} = z.infer\n` ) .join('\n')}` // Use the repository formatter without spawning a platform-dependent executable shim. const { format } = await import('oxfmt') const formatted = await format(outputFile, source, { singleQuote: true, semi: false, printWidth: 100, trailingComma: 'none' }) if (formatted.errors.length || !formatted.code.startsWith(header)) { throw new Error(`Formatting failed for ${outputFile}`) } const body = formatted.code.slice(header.length) const code = `${header}${bodyDigestPrefix}${sha256(body)}\n${body}` await mkdir(output, { recursive: true }) const destination = resolve(output, outputFile) if (check) { if ((await readFile(destination, 'utf8')) !== code) { throw new Error(`Stale generated file: ${outputFile}`) } } else { await writeFile(destination, code) } for (const file of await readdir(output)) { if ((file.endsWith('.gen.ts') || file.endsWith('.generated.ts')) && file !== outputFile) { const generatedHere = (await readFile(resolve(output, file), 'utf8')).startsWith( '// Generated by config/scripts/acp/generate-protocol.mjs' ) if (check || !generatedHere) { throw new Error(`Unexpected generated file: ${file}`) } await unlink(resolve(output, file)) } } console.log(`${check ? 'Checked' : 'Generated'} ${ordered.length} ACP definitions`)