Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:10 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://effect.website/schemas/ai-codegen.json",
"title": "Effect AI Codegen Configuration",
"description": "Configuration for @effect/ai-codegen code generation",
"type": "object",
"required": ["spec", "output"],
"properties": {
"spec": {
"description": "OpenAPI specification source - URL, file path, or structured resolver config",
"oneOf": [
{
"type": "string",
"description": "URL or file path to the OpenAPI specification"
},
{
"type": "object",
"description": "Stainless stats resolver - fetches stats.yml and extracts openapi_spec_url",
"required": ["type", "statsUrl"],
"additionalProperties": false,
"properties": {
"type": {
"const": "stainless-stats",
"description": "Resolver type"
},
"statsUrl": {
"type": "string",
"format": "uri",
"description": "URL to Stainless .stats.yml file containing openapi_spec_url"
}
}
}
]
},
"output": {
"type": "string",
"description": "Output file path relative to the package directory"
},
"name": {
"type": "string",
"description": "Name for the generated client (defaults to 'Client')"
},
"typeOnly": {
"type": "boolean",
"description": "Generate type-only output without runtime code",
"default": false
},
"header": {
"type": "string",
"description": "Content to prepend to the generated file (e.g. module-level JSDoc)"
},
"patches": {
"type": "array",
"description": "JSON Patch documents to apply to the spec before generation. Each item can be a file path or inline JSON array.",
"items": {
"type": "string"
}
},
"replacements": {
"type": "array",
"description": "Text replacements to apply to generated code.",
"items": {
"type": "object",
"required": ["from", "to"],
"additionalProperties": false,
"properties": {
"from": {
"type": "string",
"description": "The string to search for"
},
"to": {
"type": "string",
"description": "The string to replace with"
}
}
}
},
"excludeAnnotations": {
"type": "array",
"description": "Annotation keys to exclude from generated schema code (e.g. [\"examples\"]).",
"items": {
"type": "string"
}
},
"disableAdditionalProperties": {
"type": "boolean",
"description": "When true, forces additionalProperties to false on all object schemas, preventing StructWithRest emission.",
"default": false
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "../../../node_modules/@effect/docgen/schema.json",
"exclude": ["**/*.ts"]
}

View File

@@ -0,0 +1,60 @@
{
"name": "@effect/ai-codegen",
"version": "0.0.0",
"type": "module",
"private": true,
"license": "MIT",
"description": "Code generation framework for the Effect AI providers",
"homepage": "https://effect.website",
"repository": {
"type": "git",
"url": "https://github.com/Effect-TS/effect.git",
"directory": "packages/tools/ai-codegen"
},
"sideEffects": [],
"bin": {
"effect-ai-codegen": "./src/bin.ts"
},
"exports": {
"./package.json": "./package.json",
"./*": "./src/*.ts",
"./bin": null,
"./main": null
},
"files": [
"src/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map"
],
"publishConfig": {
"provenance": true,
"bin": {
"effect-ai-codegen": "./dist/bin.js"
},
"exports": {
"./package.json": "./package.json",
"./*": "./dist/*.js",
"./bin": null,
"./main": null
}
},
"scripts": {
"build": "tsc -b tsconfig.json && pnpm babel",
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
"check": "tsc -b tsconfig.json",
"test": "vitest",
"coverage": "vitest --coverage"
},
"dependencies": {
"@effect/openapi-generator": "workspace:^",
"@effect/platform-node": "workspace:^",
"effect": "workspace:^",
"glob": "^13.0.6",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/node": "^26.1.1"
}
}

View File

@@ -0,0 +1,292 @@
/**
* Configuration schema and types for AI provider code generation.
*
* @since 4.0.0
*/
import * as Data from "effect/Data"
import type * as Path from "effect/Path"
import * as Schema from "effect/Schema"
/**
* A text replacement to apply to generated code.
*
* @category models
* @since 4.0.0
*/
export class Replacement extends Schema.Class<Replacement>("Replacement")({
from: Schema.String,
to: Schema.String
}) {}
/**
* Structured spec source configuration for Stainless stats indirection.
*
* @category schemas
* @since 4.0.0
*/
export const SpecSourceConfig = Schema.Struct({
type: Schema.Literal("stainless-stats"),
statsUrl: Schema.String
})
/**
* Configuration for AI provider code generation.
*
* **Example** (Decoding a codegen configuration)
*
* ```ts
* import * as Config from "@effect/ai-codegen/Config"
* import { Schema } from "effect"
*
* const config = Schema.decodeUnknownSync(Config.CodegenConfig)({
* spec: "https://example.com/openapi.json",
* output: "Generated.ts",
* name: "MyClient"
* })
*
* console.log(config.spec)
* // "https://example.com/openapi.json"
* ```
*
* @category models
* @since 4.0.0
*/
export class CodegenConfig extends Schema.Class<CodegenConfig>("CodegenConfig")({
spec: Schema.Union([Schema.String, SpecSourceConfig]),
output: Schema.String,
name: Schema.optional(Schema.String),
typeOnly: Schema.optional(Schema.Boolean),
header: Schema.optional(Schema.String),
patches: Schema.optional(Schema.Array(Schema.String)),
replacements: Schema.optional(Schema.Array(Replacement)),
excludeAnnotations: Schema.optional(Schema.Array(Schema.String)),
disableAdditionalProperties: Schema.optional(Schema.Boolean)
}) {
/**
* Get the client name, defaulting to "Client" if not specified.
*
* @since 4.0.0
*/
get clientName(): string {
return this.name ?? "Client"
}
/**
* Check if type-only generation is enabled.
*
* @since 4.0.0
*/
get isTypeOnly(): boolean {
return this.typeOnly ?? false
}
/**
* Get the list of patch files/strings to apply.
*
* @since 4.0.0
*/
get patchList(): ReadonlyArray<string> {
return this.patches ?? []
}
/**
* Get the list of text replacements to apply.
*
* @since 4.0.0
*/
get replacementList(): ReadonlyArray<Replacement> {
return this.replacements ?? []
}
/**
* Get the header content to prepend to generated files.
*
* @since 4.0.0
*/
get headerContent(): string | undefined {
return this.header
}
/**
* Get the list of annotation keys to exclude from generated code.
*
* @since 4.0.0
*/
get excludeAnnotationsList(): ReadonlyArray<string> | undefined {
return this.excludeAnnotations
}
/**
* Check if additionalProperties should be forced to false on all object schemas.
*
* @since 4.0.0
*/
get shouldDisableAdditionalProperties(): boolean {
return this.disableAdditionalProperties ?? false
}
}
/**
* Represents the source of an OpenAPI specification.
*
* @category models
* @since 4.0.0
*/
export type SpecSource = SpecSource.Url | SpecSource.File | SpecSource.StainlessStats
/**
* Namespace containing the supported OpenAPI specification source variants.
*
* @since 4.0.0
*/
export declare namespace SpecSource {
/**
* A URL-based spec source.
*
* @category models
* @since 4.0.0
*/
export interface Url {
readonly _tag: "Url"
readonly url: string
}
/**
* A file-based spec source.
*
* @category models
* @since 4.0.0
*/
export interface File {
readonly _tag: "File"
readonly path: string
}
/**
* Stainless SDK stats.yml indirection - fetches stats file and extracts openapi_spec_url.
*
* @category models
* @since 4.0.0
*/
export interface StainlessStats {
readonly _tag: "StainlessStats"
readonly statsUrl: string
}
}
/**
* Constructors and utilities for `SpecSource`.
*
* **Example** (Creating spec sources)
*
* ```ts
* import * as Config from "@effect/ai-codegen/Config"
*
* // Create a URL-based source
* const urlSource = Config.SpecSource.Url("https://example.com/openapi.json")
*
* // Create a file-based source
* const fileSource = Config.SpecSource.File("/path/to/spec.json")
* ```
*
* @category constructors
* @since 4.0.0
*/
export const SpecSource = {
/**
* Create a URL-based spec source.
*
* @since 4.0.0
*/
Url: (url: string): SpecSource => ({ _tag: "Url", url }),
/**
* Create a file-based spec source.
*
* @since 4.0.0
*/
File: (path: string): SpecSource => ({ _tag: "File", path }),
/**
* Create a Stainless stats-based spec source.
*
* @since 4.0.0
*/
StainlessStats: (statsUrl: string): SpecSource => ({ _tag: "StainlessStats", statsUrl }),
/**
* Parse a spec string into a `SpecSource`.
* URLs (http:// or https://) become `Url`, otherwise `File`.
*
* @since 4.0.0
*/
fromString: (spec: string, packagePath: string, pathService: Path.Path): SpecSource => {
if (spec.startsWith("http://") || spec.startsWith("https://")) {
return SpecSource.Url(spec)
}
return SpecSource.File(pathService.join(packagePath, spec))
},
/**
* Parse a spec config (string or object) into a `SpecSource`.
*
* @since 4.0.0
*/
fromConfig: (
spec: string | { readonly type: string; readonly statsUrl?: string },
packagePath: string,
pathService: Path.Path
): SpecSource => {
if (typeof spec === "string") {
return SpecSource.fromString(spec, packagePath, pathService)
}
if (spec.type === "stainless-stats" && spec.statsUrl) {
return SpecSource.StainlessStats(spec.statsUrl)
}
throw new Error(`Unknown spec type: ${spec.type}`)
}
}
/**
* Error when parsing a codegen configuration file fails.
*
* **Example** (Creating a config parse error)
*
* ```ts
* import * as Config from "@effect/ai-codegen/Config"
*
* const error = new Config.ConfigParseError({
* path: "/path/to/codegen.json",
* cause: new Error("Invalid JSON")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
readonly path: string
readonly cause: unknown
}> {}
/**
* Error when a codegen configuration file is not found.
*
* **Example** (Creating a config not found error)
*
* ```ts
* import * as Config from "@effect/ai-codegen/Config"
*
* const error = new Config.ConfigNotFoundError({
* provider: "openai",
* expectedPath: "/path/to/packages/ai/openai/codegen.json"
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class ConfigNotFoundError extends Data.TaggedError("ConfigNotFoundError")<{
readonly provider: string
readonly expectedPath: string
}> {}

View File

@@ -0,0 +1,197 @@
/**
* Provider discovery service for AI codegen.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as Schema from "effect/Schema"
import * as Yaml from "yaml"
import { CodegenConfig, type SpecSource, SpecSource as SpecSourceUtils } from "./Config.ts"
import * as Glob from "./Glob.ts"
/**
* A discovered AI provider with resolved paths.
*
* **Example** (Inspecting a discovered provider)
*
* ```ts
* import type * as Discovery from "@effect/ai-codegen/Discovery"
*
* declare const provider: Discovery.DiscoveredProvider
*
* console.log(provider.name) // "openai"
* console.log(provider.specSource._tag) // "Url" | "File"
* ```
*
* @category models
* @since 4.0.0
*/
export interface DiscoveredProvider {
readonly name: string
readonly packagePath: string
readonly config: CodegenConfig
readonly specSource: SpecSource
readonly outputPath: string
}
/**
* Service for discovering AI provider configurations.
*
* @category models
* @since 4.0.0
*/
export interface ProviderDiscovery {
readonly discover: () => Effect.Effect<
Array<DiscoveredProvider>,
DiscoveryError | Glob.GlobError
>
readonly discoverOne: (
name: string
) => Effect.Effect<
DiscoveredProvider,
DiscoveryError | ProviderNotFoundError | Glob.GlobError
>
}
/**
* Service tag for discovering AI provider codegen configurations.
*
* @category services
* @since 4.0.0
*/
export const ProviderDiscovery: Context.Service<ProviderDiscovery, ProviderDiscovery> = Context.Service(
"@effect/ai-codegen/ProviderDiscovery"
)
/**
* Error during provider discovery.
*
* **Example** (Creating a discovery error)
*
* ```ts
* import * as Discovery from "@effect/ai-codegen/Discovery"
*
* const error = new Discovery.DiscoveryError({
* message: "Failed to parse config",
* cause: new Error("Invalid JSON")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
readonly message: string
readonly cause?: unknown
}> {}
/**
* Error when a specific provider is not found.
*
* **Example** (Creating a provider not found error)
*
* ```ts
* import * as Discovery from "@effect/ai-codegen/Discovery"
*
* const error = new Discovery.ProviderNotFoundError({
* provider: "openai",
* available: ["anthropic", "google"]
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class ProviderNotFoundError extends Data.TaggedError("ProviderNotFoundError")<{
readonly provider: string
readonly available: ReadonlyArray<string>
}> {}
/**
* Layer providing the ProviderDiscovery service.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<
ProviderDiscovery,
never,
Glob.Glob | FileSystem.FileSystem | Path.Path
> = Effect.gen(function*() {
const glob = yield* Glob.Glob
const fs = yield* FileSystem.FileSystem
const pathService = yield* Path.Path
const parseConfig = Effect.fn("parseConfig")(function*(configPath: string) {
const packagePath = pathService.dirname(configPath)
const name = pathService.basename(packagePath)
const isYaml = configPath.endsWith(".yaml") || configPath.endsWith(".yml")
const content = yield* fs.readFileString(configPath).pipe(
Effect.mapError((cause) =>
new DiscoveryError({
message: `Failed to read config at ${configPath}`,
cause
})
)
)
const parsed = yield* Effect.try({
try: () => isYaml ? Yaml.parse(content) : JSON.parse(content),
catch: (cause) =>
new DiscoveryError({
message: `Failed to parse ${isYaml ? "YAML" : "JSON"} at ${configPath}`,
cause
})
})
const config = yield* Schema.decodeUnknownEffect(CodegenConfig)(parsed).pipe(
Effect.mapError((cause) =>
new DiscoveryError({
message: `Invalid config schema at ${configPath}`,
cause
})
)
)
const provider: DiscoveredProvider = {
name,
packagePath,
config,
specSource: SpecSourceUtils.fromConfig(config.spec, packagePath, pathService),
outputPath: pathService.join(packagePath, config.output)
}
return provider
})
const discover = Effect.fn("discover")(function*() {
const configFiles = yield* glob.glob("packages/ai/*/codegen.{json,yaml,yml}", {
cwd: process.cwd(),
absolute: true
})
return yield* Effect.forEach(configFiles, parseConfig)
})
const discoverOne = Effect.fn("discoverOne")(function*(providerName: string) {
const providers = yield* discover()
const found = providers.find((p) => p.name === providerName)
if (!found) {
return yield* new ProviderNotFoundError({
provider: providerName,
available: providers.map((p) => p.name)
})
}
return found
})
return { discover, discoverOne }
}).pipe(Layer.effect(ProviderDiscovery))

View File

@@ -0,0 +1,222 @@
/**
* Code generator service wrapping @effect/openapi-generator.
*
* @since 4.0.0
*/
import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import type * as FileSystem from "effect/FileSystem"
import type * as JsonSchema from "effect/JsonSchema"
import * as Layer from "effect/Layer"
import * as Path_ from "effect/Path"
import * as Predicate from "effect/Predicate"
import type * as Schema from "effect/Schema"
import type { DiscoveredProvider } from "./Discovery.ts"
/**
* Error during code generation.
*
* **Example** (Creating a generation error)
*
* ```ts
* import * as Generator from "@effect/ai-codegen/Generator"
*
* const error = new Generator.GenerationError({
* provider: "openai",
* cause: new Error("Invalid spec")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class GenerationError extends Data.TaggedError("GenerationError")<{
readonly provider: string
readonly cause: unknown
}> {}
/**
* Error during patch application.
*
* **Example** (Creating a patch error)
*
* ```ts
* import * as Generator from "@effect/ai-codegen/Generator"
*
* const error = new Generator.PatchError({
* provider: "openai",
* cause: new Error("Invalid patch")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class PatchError extends Data.TaggedError("PatchError")<{
readonly provider: string
readonly cause: unknown
}> {}
/**
* Service for generating Effect code from OpenAPI specs.
*
* @category models
* @since 4.0.0
*/
export interface CodeGenerator {
readonly generate: (
provider: DiscoveredProvider,
spec: unknown
) => Effect.Effect<string, GenerationError | PatchError, FileSystem.FileSystem | Path_.Path>
}
/**
* Service tag for generating Effect client code from OpenAPI specifications.
*
* @category services
* @since 4.0.0
*/
export const CodeGenerator: Context.Service<CodeGenerator, CodeGenerator> = Context.Service(
"@effect/ai-codegen/CodeGenerator"
)
const isRecord = (u: unknown): u is { readonly [x: string]: unknown } =>
Predicate.isObjectOrArray(u) && !Array.isArray(u)
/** A bare `{ "type": "string" }` branch - the open half of an open enum. */
const isOpenStringBranch = (branch: unknown): boolean =>
isRecord(branch) && branch.type === "string" && Object.keys(branch).length === 1
/** The value of a `{ "const": "..." }` branch, when it is a string const. */
const constBranchValue = (branch: unknown): string | undefined =>
isRecord(branch) && Predicate.isString(branch.const) ? branch.const : undefined
/**
* Collapse an open enum's const branches into a single `enum` branch.
*
* Stainless-generated specs encode an open enum as
* `anyOf: [{ type: "string" }, { const: "a" }, { const: "b" }]` - "any string is valid, and these are
* the known values". Left as-is, each const becomes its own `Schema.Literal` member, so consumers
* cannot recover the literal union from the generated schema (the union's `Type` is just `string`).
*
* Rewriting the const branches to `anyOf: [{ type: "string" }, { enum: ["a", "b"] }]` emits
* `Schema.Union([Schema.String, Schema.Literals(["a", "b"])])` instead, which decodes any string while
* still exposing the known values as `members[1]` for autocomplete.
*/
const normalizeOpenEnum = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
const anyOf = js.anyOf
if (!Array.isArray(anyOf) || anyOf.length < 2) return js
const [head, ...tail] = anyOf
if (!isOpenStringBranch(head)) return js
const literals: Array<string> = []
for (const branch of tail) {
const value = constBranchValue(branch)
if (value === undefined) return js
literals.push(value)
}
return { ...js, anyOf: [head, { enum: literals }] }
}
/**
* Layer providing the CodeGenerator service.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<
CodeGenerator,
never,
OpenApiGenerator.OpenApiGenerator | FileSystem.FileSystem | Path_.Path
> = Effect.gen(function*() {
const openApiGen = yield* OpenApiGenerator.OpenApiGenerator
const pathService = yield* Path_.Path
const applyPatches = Effect.fn("applyPatches")(function*(
provider: DiscoveredProvider,
spec: Schema.Json
) {
const patchInputs = provider.config.patchList
if (patchInputs.length === 0) {
return spec
}
// Parse all patches, resolving file paths relative to the provider package
const parsedPatches = yield* Effect.forEach(patchInputs, (input) => {
// If it looks like a file path and is not absolute, resolve relative to package
const resolvedInput = !input.startsWith("[") && !pathService.isAbsolute(input)
? pathService.join(provider.packagePath, input)
: input
return OpenApiPatch.parsePatchInput(resolvedInput).pipe(
Effect.map((patch) => ({ source: resolvedInput, patch }))
)
}).pipe(
Effect.mapError((cause) => new PatchError({ provider: provider.name, cause }))
)
// Apply all patches to the spec
return yield* OpenApiPatch.applyPatches(parsedPatches, spec).pipe(
Effect.mapError((cause) => new PatchError({ provider: provider.name, cause }))
)
})
const generate = Effect.fn("generate")(function*(
provider: DiscoveredProvider,
spec: unknown
) {
// Apply patches if any are configured
const patchedSpec = yield* applyPatches(provider, spec as Schema.Json)
const excludeAnnotations = provider.config.excludeAnnotationsList
const disableAdditionalProperties = provider.config.shouldDisableAdditionalProperties
const exclude = excludeAnnotations ? new Set(excludeAnnotations) : undefined
const onEnter = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
const out = { ...normalizeOpenEnum(js) }
if (exclude) {
for (const key of exclude) delete out[key]
}
if (disableAdditionalProperties && out.type === "object") {
out.additionalProperties = false
}
return out
}
return yield* openApiGen
.generate(patchedSpec as unknown as Parameters<typeof openApiGen.generate>[0], {
name: provider.config.clientName,
format: provider.config.isTypeOnly ? "httpclient-type-only" : "httpclient",
onEnter
})
.pipe(
Effect.mapError((cause) => new GenerationError({ provider: provider.name, cause }))
)
})
return { generate }
}).pipe(Layer.effect(CodeGenerator))
/**
* Layer providing the CodeGenerator with schema transformer (default).
*
* @category layers
* @since 4.0.0
*/
export const layerSchema: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe(
Layer.provide(OpenApiGenerator.layerTransformerSchema)
)
/**
* Layer providing the CodeGenerator with TypeScript-only transformer.
*
* @category layers
* @since 4.0.0
*/
export const layerTypeScript: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe(
Layer.provide(OpenApiGenerator.layerTransformerTs)
)

View File

@@ -0,0 +1,56 @@
/**
* Glob pattern matching service.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as GlobLib from "glob"
/**
* Error during glob pattern matching.
*
* @category errors
* @since 4.0.0
*/
export class GlobError extends Data.TaggedError("GlobError")<{
readonly pattern: string | ReadonlyArray<string>
readonly cause: unknown
}> {}
/**
* Service for glob pattern matching.
*
* @category models
* @since 4.0.0
*/
export interface Glob {
readonly glob: (
pattern: string | ReadonlyArray<string>,
options?: GlobLib.GlobOptions
) => Effect.Effect<Array<string>, GlobError>
}
/**
* Service tag for glob pattern matching used by AI codegen tooling.
*
* @category services
* @since 4.0.0
*/
export const Glob: Context.Service<Glob, Glob> = Context.Service("@effect/ai-codegen/Glob")
/**
* Layer providing the Glob service.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Glob> = Layer.succeed(Glob, {
glob: (pattern, options) =>
Effect.tryPromise({
try: () => GlobLib.glob(pattern as string | Array<string>, options ?? {}) as Promise<Array<string>>,
catch: (cause) => new GlobError({ pattern, cause })
})
})

View File

@@ -0,0 +1,157 @@
/**
* Post-processing service for linting and formatting generated code.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
/**
* Error during post-processing (lint or format).
*
* **Example** (Creating a post-process error)
*
* ```ts
* import * as PostProcess from "@effect/ai-codegen/PostProcess"
*
* const error = new PostProcess.PostProcessError({
* step: "lint",
* command: "pnpm exec oxlint --fix /path/to/file.ts",
* filePath: "/path/to/file.ts",
* exitCode: 1,
* stdout: "",
* stderr: "error: some lint error",
* cause: new Error("Lint failed")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class PostProcessError extends Data.TaggedError("PostProcessError")<{
readonly step: "lint" | "format"
readonly command: string
readonly filePath: string
readonly exitCode?: number | undefined
readonly stdout: string
readonly stderr: string
readonly cause: unknown
}> {
override get message(): string {
const lines: Array<string> = [
`${this.step} failed for ${this.filePath}`,
`command: ${this.command}`
]
if (this.exitCode !== undefined) {
lines.push(`exit code: ${this.exitCode}`)
}
if (this.stderr.length > 0) {
lines.push(`stderr:\n${this.stderr}`)
}
if (this.stdout.length > 0) {
lines.push(`stdout:\n${this.stdout}`)
}
return lines.join("\n")
}
}
/**
* Service for post-processing generated code.
*
* @category models
* @since 4.0.0
*/
export interface PostProcessor {
readonly lint: (filePath: string) => Effect.Effect<void, PostProcessError>
readonly format: (filePath: string) => Effect.Effect<void, PostProcessError>
}
/**
* Service tag for linting and formatting generated code.
*
* @category services
* @since 4.0.0
*/
export const PostProcessor: Context.Service<PostProcessor, PostProcessor> = Context.Service(
"@effect/ai-codegen/PostProcessor"
)
/**
* Layer providing the PostProcessor service.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<
PostProcessor,
never,
ChildProcessSpawner.ChildProcessSpawner
> = Effect.gen(function*() {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const collectStream = (stream: Stream.Stream<Uint8Array, any>) =>
stream.pipe(
Stream.decodeText(),
Stream.mkString,
Effect.orElseSucceed(() => "")
)
const runCommand = Effect.fn("runCommand")(function*(
command: string,
args: ReadonlyArray<string>,
step: "lint" | "format",
filePath: string
) {
const fullCommand = [command, ...args].join(" ")
const cmd = ChildProcess.make(command, args, {
stdout: "pipe",
stderr: "pipe"
})
yield* Effect.scoped(Effect.gen(function*() {
const handle = yield* spawner.spawn(cmd).pipe(
Effect.mapError((cause) =>
new PostProcessError({ step, command: fullCommand, filePath, stdout: "", stderr: "", cause })
)
)
const [stdout, stderr] = yield* Effect.all([
collectStream(handle.stdout),
collectStream(handle.stderr)
])
const exitCode = yield* handle.exitCode.pipe(
Effect.mapError((cause) =>
new PostProcessError({ step, command: fullCommand, filePath, stdout, stderr, cause })
)
)
if (exitCode !== 0) {
return yield* new PostProcessError({
step,
command: fullCommand,
filePath,
exitCode,
stdout,
stderr,
cause: new Error(`Command exited with code ${exitCode}`)
})
}
}))
})
const lint = Effect.fn("lint")(function*(filePath: string) {
yield* runCommand("pnpm", ["exec", "oxlint", "--silent", "--quiet", "--fix", filePath], "lint", filePath)
})
const format = Effect.fn("format")(function*(filePath: string) {
yield* runCommand("pnpm", ["exec", "dprint", "--log-level", "silent", "fmt", filePath], "format", filePath)
})
return { lint, format }
}).pipe(Layer.effect(PostProcessor))

View File

@@ -0,0 +1,154 @@
/**
* Service for fetching OpenAPI specifications from URLs or the filesystem.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Match from "effect/Match"
import * as HttpClient from "effect/unstable/http/HttpClient"
import * as Yaml from "yaml"
import type { SpecSource } from "./Config.ts"
/**
* Error when fetching a spec fails.
*
* **Example** (Creating a spec fetch error)
*
* ```ts
* import * as SpecFetcher from "@effect/ai-codegen/SpecFetcher"
*
* const error = new SpecFetcher.SpecFetchError({
* provider: "openai",
* source: "https://example.com/openapi.json",
* cause: new Error("Network error")
* })
* ```
*
* @category errors
* @since 4.0.0
*/
export class SpecFetchError extends Data.TaggedError("SpecFetchError")<{
readonly provider: string
readonly source: string
readonly cause: unknown
}> {}
/**
* Service for fetching OpenAPI specifications.
*
* @category models
* @since 4.0.0
*/
export interface SpecFetcher {
readonly fetch: (
source: SpecSource,
provider: string
) => Effect.Effect<unknown, SpecFetchError>
}
/**
* Service tag for fetching OpenAPI specifications from configured sources.
*
* @category services
* @since 4.0.0
*/
export const SpecFetcher: Context.Service<SpecFetcher, SpecFetcher> = Context.Service(
"@effect/ai-codegen/SpecFetcher"
)
/**
* Layer providing the SpecFetcher service.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<
SpecFetcher,
never,
FileSystem.FileSystem | HttpClient.HttpClient
> = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const httpClient = yield* HttpClient.HttpClient
const fetchFromFile = Effect.fn("fetchFromFile")(function*(
path: string,
provider: string
) {
return yield* fs.readFileString(path).pipe(
Effect.mapError((cause) => new SpecFetchError({ provider, source: path, cause }))
)
})
const fetchFromUrl = Effect.fn("fetchFromUrl")(function*(
url: string,
provider: string
) {
return yield* httpClient.get(url).pipe(
Effect.flatMap((response) => response.text),
Effect.mapError((cause) => new SpecFetchError({ provider, source: url, cause }))
)
})
const parseSpec = (content: string, sourceUrl: string, provider: string) => {
const isYaml = sourceUrl.endsWith(".yaml") || sourceUrl.endsWith(".yml")
return Effect.try({
try: () => isYaml ? Yaml.parse(content) : JSON.parse(content),
catch: (cause) => new SpecFetchError({ provider, source: sourceUrl, cause })
})
}
const fetchViaStainlessStats = Effect.fn("fetchViaStainlessStats")(function*(
statsUrl: string,
provider: string
) {
// Fetch stats.yml
const statsContent = yield* httpClient.get(statsUrl).pipe(
Effect.flatMap((response) => response.text),
Effect.mapError((cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause }))
)
// Parse YAML and extract openapi_spec_url
const stats = yield* Effect.try({
try: () => Yaml.parse(statsContent),
catch: (cause) => new SpecFetchError({ provider, source: `stats:${statsUrl}`, cause })
})
const specUrl = stats?.openapi_spec_url
if (typeof specUrl !== "string") {
return yield* new SpecFetchError({
provider,
source: `stats:${statsUrl}`,
cause: new Error("Missing or invalid openapi_spec_url in stats file")
})
}
// Fetch and parse the actual spec
const content = yield* fetchFromUrl(specUrl, provider)
return yield* parseSpec(content, specUrl, provider)
})
const fetch = Effect.fn("fetch")(function*(
source: SpecSource,
provider: string
) {
// StainlessStats handles its own parsing since the URL is resolved dynamically
if (source._tag === "StainlessStats") {
return yield* fetchViaStainlessStats(source.statsUrl, provider)
}
const content = yield* Match.value(source).pipe(
Match.tag("File", ({ path }) => fetchFromFile(path, provider)),
Match.tag("Url", ({ url }) => fetchFromUrl(url, provider)),
Match.exhaustive
)
const sourceString = source._tag === "Url" ? source.url : source.path
return yield* parseSpec(content, sourceString, provider)
})
return { fetch }
}).pipe(Layer.effect(SpecFetcher))

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Effect from "effect/Effect"
import { run } from "./main.ts"
run.pipe(
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
)

View File

@@ -0,0 +1,237 @@
/**
* CLI entry point for ai-codegen tool.
*
* @since 4.0.0
*/
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Command from "effect/unstable/cli/Command"
import * as Flag from "effect/unstable/cli/Flag"
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
import * as ProviderDiscovery from "./Discovery.ts"
import * as CodeGenerator from "./Generator.ts"
import * as Glob from "./Glob.ts"
import * as PostProcessor from "./PostProcess.ts"
import * as SpecFetcher from "./SpecFetcher.ts"
// =============================================================================
// Flags
// =============================================================================
const providerFlag = Flag.string("provider").pipe(
Flag.withAlias("p"),
Flag.withDescription("Generate for specific provider only"),
Flag.optional
)
const skipLintFlag = Flag.boolean("skip-lint").pipe(
Flag.withDescription("Skip Oxlint step")
)
const skipFormatFlag = Flag.boolean("skip-format").pipe(
Flag.withDescription("Skip Dprint step")
)
// =============================================================================
// ANSI Colors
// =============================================================================
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
gray: "\x1b[90m",
white: "\x1b[37m"
} as const
// =============================================================================
// Generate Command
// =============================================================================
interface GenerateOptions {
readonly skipLint: boolean
readonly skipFormat: boolean
}
const formatSpecSource = (source: ProviderDiscovery.DiscoveredProvider["specSource"]): string => {
switch (source._tag) {
case "Url":
return source.url
case "File":
return source.path
case "StainlessStats":
return `stainless-stats(${source.statsUrl})`
}
}
const generateProvider = Effect.fn("generateProvider")(function*(
provider: ProviderDiscovery.DiscoveredProvider,
options: GenerateOptions
) {
const specFetcher = yield* SpecFetcher.SpecFetcher
const generator = yield* CodeGenerator.CodeGenerator
const postProcessor = yield* PostProcessor.PostProcessor
const fs = yield* FileSystem.FileSystem
yield* Console.log(`\n${colors.bold}${colors.cyan}${provider.name}${colors.reset}`)
// Fetch spec
yield* Console.log(` ${colors.dim}Fetching spec...${colors.reset}`)
const spec = yield* specFetcher.fetch(provider.specSource, provider.name)
// Generate code
yield* Console.log(` ${colors.dim}Generating code...${colors.reset}`)
let code = yield* generator.generate(provider, spec)
// Apply replacements
const replacements = provider.config.replacementList
if (replacements.length > 0) {
yield* Console.log(` ${colors.dim}Applying ${replacements.length} replacement(s)...${colors.reset}`)
for (const replacement of replacements) {
code = code.replaceAll(replacement.from, replacement.to)
}
}
// Prepend header if configured
const header = provider.config.headerContent
if (header !== undefined) {
code = `${header}\n${code}`
}
// Write output
yield* Console.log(` ${colors.dim}Writing output...${colors.reset}`)
yield* fs.writeFileString(provider.outputPath, code)
// Post-process
if (!options.skipLint) {
yield* Console.log(` ${colors.dim}Linting...${colors.reset}`)
yield* postProcessor.lint(provider.outputPath)
}
if (!options.skipFormat) {
yield* Console.log(` ${colors.dim}Formatting...${colors.reset}`)
yield* postProcessor.format(provider.outputPath)
}
yield* Console.log(
` ${colors.green}${colors.reset} ${colors.gray}${provider.config.output}${colors.reset}`
)
return provider.outputPath
})
const generate = Command.make("generate", {
provider: providerFlag,
skipLint: skipLintFlag,
skipFormat: skipFormatFlag
}).pipe(
Command.withHandler(Effect.fnUntraced(function*({ provider, skipLint, skipFormat }) {
const discovery = yield* ProviderDiscovery.ProviderDiscovery
const providers = yield* Option.match(provider, {
onNone: () => discovery.discover(),
onSome: (name) => discovery.discoverOne(name).pipe(Effect.map((p) => [p]))
})
if (providers.length === 0) {
yield* Console.log(`${colors.yellow}No providers found.${colors.reset}`)
return
}
yield* Console.log(
`${colors.dim}Generating ${providers.length} provider(s)...${colors.reset}`
)
for (const p of providers) {
yield* generateProvider(p, { skipLint, skipFormat })
}
yield* Console.log(`\n${colors.green}✓ All providers generated successfully!${colors.reset}`)
}))
)
// =============================================================================
// List Command
// =============================================================================
const list = Command.make("list").pipe(
Command.withHandler(Effect.fnUntraced(function*() {
const discovery = yield* ProviderDiscovery.ProviderDiscovery
const providers = yield* discovery.discover()
if (providers.length === 0) {
yield* Console.log(`${colors.yellow}No providers found.${colors.reset}`)
return
}
yield* Console.log(`${colors.green}Found ${providers.length} provider(s):${colors.reset}\n`)
for (const p of providers) {
yield* Console.log(`${colors.bold}${colors.cyan}${p.name}${colors.reset}`)
yield* Console.log(` ${colors.gray}spec:${colors.reset} ${formatSpecSource(p.specSource)}`)
yield* Console.log(` ${colors.gray}output:${colors.reset} ${p.config.output}`)
yield* Console.log(
` ${colors.gray}client:${colors.reset} ${colors.white}${p.config.clientName}${colors.reset}`
)
yield* Console.log(` ${colors.gray}typeOnly:${colors.reset} ${p.config.isTypeOnly}`)
if (p.config.patchList.length > 0) {
yield* Console.log(
` ${colors.gray}patches:${colors.reset} ${colors.yellow}${p.config.patchList.length} file(s)${colors.reset}`
)
}
yield* Console.log("")
}
}))
)
// =============================================================================
// Root Command
// =============================================================================
const root = Command.make("effect-ai-codegen").pipe(
Command.withSubcommands([generate, list])
)
// =============================================================================
// Layer Composition
// =============================================================================
// ProviderDiscovery depends on Glob, FileSystem, and Path
// SpecFetcher depends on FileSystem and HttpClient
// CodeGenerator depends on OpenApiGenerator
// PostProcessor depends on ChildProcessSpawner
// Build up layers with dependencies
const DiscoveryLayer = ProviderDiscovery.layer.pipe(
Layer.provide(Glob.layer)
)
const SpecFetcherLayer = SpecFetcher.layer.pipe(
Layer.provide(FetchHttpClient.layer)
)
const ServicesLayer = Layer.mergeAll(
DiscoveryLayer,
SpecFetcherLayer,
CodeGenerator.layerSchema,
PostProcessor.layer
)
// =============================================================================
// Export
// =============================================================================
/**
* Run the CLI.
*
* @category execution
* @since 4.0.0
*/
export const run = Command.run(root, { version: "0.0.0" }).pipe(
Effect.provide(ServicesLayer)
)

View File

@@ -0,0 +1,15 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"references": [
{ "path": "../../effect" },
{ "path": "../../platform-node" },
{ "path": "../openapi-generator" }
],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
}
}