Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
92
repos/effect/packages/tools/ai-codegen/codegen.schema.json
Normal file
92
repos/effect/packages/tools/ai-codegen/codegen.schema.json
Normal 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
|
||||
}
|
||||
4
repos/effect/packages/tools/ai-codegen/docgen.json
Normal file
4
repos/effect/packages/tools/ai-codegen/docgen.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/@effect/docgen/schema.json",
|
||||
"exclude": ["**/*.ts"]
|
||||
}
|
||||
60
repos/effect/packages/tools/ai-codegen/package.json
Normal file
60
repos/effect/packages/tools/ai-codegen/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
292
repos/effect/packages/tools/ai-codegen/src/Config.ts
Normal file
292
repos/effect/packages/tools/ai-codegen/src/Config.ts
Normal 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
|
||||
}> {}
|
||||
197
repos/effect/packages/tools/ai-codegen/src/Discovery.ts
Normal file
197
repos/effect/packages/tools/ai-codegen/src/Discovery.ts
Normal 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))
|
||||
222
repos/effect/packages/tools/ai-codegen/src/Generator.ts
Normal file
222
repos/effect/packages/tools/ai-codegen/src/Generator.ts
Normal 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)
|
||||
)
|
||||
56
repos/effect/packages/tools/ai-codegen/src/Glob.ts
Normal file
56
repos/effect/packages/tools/ai-codegen/src/Glob.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
157
repos/effect/packages/tools/ai-codegen/src/PostProcess.ts
Normal file
157
repos/effect/packages/tools/ai-codegen/src/PostProcess.ts
Normal 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))
|
||||
154
repos/effect/packages/tools/ai-codegen/src/SpecFetcher.ts
Normal file
154
repos/effect/packages/tools/ai-codegen/src/SpecFetcher.ts
Normal 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))
|
||||
10
repos/effect/packages/tools/ai-codegen/src/bin.ts
Normal file
10
repos/effect/packages/tools/ai-codegen/src/bin.ts
Normal 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
|
||||
)
|
||||
237
repos/effect/packages/tools/ai-codegen/src/main.ts
Normal file
237
repos/effect/packages/tools/ai-codegen/src/main.ts
Normal 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)
|
||||
)
|
||||
15
repos/effect/packages/tools/ai-codegen/tsconfig.json
Normal file
15
repos/effect/packages/tools/ai-codegen/tsconfig.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user