feat(client-generator): agent-friendly generators — python, go, php, cli + neutral authoring toolkit and eject workflow - #3016
feat(client-generator): agent-friendly generators — python, go, php, cli + neutral authoring toolkit and eject workflow#3016Marshevskyy wants to merge 214 commits into
Conversation
🦋 Changeset detectedLatest commit: 6cf104a The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Performance Benchmark (Lower is Faster)
|
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1785856098 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1785856098 |
Feedback from Rebilly (Replay Admin + Rebilly Core)Tried
Overall: generation succeeds for both; TypeScript still works when co-selected with 1. Core list one-shot vs
|
Follow-up: Python generator (same snapshot)Tried
Overall: generation succeeds; sync + async clients work; offline smokes pass for Admin cursor pagination and Core offset pagination + Python is in good shape relative to PHP on several points we flagged earlier — calling those out as already-good, then listing Python-specific asks. Already better than PHP (positive)
Python-specific asks1. Output filename is not a normal import pathEmitted names follow the TS stem:
Neither is a valid/idiomatic Python module name ( Ask: emit an importable name by default, e.g. 2. Cross-language auth config key mismatchPython resolves API keys from Ask: document the per-language auth dict shape next to each other (or normalize to one key with a documented alias). Easy footgun when someone copies a TS/PHP snippet into Python. 3. Reserved-word fields (
|
Clarification on single-file outputOne more product note on the earlier “large single-file” asks (PHP ~4.7MB / Python ~4.4MB for Rebilly Core): We like the single-file default and would keep it. It’s a great “Download client” artifact from Redoc/API docs — one Please treat the size/split comments as optional escape hatches for huge descriptions, not a request to change the default:
The other asks still stand, especially for Python: importable filename ( |
Follow-up: Go generator (same snapshot)Tried
Overall: generation succeeds; Same single-file preference as before: keep the one-file default for Redoc/API docs downloads. Core Go is notably smaller than Core PHP (~4.7MB) / Python (~4.4MB), which helps that story. Already in good shape (positive)
Go-specific asks1. Emit
|
Follow-up:
|
|
|
||
| Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, an `eject-generator` command, `x-codeSamples` output, and verification against large real-world descriptions — with every generator now emitting through source-text templates. | ||
|
|
||
| **Note:** the AST exports (`ts`, `printStatements`, `schemaToTypeNode`, …) were removed from `@redocly/client-generator/generate` in favor of the text toolkit (`tsType`, `tsJsdoc`, `codeLiteral`). |
| - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 | ||
| with: | ||
| python-version: '3.12' | ||
| - name: Install httpx (the large-descriptions Python import bar needs it) | ||
| run: pip install httpx | ||
| - name: Cache the pinned GitHub REST description | ||
| uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 | ||
| with: | ||
| path: tests/e2e/generate-client/.cache | ||
| key: large-descriptions-${{ hashFiles('tests/e2e/generate-client/large-descriptions.test.ts') }} |
There was a problem hiding this comment.
I think we need a separate test suite for generator.
I think it will be very big and slow eventually when we start testing compiled languages.
There was a problem hiding this comment.
Agreed, and it's already the biggest suite. Splitting generator tests (client-generator unit +
tests/e2e/generate-client, including the large-description compile bars) into their own vitest
suite and CI job so compiled-language testing can grow without slowing the main e2e job.
| Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. | ||
|
|
||
| Ejectable generators: `python`, `go`, `php` — the language generators built on the language-neutral authoring toolkit. | ||
| The TypeScript `sdk` and its satellite generators are customized through `client.setup`, middleware, and configuration instead; running `eject-generator sdk` prints that guidance. |
There was a problem hiding this comment.
I think typescript and satellites should be ejectable too as separate generators
There was a problem hiding this comment.
Agreed. Docs now say every built-in is ejectable. Mechanically the language generators are one
self-contained file, while the TypeScript ones are thin entries over shared emitters, so eject
will bundle each generator with the emitters it uses into a single .mjs, keeping the
@redocly/client-generator imports external. sdk.mjs will be large, but it's one file you own,
which is the point.
| | ---------- | ------- | ---------------------------------------------------------------------------------------------------- | | ||
| | generator | string | Built-in generator to eject: `python`, `go`, or `php`. | | ||
| | `--dir` | string | Directory to eject into. Default `./generators`. | | ||
| | `--update` | boolean | Three-way merge a newer generator version into your customized copy; conflicts get standard markers. | |
| Ejecting writes four things: | ||
|
|
||
| - `<dir>/<name>.mjs` — the generator, the exact code the built-in runs, readable plain ESM. | ||
| - `<dir>/.pristine/<name>.mjs` — a pristine snapshot (commit it); `--update` uses it as the merge base. |
There was a problem hiding this comment.
I think we don't need it. can update compare with the latest upstream version?
There was a problem hiding this comment.
Dropping .pristine/ entirely. --update now uses the version recorded in the ejected file's
header as the merge base, so nothing extra is committed and there's nothing to keep in sync.
| ### Code samples for docs | ||
|
|
||
| A generator that knows how to call an operation can also document it: implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) per operation. | ||
| With `codeSamples: true` in the `client` block, generation collects every selected generator's samples into `<output stem>.code-samples.yaml` — an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) adding `x-codeSamples` per operation, ready for docs tooling to apply. |
There was a problem hiding this comment.
I think we don't have support for Overlays. We need to either add support for overlays in bundle or figure out some other format
There was a problem hiding this comment.
we will think about it in separate PR
|
|
||
| The `cli` generator emits `<stem>.cli.ts` — a zero-dependency, bin-ready command-line interface over the generated client. | ||
| Path params are positional, query params become typed `--kebab-name` flags (enums list their choices in `--help`, array params repeat the flag), and JSON request bodies arrive via `--json '<json>'`, `--json @file.json`, or `--json @-` (stdin). | ||
| When `zod` is co-selected, requests are validated before they are sent. |
There was a problem hiding this comment.
I think it shoud just do it by default without the need to coselect zod
There was a problem hiding this comment.
Done. cli declares requires: ['sdk', 'zod'] and the resolver now pulls prerequisites in, so
--generator cli alone emits all three files and validates (exit 3) with nothing extra to
select. Same for the wrappers: --generator tanstack-query brings the sdk it wraps. The
trade-off is a zod runtime dependency for the CLI
| npx tsx src/client.cli.ts orders listOrders --status open --limit 10 | ||
| npx tsx src/client.cli.ts orders createOrder --json @order.json | ||
| npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per line | ||
| npx tsx src/client.cli.ts schema createOrder # request/response schemas |
| `tanstack-query`, `swr`, and `cli` wrap the throw-mode `sdk` client, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. | ||
| See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. | ||
|
|
||
| ### Generated CLI |
There was a problem hiding this comment.
I think it should also be able to generate documentation for the CLI (markdown file)
But the same applies for other clients so it can be the next stage.
There was a problem hiding this comment.
It may need some documentation templates that can be ejected then too.
There was a problem hiding this comment.
Built it — there's now a cli-docs generator that writes .cli.md beside the generated CLI: usage, global flags, credential environment variables, the exit-code table, and one section per command with its positional arguments and flags (type, required, enum choices, description). --generator cli-docs is the whole selection, since it pulls in the CLI it documents.
On ejectable templates: the renderer is the template. Every built-in generator is ejectable now, so redocly eject-generator cli-docs hands you the page layout as code you own — same language and toolkit as everything else, no template syntax and no extra dependency to learn. Light customization stays declarative through the generator's own options (title, frontmatter) under client.options.cli-docs; anything structural is an eject. We deliberately didn't add a template engine, because that would be a second customization mechanism sitting next to eject.
One property worth calling out: the page renders from the same command table the CLI dispatches on, and the same functions the runtime uses to address groups and name credential variables — so the docs can't drift from the tool. An e2e walks the generated CLI's own --help at both levels and fails if any command it lists is missing a section.
| The `python` generator emits a self-contained `<stem>.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): | ||
| typed dataclass models (allOf flattened, enums, discriminated unions decoded by their discriminator), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`<op>_pages()` / `<op>_items()`, `async for` variants), SSE streaming, multipart bodies, `<op>_with_headers()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. |
There was a problem hiding this comment.
Why we need all of this details. It should be same as typescript one
There was a problem hiding this comment.
it doesn't support any of options we support for typescript? why?
There was a problem hiding this comment.
You were right on both counts, and one of them was a bug: serverUrl was silently ignored by the
language generators (fixed, 795f58ca9). dateType: Date is now implemented for all three
(ea3795e47). Options a language genuinely can't apply no longer vanish — they warn with the
reason, or fail fast where the output would be wrong (d6cf2ba4e). The docs no longer enumerate
per-language caveats: the language sections state that these are the TypeScript client in another
language, with one table for the differences the language forces (error idiom, date type, header
envelope, auth shape, reserved-word suffix, file layout).
| A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). | ||
|
|
||
| A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root, and build real TypeScript with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: | ||
| A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root. |
There was a problem hiding this comment.
maybe it should also export additional options this generator may support? (e.g. as json schema)
We can support them via config only for example.
There was a problem hiding this comment.
Yes, a generator declares options as a JSON Schema, validated before run, and publishers set them under client.options.<generator> in config.
…, discriminatorCases, nullability, enums)
… from the root and /generate
…oaded built-in generators
… hooks, sdk as reference implementation
…d test, and guide rework
…lkit-import names
… with build-time embed
… table with reflective hydration
…mples and a dogfooding guard
… helpers — slice-2 lessons landed, python items typed
…tdlib-only, vetted, embedded
The guide had one section per docs generator; it now has one "Reference documentation" section for the switch, with a table of which generator writes which page. The command reference gains `--docs`, the client reference gains `docs` and `docsFrontmatter`, the eject page says a generator carries its page, and the custom-generator guide shows the `docs` hook with `renderReferencePage`.
…output path A sample hook cannot know which module to import unless it is told where the run writes, because each language rewrites the --output anchor its own way. So `SampleContext` now carries `outputPath`, threaded from both call sites — the codeSamples overlay and every `docs` hook — and each hook derives its own identity from it. Reported by the review bot: `pythonSample` emitted `from client import Client` while the generator writes `openapi_client.py` for `openapi.client.ts`, and `goSample` hardcoded `client.New` while `goPackage` renames the clause. Checking the other two hooks found the same defect unreported: typescriptSample imported './client', which is the wrong name for most stems and extensionless under ESM resolution, and phpSample required no file at all, so the snippet could not run. Both the documentation pages and the codeSamples overlay read these hooks, so a wrong name shipped in two places.
Review asked why a tag is needed to address a command. It is not: a bare operationId resolves whenever it is unambiguous, and the docs simply taught the longer form first. The guide and the cli example now lead with `<bin> listOrders`, and show the tag slug as what resolves an ambiguous name. The guide also says what a tag group is still for — organizing `--help` for an API with hundreds of operations — and that an operation without an operationId still gets a command, named from its method and path (`GET /pets` becomes `getPets`), so a description that declares none still has a complete CLI.
Three gaps a reader hits before anything else. The command page opened with five paragraphs of orientation, so a reader had to read before running anything. It now opens with a quickstart: one npx line, the code it produces, and the flag list for the rest. Nothing told an app with a hand-written client how to move. A new guide does: generate beside the old client, a table mapping each hand-written piece to its replacement, how to migrate call sites, what to do when the description turns out to be wrong, and how to migrate the tests instead of mocking the client away. Nothing documented `ClientConfig.fetch`, so "can it go through my configured request library?" had no answer in the docs. The usage guide now has an HTTP layer section with an adapter example, and says to prefer middleware for behavior that belongs to the API.
The quickstart and the migration guide invoked the command through `npx @redocly/cli`, which no other generate-client example does. Every command page assumes an installed `redocly`, and the installation page already covers the npx form.
…quest `client.options.python.models: pydantic` emits `BaseModel` classes instead of dataclasses, for the half of the Python ecosystem that expects them. A wire name that is not a legal field name becomes `Field(alias=…)` with `populate_by_name=True`, so `_field_map` and its `ClassVar` import are not emitted in that mode. Nothing else changes: the same class names, the same field names, the same client, the same runtime. Switching modes does not touch a call site. One runtime serves both modes. `_decode.py` dispatches on the target — a class with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively — and `encode` mirrors it with `model_dump(by_alias=True, exclude_none=True, mode="json")`. A second runtime variant would double the surface that has to stay in step, and pydantic's `ValidationError` already subclasses `ValueError`, so probing union members needs no new except clause. The mode adds a dependency, so the generated header asks for `httpx pydantic` instead of letting the import fail with nothing to act on. CI installs pydantic so the round-trip bar runs rather than skips.
"You support a validation library I don't use" has one honest answer: write the generator, it is short. So the custom-generator guide gains a Recipes section — a schema library the built-ins do not cover, a framework wrapper, a shape your codebase already uses, and changing a built-in through eject rather than starting from a blank file. The first recipe now points at a runnable example instead of prose. The examples suite discovers it and generates it, and `typecheck:examples` checks it against real valibot, so the recipe cannot rot. Writing it also proved its own worth: the first version mapped every string to `v.string()`, and `tsc` rejected it, because a `format: binary` property is a `Blob` in the client. Reading `metadata.format` is now both the fix and a bullet in the example's README.
Review asked why the generated client redeclares what the instance already has:
`export const setBearer = client.auth.bearer;`. It is a fair question. Setting a
credential had three spellings — the setter, `configure({ auth })`, and
`client.auth.*` — for one act, which is the rule about one name per thing that
this generator advertises about operation names.
So the setters are gone, and two things go with them. `emitters/auth.ts` existed
only to derive setter names, including the `setApiKey` versus `setApiKeyKeyA`
disambiguation that several apiKey schemes forced; `client.auth.apiKey(key,
value)` addresses a scheme by the key the description already gives it, so that
problem does not exist. And the two identifier reservations those names fed are
gone, so a description may now name an operation or a schema `setBearer` and keep
the name — the former tests are turned around to assert exactly that.
Callers move to `configure({ auth })` or `client.auth.*`: two e2e consumers, the
configure-and-middleware example, and the guide's Authentication section, which
now documents two ways instead of three.
…etter The injection bar claimed to check that a hostile operationId becomes a single valid identifier "in the flat call sugar", with a pattern that cannot match operation sugar: a generic parameter list and a return-type annotation sit between the name and the arrow. What satisfied it was the credential setter, which the hostile security scheme also produced — so removing the setters is what surfaced this. It now captures the exported name and the client method it forwards to and requires them to be the same identifier, which is the invariant the comment always described.
| # A pydantic model validates itself, aliases included. `ValidationError` | ||
| # subclasses `ValueError`, so union member probing above still works. | ||
| if isinstance(type_, type) and hasattr(type_, "model_validate"): | ||
| return type_.model_validate(data) |
There was a problem hiding this comment.
Pydantic skips nested discriminators
High Severity
Under models: pydantic, decode hands an entire object tree to model_validate, so nested discriminated unions never consult the DISCRIMINATORS registry. Dataclass mode still walks fields and routes those unions correctly. Nested polymorphic responses can hydrate as the wrong member or fail validation while the same description works in the default mode.
Reviewed by Cursor Bugbot for commit 9ed3ffd. Configure here.
A flat free function had two argument shapes: `listOrders({ limit: 20 })` for the
call, and `listOrders.pages({ params: { limit: 20 } })` for its iterators, which
were bound straight from the grouped client method. The guide documented that as
an exception, which was the tell — review asked twice why one function changes
its interface, and this was the remaining case.
The emitter now wraps the iterators the same way it wraps the call, so `.pages()`
and `.items()` take exactly the arguments the function takes. `init` is a plain
`RequestOptions` rather than the envelope-aware generic, because `envelope` means
nothing for an iterator. Grouped mode is untouched: it already re-exported the
client methods.
One input shape per generated client, and the guide now says so instead of
carving out an exception.
Review pointed out that `binName` names the command but installs nothing, and that the docs never showed how to close that gap — they said twice to "point the `bin` field at the compiled file" without an example. The CLI section now has a "Ship it as a real command" step-by-step: `"type": "module"` with the confusing tsx error it prevents, a package.json declaring `bin` plus a tsc build, and `npm link`. It also says to keep the `bin` key and `binName` identical, or the help output names a command that does not exist, and that `binName` is cli-only — the language SDKs are libraries with no command. The config reference, the command page, and the `--bin-name` flag description now state that it installs nothing and point at that section.
The guide claimed a group disambiguates commands that share a name. That cannot happen: when a description declares the same operationId twice, the generator reports it and emits the second as `<name>_2`, so command names are unique. The group organizes `--help` — which is what it is for on an API with hundreds of operations — and addressing by group stays available for the reader who just browsed that group, but it is never required.
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1787063344 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1787063344 |
The parser read a leading word as a group whenever it matched a tag slug, so an untagged operation of that name could not be run at all: the group branch took the word, and no group prefix exists for an untagged operation. Its name now wins over a group of the same slug. A tagged operation keeps yielding to group help, since `<its tag> <name>` still runs it. The generator warns once per run for either case and names the address that works, and the CLI guide states how the first word resolves.
…stale import Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a union nested in a model was resolved by pydantic's shape matching and never reached the discriminator table that dataclass mode walks: an item tagged `dog` could hydrate as `Cat`. Such a union now carries its discriminator into the annotation, and each member pins its mapped value as a `Literal`, which is what pydantic needs to resolve it at any depth. Also: a package-mode client no longer imports `TokenProvider`. It typed the credential setters that this branch removed, and an unused type import fails a consumer's `noUnusedLocals` build. Docs: the migration table no longer points at the removed setters, and its pagination row keeps Vale happy.
…ayer
A generated TypeScript operation took positional arguments and was exported twice:
as a wrapper function and as a method on the client instance. The same name
therefore had two argument shapes, and the wrapper's shape stopped being shorter
as soon as an operation had more than one kind of input — a required body landing
after an optional query bag forced `updateOrder('ord_1', {}, body)`.
Operations now take one object, grouped by transport layer:
updateOrder({ path: { orderId }, query: { dryRun }, headers: {…}, body: {…} })
`argsStyle: grouped` is the default. `flat` remains, redefined as the same object
with the layers merged into one level; it merges a required object body and keeps
a `body` key for a body it cannot merge (optional, array, scalar, binary). An
operation whose merged names would collide keeps the grouped shape.
The module-level exports are bindings of the client's own methods, so an operation
is one function reachable two ways rather than two functions. That deletes the
wrapper emitter, the flat-iterator patch it needed, the path-param binding
identifiers, and the guard that rejected a path parameter named after an argument
slot — a layer key cannot collide with a wire name.
`<Op>Params` is now `<Op>Query`, beside a new `<Op>Path`. The runtime converts a
merged call using the descriptor's parameter list, so both styles share one path
through `splitArgs`, and the generated CLI builds whichever shape its client takes.
… language OpenAPI lets one operation use the same parameter name in two locations, and the Python, PHP, and Go clients each pass one argument per parameter. Their generated modules did not parse at all for such a description: `id` in the path and in the query produced `def get_thing(self, id, *, id=None)` (SyntaxError), a redefined `$id` (PHP fatal), and a duplicate `body` argument in Go. The same break came from a parameter named after an argument the method declares itself — `body`, `headers`, `timeout`, `params`, `ctx`. Parameter names are now derived through one namespace per signature, seeded with those argument slots, so the later name moves aside the way each language spells names: `id_2`, `$id2`, `id2`. The wire name is untouched, so both values still reach the API as written, and the pipeline reports the collision once so the publisher can rename it in the description instead. The rule lives in the authoring toolkit as `uniqueIdentifiers`, beside `identifierFor`, so a generator written by someone else inherits it. Each language skill records it, and the reference guide documents the rename. Each language's e2e suite now generates from a fixture built out of these names and proves the result is real code: `py_compile`, `php -l`, and `go build`.
| args: OperationArgs, | ||
| config: ClientConfig | ||
| ): OperationArgs { | ||
| return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; |
There was a problem hiding this comment.
Flat collision fallback ignored at runtime
High Severity
When argsStyle is flat, an operation whose merged names collide keeps a grouped <Op>Variables type, but createClient still runs every call through namespaceArgs. A typed call with path/query is treated as unknown top-level keys (or as body fields). The CLI also always flattens path and query, so it cannot send both values either.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 57f009d. Configure here.
| } | ||
| const collisions = [...counts].filter(([, count]) => count > 1).map(([paramName]) => paramName); | ||
| return collisions.length > 0 ? { collisions } : { mergeBody }; | ||
| } |
There was a problem hiding this comment.
Intersection bodies skip merge collisions
Medium Severity
flatInputShape treats a required intersection (allOf) body as mergeable but only walks properties when the resolved schema is kind === 'object'. Name clashes between parameters and allOf body fields are not counted, so those operations stay flat and namespaceArgs sends the shared name only as a parameter, omitting it from the JSON body.
Reviewed by Cursor Bugbot for commit 57f009d. Configure here.
| const iterNames = uniqueIdentifiers( | ||
| op.queryParams.map((param) => param.name), | ||
| { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } | ||
| ); |
There was a problem hiding this comment.
Pagination wrappers rename query args differently
Medium Severity
writeMethod unique-ifies path and query names together, but _pages/_items unique-ify only query names. A query parameter that was emitted as id_2 on the operation is still id on the iterators, so copying the method’s keyword arguments into .pages() raises TypeError.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 57f009d. Configure here.
| ): string { | ||
| const advance = paramsAccess(spec.param); | ||
| // Where the caller's own starting value lives, in the sdk's spelling for a query param. | ||
| const given = argsStyle === 'flat' ? `vars.${advance}` : `vars.query?.${advance}`; |
There was a problem hiding this comment.
Flat infinite query breaks unsafe names
Low Severity
For argsStyle: flat, initialPageParam is emitted as vars.${advance} where advance is ["wire-name"] when the pagination param is not a safe identifier. That prints vars.["after-cursor"], which is a syntax error. Grouped mode keeps valid vars.query?.["after-cursor"] optional chaining.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 57f009d. Configure here.
…self from the command it runs as
| : []), | ||
| `export const wiring: CliWiring = { | ||
| name: basename(process.argv[1] ?? ${codeJson(options.stem)}), | ||
| envPrefix: ${codeJson(constantCase(options.stem))}, |
There was a problem hiding this comment.
CLI help uses script filename
Medium Severity
Generated CLI wiring sets name to basename(process.argv[1]), so help prints the script file (including .cli.js or .ts) whenever the process is not started through a same-named symlink. That is the documented bin → dist/cafe.cli.js layout on Windows (*.cmd wrappers pass the target script) and any node/tsx run of the file. Help then names a command that is not what the user typed, which is the problem removing binName was meant to avoid.
Reviewed by Cursor Bugbot for commit 1ba6e7a. Configure here.
The entry had grown to eight paragraphs of implementation detail for one feature. The changelog names what a user gets; the guides carry the rest.
…and cli help honest
Five review findings, each reproduced before it was fixed.
A flat-style client rejected its own typed call. An operation whose merged names
would collide keeps the namespaced input type, but every call still went through
`namespaceArgs`, so `{ path, query }` arrived as unknown keys. The descriptor now
carries `argsStyle: "grouped"` for exactly those operations, and the runtime and
the CLI dispatcher both read it — the type and the wire cannot disagree.
The collision check missed an `allOf` body. `mergeBody` accepted an intersection
but counted properties only for a plain object, so a parameter sharing a name with
an allOf body field produced a merged shape that dropped the value from the body.
One recursive walk now collects the names of every member.
A python iterator asked for the path template. `build_url` was called with an
empty path dict, so a paginated operation under a path parameter requested
`/orders/{orderId}/items` literally, and the caller had no argument to pass the
value in. The iterators now take the same path arguments as the operation, named
through the same namespace, so a name the method moved aside is the same name
there. Go and PHP already did this; python now matches them.
A flat infinite query emitted `vars.["after-cursor"]`, which does not parse.
Member access follows the name's shape in both argument styles.
Help named the script file. `basename(process.argv[1])` prints `cafe.cli.js` for a
Windows shim, a `node dist/…` run, or `tsx client.cli.ts` — a command nobody can
type, which is what reading the invocation was meant to prevent. `invokedName`
drops a script or shim extension and the `.cli` marker: a `mycafe` symlink still
prints `mycafe`, and running the file prints `cafe`.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 18 total unresolved issues (including 16 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1bdd8ec. Configure here.
| ...(options.runtime === 'package' | ||
| ? [ | ||
| 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";', | ||
| ] |
There was a problem hiding this comment.
Package CLI cannot import invokedName
High Severity
Package-mode CLI output now imports invokedName from @redocly/client-generator, but the package root still only re-exports runCli from the CLI runtime. Generated runtime: package CLI modules therefore fail to load or type-check as soon as they evaluate help wiring.
Reviewed by Cursor Bugbot for commit 1bdd8ec. Configure here.
| // An unmerged body keeps the `body` key, which a parameter of that name would shadow. | ||
| if (op.requestBody && !mergeBody) counts.set('body', (counts.get('body') ?? 0) + 1); | ||
| for (const property of bodyProperties ?? []) { | ||
| counts.set(property, (counts.get(property) ?? 0) + 1); |
There was a problem hiding this comment.
AllOf overlap treated as name collision
Medium Severity
mergedBodyProperties concatenates property names from every allOf member and later increments a collision counter for each occurrence. Shared names across members (normal in composed schemas) look like two layers using the same key, so a valid flat merge is rejected and the operation is forced back to grouped inputs.
Reviewed by Cursor Bugbot for commit 1bdd8ec. Configure here.
…refinement as a collision A package-mode CLI imported `invokedName` from the package root, which exported only `runCli` — so every `--runtime package` CLI failed at import. The root exports it now, and a guard test reads the value names out of the emitted import line and checks each against the root's exports, so the next name added there cannot drift. `allOf` members routinely redeclare a property to refine it, and counting the name once per member read as two layers using one key: a mergeable operation fell back to the namespaced shape. The body's property names are deduplicated, because the merged body carries one key per name either way.
tatomyr
left a comment
There was a problem hiding this comment.
Left minor suggestions, otherwise looks good 💪.
| "import": "./lib/generate.js", | ||
| "default": "./lib/generate.js" | ||
| }, | ||
| "./runtime-sources": { |
There was a problem hiding this comment.
Why do we need this? Cannot the runtime sources be exported from the root?
| @@ -1,11 +1,47 @@ | |||
| # Testing | |||
There was a problem hiding this comment.
Since you've modified this rule, could you add a note that unit tests should reside in a __test__ folder located at the same level as the file being tested (in other words, no nesting in __tests__ folders)? I noticed that you placed this test file (and probably some others) incorrectly: packages/cli/src/__tests__/commands/eject-generator.test.ts. The commands folder exists for historical reasons, but it'd be easier to develop/review if we follow one pattern at least when working on new features.
| `Third-party software bundled in @redocly/cli\n\n${sections.join('\n\n')}\n` | ||
| ); | ||
|
|
||
| cpSync( |
There was a problem hiding this comment.
This seems fragile. I tried to eject a generator in the repo itself during development (npm run cli -- eject-generator python) and got this:
An unexpected error occurred. This is likely a bug that should be reported.
Error: ENOENT: no such file or directory, open '.../packages/cli/src/commands/eject-assets/generators/python.mjs'
at readFileSync (node:fs:441:20)
at handleEjectGenerator (.../packages/cli/src/commands/eject-generator.ts:524:17)
at <anonymous> (.../packages/cli/src/wrapper.ts:117:15)
I suggest making it a compile step unless I'm missing something.
| const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); | ||
| const wired = wireConfig(config.configPath, name, configEntry); | ||
| logger.info( | ||
| `Ejected the "${name}" generator to ${printedTarget}.\n` + |
There was a problem hiding this comment.
I'd add an instruction of how to run the ejected generator right here in the ejection message for the sake of users' convenience. Otherwise they have to find the docs themselves (BTW, the link to the docs also wouldn't hurt either).


What this adds
Client generation for languages beyond TypeScript, built so that both we and our users extend it with AI agents driven by per-generator design skills.
New built-in generators
python— self-contained client over httpx: typed dataclasses, sync + async clients, pagination iterators, SSE, multipart, retries, discriminated-union decode via aDISCRIMINATORSregistry.go— stdlib-only single file: structs with json tags, typed-const enums,(T, error)methods,context.Context, range-over-func pagination, SSE.goPackagesets the package clause.php— PHP ≥ 8.1 over the curl extension, zero Composer dependencies: promoted-constructor models, native enums, named-argument methods,Generator-based pagination, SSE over a curl_multi pump.cli— a bin-ready command-line interface over the sdk. It validates requests by default: selectingclipulls in the generators it needs (typescriptandzod), so nothing extra has to be listed.binNamenames the command.Reference documentation, behind one switch
client.docs: true(or--docs) writes the reference documentation for whatever the run generates. Documentation is not a generator name: it is adocshook on the generator, so each generator documents itself and writes one Markdown page next to its own output —<stem>.cli.mdfor the CLI (usage, global flags, credential variables, exit codes, every command), and<stem>.<language>.mdfor each SDK (security schemes, then every operation with its parameters, body, response type, and a call sample in that language).Three consequences worth naming: one switch covers every language, so a newly documented generator needs no new flag; each page takes its snippets from that generator's own
samplehook and the CLI page renders from the same command tablerunClidispatches on, so a page cannot describe anything but the artifact beside it; and ejecting a generator takes its page with it, which is what makes the documentation templates ejectable. A generator that documents nothing (zod, the framework wrappers) writes no page, and--docswith a selection that documents nothing warns instead of doing nothing silently.All three SDK languages have parity on auth (bearer/basic/apiKey with token providers), retries with
Retry-After+ jittered backoff, timeouts, idempotency keys, middleware, pagination, SSE, multipart, binary downloads, templated-server helpers (Serversclass /<Name>URLfunctions), and response-header envelopes (<op>WithHeadersvariants mirroring the TS{ envelope: true }option from #3002).The authoring model
Printer(indentation-aware source-text builder), naming (casing,identifierFor,RESERVED_WORDS), schema semantics (flattenAllOf,discriminatorCases, nullability,enumValues,headerCoerceType,schemaAtPointer),paginationRuleFor, andNotSupportedErrorfor rejecting an option the generator can't honor. A dogfooding guard test pins that the built-in language generators use nothing else.typescriptdependency in the authoring path. The TypeScript emitters render source text like everything else; thets.factoryexports are gone.typescriptremains an optional peer needed only to bake a--setupmodule, the one place we parse TypeScript.run(unknown key, wrong type, value outside anenum, missing required key) with defaults applied. Publishers set them underclient.options.<generator>.Eject workflow
redocly eject-generator <name>vendors any built-in generator into the repo as an editable.mjs. A language generator ships as its own source; a TypeScript generator ships bundled with the emitters it uses (unminified, one comment per source module), sotypescript,zod,mock,swr,tanstack-query,transformers, andcliare ejectable too, each carrying itsdocshook. An ejected-unmodified generator produces byte-identical output, proven in e2e.Eject also:
.claude/skills/<name>-generator/SKILL.md) plus the shared authoring skill, where agents auto-load them, and leaves a short pointer beside the code;@redocly/client-generatorindevDependenciesand adds the entry toclient.generators, editing the config text so comments survive;--updatethree-way merges with no committed snapshot: the merge base is the version recorded in the ejected file's own header, fetched from the registry when it differs from the installed one.Compatibility
Generator compatibility is the package version under semver, not an invented number: a generator declares
requiresGenerator(^1.2.0,~1.2.0,>=1.2.0, or an exact version), and a CLI outside that range says which version it ships, which the generator needs, and how to reconcile them. Ejected generators record it automatically. Custom generators also run behind a validated contract: load-time shape validation, output-path containment (no writes outside--output), andrun()result validation. An IR-shape snapshot test forces the "additive or breaking?" question on any model change. Failures are attributable (Generator "<name>" failed: …) and categorized in telemetry along with eject/update outcomes (coarse categories only — never file contents, paths, or user-chosen names).Verification
tsc/py_compile+import /go build+go vet+gofmt/php -l+require(tests/e2e/generate-client/large-descriptions.test.ts).npm run client-generatorsruns the client-generator unit tests plustests/e2e/generate-client, sharded two ways, so a growing set of compiled-language bars cannot slow the job everything else shares.npm run e2ecovers everything else.tests/e2e/generate-client/examples/.Docs
New command page (
eject-generator), a customize-client-generation guide covering thedocshook for custom generators, per-language usage notes, a CLI composition section, and configuration reference updates (goPackage,binName,options,codeSamples,docs,docsFrontmatter).Review feedback folded in along the way: the per-language comparison table was dropped because every row of it restated the configuration reference or the sections around it; the language runtimes moved from three sibling folders to
runtime/<lang>; and the guide now explains that the word after the bin name is a tag slug for one API and the api alias for a composed binary.Note for reviewers
The per-operation pagination extension is now
x-redoclyPagination(camelCase, like every other Redocly extension). A description that still declaresx-redocly-paginationsilently loses its pagination rule — called out in the changeset.Check yourself
Security
Note
High Risk
Large experimental surface area (new languages, eject/merge, generator contract, CLI composition) plus a breaking pagination extension rename; failures would affect generated SDKs/CLIs consumers rely on.
Overview
Adds agent-oriented client generation beyond TypeScript: built-in
python,go,php, andcligenerators (self-contained SDKs or a zod-backed CLI), with--docs/client.docsemitting per-generator Markdown reference pages.Renames the default TypeScript generator from
sdktotypescript, expandsclientconfig (goPackage,options,codeSamples,cliOutput,docs, etc.), and documentsredocly eject-generatorto vendor and customize any built-in generator (plus agent skills and--updatemerges). Custom/ejected generators use a language-neutral authoring toolkit, optionalrequiresGeneratorsemver checks, and generator-ownedsample/docshooks.CI and dev workflow split slow compile bars into
npm run client-generatorsand a dedicated sharded GitHub Actions job (Python/httpx, cached large OpenAPI fixtures); generalnpm run e2eno longer runsgenerate-client. CLI publish build copieseject-assetsintolib/.Docs and contributor guides are updated (migration guide, usage for multi-language SDKs/CLI composition, telemetry for
generate-client/eject-generator). Breaking note: pagination extension isx-redoclyPagination(camelCase); legacyx-redocly-paginationis no longer honored.Reviewed by Cursor Bugbot for commit 6cf104a. Bugbot is set up for automated code reviews on this repo. Configure here.