| 1 | /** |
| 2 | * CLI entry point for ai-codegen tool. |
| 3 | * |
| 4 | * @since 4.0.0 |
| 5 | */ |
| 6 | import * as Command from "effect/cli/Command" |
| 7 | import * as Flag from "effect/cli/Flag" |
| 8 | import * as Console from "effect/Console" |
| 9 | import * as Effect from "effect/Effect" |
| 10 | import * as FileSystem from "effect/FileSystem" |
| 11 | import * as FetchHttpClient from "effect/http/FetchHttpClient" |
| 12 | import * as Layer from "effect/Layer" |
| 13 | import * as Option from "effect/Option" |
| 14 | import * as ProviderDiscovery from "./Discovery.ts" |
| 15 | import * as CodeGenerator from "./Generator.ts" |
| 16 | import * as Glob from "./Glob.ts" |
| 17 | import * as PostProcessor from "./PostProcess.ts" |
| 18 | import * as SpecFetcher from "./SpecFetcher.ts" |
| 19 | |
| 20 | // ============================================================================= |
| 21 | // Flags |
| 22 | // ============================================================================= |
| 23 | |
| 24 | const providerFlag = Flag.String("provider").pipe( |
| 25 | Flag.withAlias("p"), |
| 26 | Flag.withDescription("Generate for specific provider only"), |
| 27 | Flag.optional |
| 28 | ) |
| 29 | |
| 30 | const skipLintFlag = Flag.Boolean("skip-lint").pipe( |
| 31 | Flag.withDescription("Skip Oxlint step"), |
| 32 | Flag.withDefault(false) |
| 33 | ) |
| 34 | |
| 35 | const skipFormatFlag = Flag.Boolean("skip-format").pipe( |
| 36 | Flag.withDescription("Skip Dprint step"), |
| 37 | Flag.withDefault(false) |
| 38 | ) |
| 39 | |
| 40 | // ============================================================================= |
| 41 | // ANSI Colors |
| 42 | // ============================================================================= |
| 43 | |
| 44 | const colors = { |
| 45 | reset: "\x1b[0m", |
| 46 | bold: "\x1b[1m", |
| 47 | dim: "\x1b[2m", |
| 48 | cyan: "\x1b[36m", |
| 49 | green: "\x1b[32m", |
| 50 | yellow: "\x1b[33m", |
| 51 | gray: "\x1b[90m", |
| 52 | white: "\x1b[37m" |
| 53 | } as const |
| 54 | |
| 55 | // ============================================================================= |
| 56 | // Generate Command |
| 57 | // ============================================================================= |
| 58 | |
| 59 | interface GenerateOptions { |
| 60 | readonly skipLint: boolean |
| 61 | readonly skipFormat: boolean |
| 62 | } |
| 63 | |
| 64 | const formatSpecSource = (source: ProviderDiscovery.DiscoveredProvider["specSource"]): string => { |
| 65 | switch (source._tag) { |
| 66 | case "Url": |
| 67 | return source.url |
| 68 | case "File": |
| 69 | return source.path |
| 70 | case "StainlessStats": |
| 71 | return `stainless-stats(${source.statsUrl})` |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | const generateProvider = Effect.fn("generateProvider")(function*( |
| 76 | provider: ProviderDiscovery.DiscoveredProvider, |
| 77 | options: GenerateOptions |
| 78 | ) { |
| 79 | const specFetcher = yield* SpecFetcher.SpecFetcher |
| 80 | const generator = yield* CodeGenerator.CodeGenerator |
| 81 | const postProcessor = yield* PostProcessor.PostProcessor |
| 82 | const fs = yield* FileSystem.FileSystem |
| 83 | |
| 84 | yield* Console.log(`\n${colors.bold}${colors.cyan}${provider.name}${colors.reset}`) |
| 85 | |
| 86 | // Fetch spec |
| 87 | yield* Console.log(` ${colors.dim}Fetching spec...${colors.reset}`) |
| 88 | const spec = yield* specFetcher.fetch(provider.specSource, provider.name) |
| 89 | |
| 90 | // Generate code |
| 91 | yield* Console.log(` ${colors.dim}Generating code...${colors.reset}`) |
| 92 | let code = yield* generator.generate(provider, spec) |
| 93 | |
| 94 | // Apply replacements |
| 95 | const replacements = provider.config.replacementList |
| 96 | if (replacements.length > 0) { |
| 97 | yield* Console.log(` ${colors.dim}Applying ${replacements.length} replacement(s)...${colors.reset}`) |
| 98 | for (const replacement of replacements) { |
| 99 | code = code.replaceAll(replacement.from, replacement.to) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Prepend header if configured |
| 104 | const header = provider.config.headerContent |
| 105 | if (header !== undefined) { |
| 106 | code = `${header}\n${code}` |
| 107 | } |
| 108 | |
| 109 | // Write output |
| 110 | yield* Console.log(` ${colors.dim}Writing output...${colors.reset}`) |
| 111 | yield* fs.writeFileString(provider.outputPath, code) |
| 112 | |
| 113 | // Post-process |
| 114 | if (!options.skipLint) { |
| 115 | yield* Console.log(` ${colors.dim}Linting...${colors.reset}`) |
| 116 | yield* postProcessor.lint(provider.outputPath) |
| 117 | } |
| 118 | |
| 119 | if (!options.skipFormat) { |
| 120 | yield* Console.log(` ${colors.dim}Formatting...${colors.reset}`) |
| 121 | yield* postProcessor.format(provider.outputPath) |
| 122 | } |
| 123 | |
| 124 | yield* Console.log( |
| 125 | ` ${colors.green}✓${colors.reset} ${colors.gray}${provider.config.output}${colors.reset}` |
| 126 | ) |
| 127 | return provider.outputPath |
| 128 | }) |
| 129 | |
| 130 | const generate = Command.make("generate", { |
| 131 | provider: providerFlag, |
| 132 | skipLint: skipLintFlag, |
| 133 | skipFormat: skipFormatFlag |
| 134 | }).pipe( |
| 135 | Command.withHandler(Effect.fnUntraced(function*({ provider, skipLint, skipFormat }) { |
| 136 | const discovery = yield* ProviderDiscovery.ProviderDiscovery |
| 137 | |
| 138 | const providers = yield* Option.match(provider, { |
| 139 | onNone: () => discovery.discover(), |
| 140 | onSome: (name) => discovery.discoverOne(name).pipe(Effect.map((p) => [p])) |
| 141 | }) |
| 142 | |
| 143 | if (providers.length === 0) { |
| 144 | yield* Console.log(`${colors.yellow}No providers found.${colors.reset}`) |
| 145 | return |
| 146 | } |
| 147 | |
| 148 | yield* Console.log( |
| 149 | `${colors.dim}Generating ${providers.length} provider(s)...${colors.reset}` |
| 150 | ) |
| 151 | |
| 152 | for (const p of providers) { |
| 153 | yield* generateProvider(p, { skipLint, skipFormat }) |
| 154 | } |
| 155 | |
| 156 | yield* Console.log(`\n${colors.green}✓ All providers generated successfully!${colors.reset}`) |
| 157 | })) |
| 158 | ) |
| 159 | |
| 160 | // ============================================================================= |
| 161 | // List Command |
| 162 | // ============================================================================= |
| 163 | |
| 164 | const list = Command.make("list").pipe( |
| 165 | Command.withHandler(Effect.fnUntraced(function*() { |
| 166 | const discovery = yield* ProviderDiscovery.ProviderDiscovery |
| 167 | const providers = yield* discovery.discover() |
| 168 | |
| 169 | if (providers.length === 0) { |
| 170 | yield* Console.log(`${colors.yellow}No providers found.${colors.reset}`) |
| 171 | return |
| 172 | } |
| 173 | |
| 174 | yield* Console.log(`${colors.green}Found ${providers.length} provider(s):${colors.reset}\n`) |
| 175 | |
| 176 | for (const p of providers) { |
| 177 | yield* Console.log(`${colors.bold}${colors.cyan}${p.name}${colors.reset}`) |
| 178 | yield* Console.log(` ${colors.gray}spec:${colors.reset} ${formatSpecSource(p.specSource)}`) |
| 179 | yield* Console.log(` ${colors.gray}output:${colors.reset} ${p.config.output}`) |
| 180 | yield* Console.log( |
| 181 | ` ${colors.gray}client:${colors.reset} ${colors.white}${p.config.clientName}${colors.reset}` |
| 182 | ) |
| 183 | yield* Console.log(` ${colors.gray}typeOnly:${colors.reset} ${p.config.isTypeOnly}`) |
| 184 | if (p.config.patchList.length > 0) { |
| 185 | yield* Console.log( |
| 186 | ` ${colors.gray}patches:${colors.reset} ${colors.yellow}${p.config.patchList.length} file(s)${colors.reset}` |
| 187 | ) |
| 188 | } |
| 189 | yield* Console.log("") |
| 190 | } |
| 191 | })) |
| 192 | ) |
| 193 | |
| 194 | // ============================================================================= |
| 195 | // Root Command |
| 196 | // ============================================================================= |
| 197 | |
| 198 | const root = Command.make("effect-ai-codegen").pipe( |
| 199 | Command.withSubcommands([generate, list]) |
| 200 | ) |
| 201 | |
| 202 | // ============================================================================= |
| 203 | // Layer Composition |
| 204 | // ============================================================================= |
| 205 | |
| 206 | // ProviderDiscovery depends on Glob, FileSystem, and Path |
| 207 | // SpecFetcher depends on FileSystem and HttpClient |
| 208 | // CodeGenerator depends on OpenApiGenerator |
| 209 | // PostProcessor depends on ChildProcessSpawner |
| 210 | |
| 211 | // Build up layers with dependencies |
| 212 | const DiscoveryLayer = ProviderDiscovery.layer.pipe( |
| 213 | Layer.provide(Glob.layer) |
| 214 | ) |
| 215 | |
| 216 | const SpecFetcherLayer = SpecFetcher.layer.pipe( |
| 217 | Layer.provide(FetchHttpClient.layer) |
| 218 | ) |
| 219 | |
| 220 | const ServicesLayer = Layer.mergeAll( |
| 221 | DiscoveryLayer, |
| 222 | SpecFetcherLayer, |
| 223 | CodeGenerator.layerSchema, |
| 224 | PostProcessor.layer |
| 225 | ) |
| 226 | |
| 227 | // ============================================================================= |
| 228 | // Export |
| 229 | // ============================================================================= |
| 230 | |
| 231 | /** |
| 232 | * Run the CLI. |
| 233 | * |
| 234 | * @category running |
| 235 | * @since 4.0.0 |
| 236 | */ |
| 237 | export const run = Command.run(root, { version: "0.0.0" }).pipe( |
| 238 | Effect.provide(ServicesLayer) |
| 239 | ) |