fix(cli-generator): retry safety, $ref validation, and launcher exit codes - #17545
Conversation
Nine fixes from AgentMail's post-1.0.0 review (they shipped on 0.31.2; a few items in that report were already fixed between then and 0.38.4 — see the PR body for the triage). **Retry safety.** `decide_retry` was passed `method.idempotent || idempotency_key.is_some()`, and since the auto key is generated for every POST/PUT/PATCH, that made every non-idempotent operation retry-eligible: a 5xx on a create retried ~4x against endpoints with no idempotency support at all. Retry-safety now requires a key the *server* is known to honor — `x-fern-idempotent: true` or an explicit `--idempotency-key`. Compounding it, the marker *suppressed* key generation: it was read as "the caller supplies one", so a marked operation invoked without the flag retried with no key, while the same operation without the marker got one. The marker only means the operation exposes the flag, so only a key actually present now suppresses generation. `sdk_executor` had the same shape hardcoded (`true, // SDK requests are treated as idempotent`), making every custom-command POST retry as idempotent. It now derives safety from the request's own `Idempotency-Key` header, the only signal available to a transport handed a built request. **Validation.** `validate_value` had an object branch and nothing else, so a `$ref` to a scalar or array component accepted any value. On a spec where most schemas are component refs, validation was effectively off and `--dry-run` exited 0 on a malformed body. Scalars and arrays are now type-checked (sharing one `check_json_type` with the inline path so they cannot drift again), and bare-`$ref` component chains are followed via a bounded iterative resolver. Relatedly, `convert_parameter` had no access to component schemas and `OpenApiParamSchema` had no `$ref` field, so a `$ref`'d parameter deserialized to all-`None` — no type, enum, format or bounds. Array-typed parameters also never set `repeated`, for any spelling, so `--labels a --labels b` was rejected outright. **Agent contract.** Pagination flags are registered only where the spec says how to page; they were advertised everywhere and the executor's `pageToken` / `nextPageToken` fallback silently returned page 1 with exit 0. Generated skills now verify auth with `auth status` instead of `--help`, which reads no credentials. **Packaging.** The npm launcher called `process.exit(e.status)` on a signal death, where `status` is null and Node coerces it to 0 — CI timeouts, SIGSEGV and OOM-kills all reported success. Signal deaths now exit 128+signum. And any SemVer pre-release gets a non-latest dist-tag; matching only `-alpha`/`-beta` let `-rc.1` and `-next.1` move `latest`. **Custom commands.** The SDK bridge built `ClientConfig::default()`, whose `base_url` is empty for any API with no declared environment, so `sdk::client(ctx)` failed on a relative URL. It now seeds from a new `AppContext::effective_base_url()` mirroring the built-in path's resolution. 1922 lib tests pass (up from 1916) plus a wiremock suite reproducing the reported A/B on a 500ing mock.
…ase URL Caught regenerating AgentMail's real CLI: their spec declares `servers:` on each operation and none at the root, so `AppContext::effective_base_url()` read `doc.root_url` (empty) and the bridge still produced a client with no host — `SDK network error: builder error` on every custom command, the same symptom the fix was meant to remove. The built-in path already handles this via `effective_root_url(method, doc)`, but the bridge has no operation in scope. It now falls back to the first operation that declares a server, walked in sorted order so the answer is stable across runs (the resource/method trees are `HashMap`s). Verified against their spec: the custom command now reaches `api.agentmail.to` and returns a real 403 for a bad key instead of failing to build a URL.
End-to-end verification on AgentMail's real configGenerated their CLI with this branch via This found a real flaw in the fix — pushed as a second commitTheir spec declares Before: Results
For 1b I restored the Items 3 and 7 are workflow-template changes; the output mode here is 1923 lib tests pass (up from 1916), including a new |
…nfig
**Build profile (report item 8).** `ci.yml` built `cargo build --release`
while cargo-dist's `release.yml` builds `--profile dist` (release + thin LTO),
so npm and the GitHub Release shipped different binaries for the same tag —
9.8 MB vs 10.9 MB in the reporter's measurements. npm is a distribution
channel, so it now uses the profile named `dist`. The copy path moves with it:
a custom cargo profile writes to `target/<triple>/dist/`, not `.../release/`.
**LICENSE (report item 9).** The generator shipped `Cargo.toml
license = "MIT"` (from `packageIdentity.license`) alongside an Apache-2.0
`LICENSE` file — a direct contradiction the reporter asked us to settle before
wide adoption.
The Apache-2.0 file was not a licensing decision: `copySdk` copies `./sdk/`
verbatim and `sdk/LICENSE` simply wasn't excluded, so the vendored runtime's
license rode along into all 49 seed fixtures. No other Fern generator ships a
LICENSE by default — a survey of every seed tree finds exactly two LICENSE
files outside `seed/cli`, both from explicit `custom-license` fixtures, and
`RustProject.writeLicenseFile` returns early for anything but `type: custom`.
So the CLI now behaves like the fleet: `LICENSE` is excluded from the SDK copy
via `sdk/.sdk-ignore.json`, and a new `writeLicense` step emits one only when
the customer configures `license: { type: custom }`, copying the Fern CLI's
`/tmp/LICENSE` mount and honoring a configured filename. A basic license
(`license: MIT`) mounts no file and stays package metadata, as elsewhere.
License text is never authored here.
Verified by regenerating AgentMail's CLI: no LICENSE emitted, `license = "MIT"`
still stamped in Cargo.toml.
The generated repo contains `fern-cli-sdk` copied verbatim by `copySdk`, and Apache-2.0 s4 requires retaining that license notice when redistributing the covered code. The previous commit stopped shipping the runtime's `LICENSE` file — it contradicted the `license` field `packageIdentity` writes into Cargo.toml, and no other Fern generator ships a LICENSE it wasn't asked for — which removed the contradiction but also removed the notice. The README now carries it as its own `## Attribution` section, so the notice survives while the repo's own license stays whatever the customer declared. Emitted as a discrete Block, which means `mergeWithExisting` treats it like any other section: a customer who rewords it keeps their wording across regens. Verified on AgentMail's generated CLI: the section renders, reaches the table of contents, and no LICENSE file is emitted.
…are $ref A differential run against the ElevenLabs spec showed the parameter fix in 0508357 reached 2 of its 31 array query parameters. The other 29 are spelled `anyOf: [{type: array}, {type: 'null'}]` — pydantic's `Optional[list[T]]` — and `OpenApiParamSchema` had no composition fields at all, so those parameters still deserialized to all-`None`. The commit message claimed arrays never set `repeated` "for any spelling"; the fix only covered the two spellings it knew how to see. `resolve_param_nullable_branch` is the parameter counterpart to `recognize_nullable_composite`, which operates on `OpenApiSchemaObject` and so only ever reached body properties. It takes the single non-null branch of a `oneOf`/`anyOf`, resolves it through `$ref`, and leaves true unions (more than one non-null branch) opaque. Verified on the real spec, before -> after: --voice-ids v1 --voice-ids v2 rejected -> ?voice_ids=v1&voice_ids=v2 --voice-ids '["v1","v2"]' ?voice_ids=%5B%22v1%22,%22v2%22%5D -> ?voice_ids=v1&voice_ids=v2 --voice-ids v1 ?voice_ids=v1 -> ?voice_ids=v1 (unchanged) The URL-encoded-JSON row is the one that mattered: it was near-certainly wrong on the wire and failed silently. Also corrects two changelog claims: the parameter entry now names all three spellings, and the pagination entry states plainly that a script already passing `--page-all` on a metadata-free spec goes from exit 0 to a hard error.
Deferred items from the AgentMail review, and where
|
| Report | Status |
|---|---|
| 10(a) double-encoded error bodies | Body now lands as nested JSON under details, message/reason lifted out |
5(c) root --help listing 4 --format values |
Root and subcommand both list all 7 identically |
11(c) custom.rs not in .fernignore |
Emitted .fernignore contains it |
Other pre-existing bugs surfaced, unfiled
- Integer query params aren't type-checked:
voices search --page-size abcexits 0 and serializes?page_size=abc. - Required-nested-inside-optional-parent over-strictness on 12 operations; one variant surfaces as a confusing
Cannot combine --settings with --settings.auth_typewhen the parent is supplied as JSON.
… errors AgentMail sent the verbatim text of `<bin>-shared/SKILL.md`, the file every per-resource skill names as PREREQUISITE. Their 12 per-resource files are accurate; this one had five errors. Four are fixed here (the fifth, the `--help` auth step, was fixed in 0508357). **Flags that do not exist.** `-o, --output` was emitted unconditionally, so on an API with no binary responses it documented a flag the parser rejects — 0 of their 130 operations accepted it. Same for the pagination flags. Both rows are now gated on `doc_has_binary_response` / `doc_has_pagination`, mirroring the registration conditions in `commands.rs`. The `ffplay -` / `aplay -` examples were audio-API boilerplate; the description is now media-agnostic. **`--schema` hints that cannot appear.** The paragraph told agents to "check the per-op `--schema` output's `paginable` / `binaryResponse` hints". Those keys are real, but only ever emitted for a spec that has such operations — for theirs, neither could appear, so the advice was dead. The paragraph now names only the affordances the API actually has, and collapses to "available on every command in this CLI" when there are none. **Both auth env vars exported.** `any: [BearerAuth, TokenAuth]` are alternative schemes for the same credential, and the file told agents to export both — which is precisely the shadowing scenario `decorate_with_source_hint` warns about. `AuthStrategy` is now threaded to the emitter: anything but `All` says "set **one** of". `Any` and `Auto` both apply one scheme per request, so `All` is the only strategy that genuinely needs every variable. **Wrong `--format` default.** Advertised `json`; the real default is `table` on a TTY and `json` when piped, which subcommand `--help` has always stated correctly. Verified by regenerating their CLI and running `generate-skills`: all four corrected, and the flag table now lists only the 7 flags that exist on every one of their operations.
**Item 10(b) — auth hints.** `decorate_with_source_hint` listed every
*declared* credential source and gated its shadowing advice on
`hints.len() > 1`, which is always true on a two-scheme API. With only
`AGENTMAIL_API_KEY` set, the 401 named all four of that CLI's sources and told
the user to go looking for shadowing between variables they had never set.
`AuthCredentialSource::populated_credential_hints` filters through
`try_resolve`, and a defaulted `AuthProvider` method (overridden by the bearer
/ basic schemes and both composites) exposes it, so the ten other impls are
untouched. `try_resolve` rather than `resolve`: a keyring entry that exists but
cannot be read is *configured*, and hiding it would misdirect. `Cli` and
`Closure` sources pass through unfiltered — the former resolves post-finalize,
and re-invoking the latter could have side effects. Only whether a source
resolved is used; no values are read.
**Item 5(b) — globalFlags.** `--params`, `--no-retry`, `--no-extract` and
`--help` are registered on every operation but were absent from `globalFlags`,
so an agent coding against `--schema` could not discover them. The five other
flags the report listed are genuinely per-op and stay out — and the pagination
gate earlier in this PR is what makes that ADR-0006 claim true, since before it
`--page-all` really was advertised everywhere.
**Two `--schema` bugs the ElevenLabs differential surfaced**, both worse than
the missing flags because they make the contract actively wrong:
*Array element types.* A repeated flag carries `param_type: "string"` — the
flag surface, since clap collects strings — and `--schema` rendered that as the
element type. For `text-to-dialogue convert --inputs`, an array of
`DialogueInput`, the contract said `items: {type: string}`; an agent sent
`["x"]` and the validator correctly rejected it. `array_item_type` resolves the
real element type through `$ref` and nullable compositions onto a new
`item_type`, which `--schema`/`--help` render and the collector consults, so
`--inputs '{...}' --inputs '{...}'` now decodes to an array of objects instead
of an array of literal strings. `None` means string — byte-identical to every
previous lowering.
*Required list.* An object-valued property the parser recurses into had
`required: false` hardcoded on its shorthand flag. That is right for clap (leaf
flags can satisfy it) but wrong for the contract: `agents drafts create`
advertised 5 required fields, and supplying all 5 still failed on `workflow`,
a 6th that was never listed. `required_by_spec` carries the spec's bit for
`--schema` while clap keeps using `required`.
Verified on the real spec: the 6-field contract is now followable, repeated
object elements build the right array, and plain string arrays are unchanged.
…th filter reach real CLIs Three defects found by building both customers' CLIs from this branch and driving every operation. Two are regressions from this PR's own commits. **Self-contradictory `required` (regression, 875c0cd).** `required_by_spec` correctly restored a required parent that recursion had dropped — but it also added parents whose leaves were *already* listed. The executor rejects combining `--a` with `--a.b`, so following the advertised contract verbatim failed: required = [settings, settings.auth_type, settings.name, settings.webhook_url] -> "Cannot combine --settings with --settings.auth_type" `--schema` now drops any ancestor of another required entry. The required leaves already imply the parent must be present, and they are the form a caller can use together; a required parent with no required leaves — the `workflow` case `required_by_spec` was added for — is untouched. Affected 8 of 337 ElevenLabs operations and 1 of 130 AgentMail ones; a sweep of both specs now reports zero. **The populated-hints filter never engaged (regression, 875c0cd).** I added `populated_credential_hints` overrides to `AnyAuthProvider` and `AllAuthProvider` but not to `LayeredAuthProvider` or `RoutingAuthProvider`, which inherited the trait default and returned the *unfiltered* hints. A `RoutingAuthProvider` is built whenever any operation declares per-operation `security:` — 129 of AgentMail's 130 do — so item 10(b) was unfixed on the common case while looking fixed in unit tests. Verified on the rebuilt CLI: before: "Credentials were supplied via: AGENTMAIL_TOKEN …, keyring …, AGENTMAIL_API_KEY …, keyring …. More than one source has a value…" after: "Credentials were supplied via: AGENTMAIL_API_KEY environment variable." Audited the remaining five `AuthProvider` impls: the three OAuth-ish ones build prose from their own config rather than delegating, so the default is right for them. **`--help` value name (incomplete work, 875c0cd).** The `item_type` plumbing reached `--schema` and the collector but not the help renderer, so an array-of-objects flag advertised `<STRING>` while `--schema` said `items: {type: object}`. Extracted `value_name_for`, which prefers `item_type` on repeated flags. `--inputs <STRING>` -> `--inputs <JSON_OBJECT>`; `--tags <STRING|null>` unchanged.
CI's `lint` and `biome` jobs failed on two formatting nits: a stray blank line left behind when a test was moved between describe blocks, and a line-wrap in `writeLicense.ts`. Also removed the `as NodeJS.ErrnoException` in that file — `"code" in error` already narrows to `Error & Record<"code", unknown>`, so the comparison needs no assertion, and CLAUDE.md forbids one where a guard suffices.
…tract
Found by the ElevenLabs differential and the worst of the remaining
`--schema` gaps: multipart body fields live in `method.multipart_fields`, not
`method.parameters`, and `build_operation_schema` only walks the latter. So an
upload operation advertised its query parameters and headers and nothing else:
audio-isolation convert --schema
required : []
properties : ['xi-api-key']
while the spec's multipart schema has `audio` (required), `file_format` and
`preview_b64`. The flags existed and worked — check the `--help` output or the
repeatable-`--files` fix earlier in this PR — they were simply invisible to
anything reading the contract, so an agent driving purely from `--schema` could
not invoke *any* multipart operation. 28 operations on that spec.
Fields are emitted into the `body` bucket as strings, which is what the flag
surface actually takes: a text part its value verbatim, a file part a
filesystem path. `file: true` is what tells an agent to pass a path rather than
the content; a per-part `contentType` surfaces when the spec pins one; repeated
parts render as an array of strings. Builtin-colliding names are skipped,
mirroring `build_resource_command` — those args are never registered, so
advertising them would be the same class of lie this commit removes.
after:
required : ['audio']
properties : ['audio', 'file_format', 'preview_b64', 'xi-api-key']
audio : {"type":"string","location":"body","file":true,"description":…}
Verified by reading the per-op contract for all 337 operations of that spec,
then driving each of the 28 multipart ones from exactly the fields its own
`input.required` lists: 28 of 28 exit 0. Previously none were followable.
CI's `biome` job runs `biome check`, which includes the `assist/source/organizeImports` rule that `biome format` and `biome lint` do not. My `writeLicense` import went in next to `emitReadme` rather than in sorted position, which only `pnpm check` catches locally.
Description
Linear ticket: Refs
Fixes AgentMail's 11-item post-1.0.0 CLI review. They shipped on 0.31.2; current is 0.38.4, and four items had already been fixed in between — triage below, so nothing gets re-fixed or wrongly closed.
Everything is verified by regenerating and building the reporter's own CLI from
agentmail-to/agentmail-docs, plus a second real spec (ElevenLabs) as a differential control. Two of the fixes below exist because that verification found my first attempt incomplete.Per-item outcome
x-fern-idempotent: truemakes retries send no key at all$refschemas never resolved — validation off for params and bodies--page-allsilently returns page 1 with exit 0--schemaper-op entries lackhttpMethod/pathglobalFlagsomits flags every subcommand shows--helplists 4--formatvalues, subcommand lists 7generate-skillsships broken instructions (5 spots)-alpha/-betapublish as npmlatestlicense: MITmetadata contradicts the shipped Apache-2.0 LICENSEsdk.rsbuildsClientConfig::default()— empty base URLsdk_executor.rshardcodes "SDK requests are idempotent"custom.rsclaims.fernignoreprotection it doesn't havePlus two bugs neither the report nor I spotted until the CLIs were actually built — see Found during verification.
1 — Retry semantics
Root cause was the call site, not
method_allows_retry.decide_retryreceivedmethod.idempotent || idempotency_key.is_some(), and the auto key is generated for every POST/PUT/PATCH — so that second clause made every non-idempotent operation retry-eligible. (I initially misreadmethod_allows_retryin isolation as already correct.)Compounding it, the marker suppressed key generation:
user_provides_idempotencyincludedmethod.idempotent, but the marker only means the operation exposes--idempotency-key, not that the caller passed it.Retry-safety now requires a key the server is known to honor —
x-fern-idempotent: true, or an explicit--idempotency-key. The auto key is still sent (it is what makes a retry safe where the endpoint consumes it) but no longer licenses one. Only a key actually present suppresses generation.New behavior (their spec, against a 500ing mock):
inboxes createx-fern-idempotentsdk_executor.rshad the same shape hardcoded (true, // SDK requests are treated as idempotent), making every custom-command POST retry as idempotent. It now derives safety from the request's ownIdempotency-Keyheader — the only signal available to a transport handed an already-builtRequest, and the right one. (11b)They can now put the marker back, which their overrides currently warn against adding.
2 —
$refresolutionThree distinct bugs behind one symptom.
Bodies.
validate_valuehad an object branch and nothing else, so a$refto a scalar or array component accepted any value. On a spec where most schemas are component refs, validation was effectively off and--dry-runexited 0 on a malformed body. Scalars and arrays are now type-checked through one sharedcheck_json_type, so the inline and component paths cannot drift again.Ref chains. A bare-
$refcomponent (A: {$ref: B}) was never followed. Now resolved by a bounded iterative resolver — recursion would not terminate on a cycle here, unlike the value-driven recursion elsewhere in the module.Parameters.
convert_parametertook nocomponent_schemasandOpenApiParamSchemahad no$reffield, so a$ref'd parameter deserialized to all-None: no type, enum, format or bounds. The array half was also broader than diagnosed —repeatedwas only ever set for body properties and multipart fields, so no query/header parameter was repeatable in any spelling.New behavior:
3 — npm launcher exit codes
execFileSyncthrows withstatus: nullandsignal: "SIGTERM"on a signal death; the launcher's"status" in etest matched that andprocess.exit(null)coerces to 0. CI timeouts, SIGSEGV and OOM-kills all reported success to$?, on the npm install path only.New behavior: a numeric status is required; otherwise the launcher exits
128 + signum(SIGTERM → 143), matching the shell convention. The rationale lives in the TS source, not in the customer's emittedci.yml.4 — Pagination flags
Registered only where
method.paginationis set or the spec root declares the token query-param / response-path pair — the same treatment--no-streamalready gets. Read sites moved totry_get_*so absence reads as "off" rather than panicking on an unknown arg id.New behavior, and this one is a visible break worth calling out. On a spec with no pagination metadata the flags disappear from every operation (337 of 337 on the ElevenLabs control). Nothing that previously worked breaks —
--page-allsilently fetched exactly one page and exited 0 — but a script or agent already passing it flips from exit 0 to a hardunexpected argumenterror. The failure is now loud instead of silent.5 —
--schemacompleteness5(a) is deferred, not fixed.
httpMethod/pathwere dropped deliberately in cli-sdk#190 (feat: redesign --schema as the agent-facing contract, patrickthornton, 2026-06-26), vendored here by #16521. ADR-0006 rejected keeping them as an explicit alternative:Restoring them is an ADR amendment, not a bug fix — and #190 was 88 files, porting every
demo.sh/DEMO.md/README.mdthat piped--schemathroughjqfrom.[]to.operations[], so a reversal is a shape change rather than re-adding two fields. It shipped in the 0.19.x range, not 0.28.1 as the report states.5(b) — 3 of the 8 listed flags are genuine.
globalFlagswent 10 → 14, adding--params,--no-retry,--no-extract,--help. The other five (--page-all/-limit/-delay,--no-pager) are per-op and correctly excluded per ADR-0006 — and the pagination gate above is what makes that claim true, since before it they really were advertised everywhere.A third, worse than both: multipart body fields were missing from the contract entirely. They live in
method.multipart_fields, notmethod.parameters, andbuild_operation_schemaonly walked the latter — so an upload operation advertised its query params and headers and nothing else:The flags existed and worked; they were invisible to anything reading the contract, so an agent driving purely from
--schemacould not invoke any multipart operation — 28 of them on the control spec. Fields are emitted into thebodybucket as strings, which is what the flag surface takes (a text part its value, a file part a path);file: truetells an agent to pass a path, a per-partcontentTypesurfaces where the spec pins one, and repeated parts render as an array. Builtin-colliding names are skipped, mirroring flag registration. Verified by reading the per-op contract for all 337 operations and driving each of the 28 multipart ones from exactly the fields its owninput.requiredlists: 28 of 28 exit 0, where previously none were followable.Two more
--schemabugs, found by the differential. Both make the contract actively wrong rather than incomplete:Array element types. A repeated flag carries
param_type: "string"— correct for the flag surface, since clap collects strings — and--schemarendered that as the element type.text-to-dialogue convert --inputs, an array ofDialogueInput, advertiseditems: {type: string}; an agent following the contract sent["x"]and the validator correctly rejected it.array_item_typeresolves the real element type through$refand nullable compositions onto a newitem_type, which--schema/--helprender and the collector consults:item_type: Nonemeans string, byte-identical to every previous lowering, so--tags a --tags bstill yields["a","b"].Required list. An object-valued property the parser recurses into had
required: falsehardcoded on its shorthand flag. Right for clap — leaf flags can satisfy it — wrong for the contract:agents drafts createadvertised 5 required fields and supplying all 5 still failed onworkflow, a 6th never listed.required_by_speccarries the spec's bit for--schemawhile clap keeps usingrequired. The contract is now followable end to end.6 —
generate-skillsThe reporter supplied the verbatim
<bin>-shared/SKILL.md— the file every per-resource skill names as PREREQUISITE. Their 12 per-resource files were accurate; this one had five errors, and the pattern is the same in three of them: the shared file described the union of every affordance the generator can emit rather than what the spec produces.-o, --outputdocumented, accepted by 0/130 opsdoc_has_binary_response;ffplay -/aplay -audio boilerplate replaced with media-agnostic text--schemapaginable/binaryResponsehints that never appear<bin> --helpas the auth check<bin> auth status--formatdefaultjsontableon a TTY,jsonwhen pipedFor ④,
AuthStrategyis threaded to the emitter rather than guessed from scheme count:AnyandAutoboth apply one scheme per request, soAllis the only strategy that genuinely needs every variable.Their flag table went from 13 rows — six naming flags their CLI rejects — to the 7 that exist on every operation.
One note back to them on ③: they suggested
auth me. I usedauth statusbecause it is grafted onto every Fern CLI (ADR-0007) whereasauth meis not a built-in and no operation is generically guaranteed. Trade-off:auth statusconfirms credentials resolve, not that they are valid.7 — Pre-release dist-tags
Only
-alpha/-betawere matched, sov1.1.0-rc.1or-next.1fell through to a barenpm publishand would movelatest. New behavior: any SemVer pre-release gets a tag derived from its first identifier (-rc.1→rc), with aprereleasefallback for an all-numeric or empty one (npm rejects a numeric dist-tag). Applied to both publish steps.8 — Build profile
ci.ymlbuiltcargo build --release; cargo-dist'srelease.ymlbuilds--profile dist(release + thin LTO) — 9.8 MB vs 10.9 MB in their measurements. npm is a distribution channel, so it now uses the profile nameddist. The copy path moves with it: a custom cargo profile writes totarget/<triple>/dist/, not.../release/. New behavior: both channels ship byte-identical binaries per tag.9 — LICENSE
The Apache-2.0 file was never a licensing decision:
copySdkcopies./sdk/verbatim andsdk/LICENSEsimply wasn't excluded, so the vendored runtime's license rode along into all 49 seed fixtures.No other Fern generator ships a LICENSE by default. A survey of every seed tree finds exactly two LICENSE files outside
seed/cli, both from explicitcustom-licensefixtures, andRustProject.writeLicenseFilereturns early for anything buttype: custom.New behavior:
LICENSEis excluded from the SDK copy viasdk/.sdk-ignore.json, and a newwriteLicensestep emits one only forlicense: { type: custom }, copying the Fern CLI's/tmp/LICENSEmount and honoring a configured filename. A basic license stays package metadata, as elsewhere. License text is never authored here.Because Apache-2.0 §4 requires retaining the notice for the vendored runtime, the generated README now carries an
## Attributionsection namingfern-cli-sdkand its license — emitted as a discrete Block, somergeWithExistinglets a customer reword it and keep their wording across regens.10 — Error surfaces
10(a) needed nothing — a JSON error body already lands as nested JSON under
detailswithmessage/reasonlifted out. No second parse.10(b) reproduced exactly.
decorate_with_source_hintlisted every declared credential source and gated its shadowing advice onhints.len() > 1, always true on a two-scheme API. With onlyAGENTMAIL_API_KEYset:AuthCredentialSource::populated_credential_hintsfilters throughtry_resolve— deliberatelytry_resolve, notresolve, so a keyring entry that exists but cannot be read (denied prompt) still counts as configured rather than misdirecting. Exposed via a defaultedAuthProvidermethod overridden only in the bearer/basic schemes and both composites, leaving the ten other impls untouched.CliandClosuresources pass through unfiltered — the former resolves post-finalize, and re-invoking the latter could have side effects. Only whether a source resolved is used; no values are read.11 — Custom-command scaffold
11(a)
ClientConfig::default()carriesbase_url: String::new()for any API with no declared environment (seeseed/rust-sdk/no-environment), sosdk::client(ctx)produced a client with no host and every custom command failed on a relative URL before the injected executor could help. The bridge now seeds from a newAppContext::effective_base_url(), mirroring the built-in path's--base-url>doc.base_url> server-root resolution.New behavior:
SDK network error: builder error→ a real HTTP status from the API. This did not reproduce on a spec that declares environments, which is why it needed their spec to catch.11(b) covered under item 1. 11(c) already fixed — the emitted
.fernignorecontainscli/<bin>/custom.rs.Found during verification
Five bugs that were not in the report and that I only found by regenerating and building the customers' CLIs and driving every operation. Four are fixes to this PR's own earlier commits — none of them were caught by a unit test, which is the argument for the end-to-end pass:
Per-operation
servers:. AgentMail's spec declaresservers:on each operation and none at the root, so my firsteffective_base_url()readdoc.root_url(empty) and the bridge still produced a hostless client — the same symptom 11(a) was meant to remove. It now falls back to the first operation that declares a server, walked in sorted order for determinism, mirroringeffective_root_url(method, doc)on the built-in path.Self-contradictory
requiredlists.required_by_speccorrectly restored a required parent that recursion had dropped — but it also added parents whose leaves were already listed. The executor rejects combining--awith--a.b, so following the advertised contract verbatim failed withCannot combine --settings with --settings.auth_type.--schemanow drops any ancestor of another required entry; the required leaves already imply the parent, and a required parent with no required leaves (theworkflowcase the field was added for) is untouched. Hit 8 of 337 ElevenLabs operations and 1 of 130 AgentMail ones; a sweep of both specs now reports zero.The populated-hints filter never engaged. I added
populated_credential_hintstoAnyAuthProviderandAllAuthProviderbut not toLayeredAuthProviderorRoutingAuthProvider, which inherited the trait default and returned the unfiltered hints. ARoutingAuthProvideris built whenever any operation declares per-operationsecurity:— 129 of AgentMail's 130 do — so item 10(b) was unfixed on the common case while passing its unit tests. Audited the remaining fiveAuthProviderimpls: the three OAuth-ish ones build prose from their own config rather than delegating, so the default is correct for them.--helpvalue name. Theitem_typeplumbing reached--schemaand the collector but not the help renderer, so an array-of-objects flag advertised<STRING>while--schemasaiditems: {type: object}.value_name_fornow prefersitem_typeon repeated flags:--inputs <STRING>→--inputs <JSON_OBJECT>,--tags <STRING|null>unchanged.anyOf: [array, null]parameters. My parameter fix reached 2 of the 31 array query parameters on the ElevenLabs spec. The other 29 use pydantic'sOptional[list[T]]spelling, andOpenApiParamSchemahad no composition fields at all.resolve_param_nullable_branchis the parameter counterpart torecognize_nullable_composite, which operates onOpenApiSchemaObjectand so only ever reached body properties. My earlier commit message claimed arrays never setrepeated"for any spelling"; it covered only the spellings it could see.Changes Made
sdk/src/openapi/executor.rs— retry-safety gate; key generation no longer suppressed by the marker;validate_valuescalar/array branch;check_json_type;resolve_schema_chain.sdk/src/sdk_executor.rs— retry safety from the request'sIdempotency-Key.sdk/src/openapi/parser.rs—$ref+ composition onOpenApiParamSchema;resolve_param_schema_ref;resolve_param_nullable_branch;repeatedfor array parameters;array_item_type;required_by_spec.sdk/src/openapi/commands.rs—method_has_paginationgate;value_name_for(element-type-aware--helpvalue names).sdk/src/openapi/app.rs—effective_base_url()(+ per-op server fallback); tolerant pagination reads;globalFlagsadditions; item-type-aware repeated collector.sdk/src/openapi/help.rs— real array element type;required_by_specininput.required; ancestor-drop so a required parent and its required leaves are never both advertised;multipart/form-databody fields in the per-op contract.sdk/src/openapi/skill_emitter.rs— conditional per-op flag rows;AuthStrategy-aware auth setup; correct--formatdefault.sdk/src/auth/{credential,provider,schemes,compose,error}.rs—populated_credential_hints, including theLayered/Routingcomposite overrides without which the filter was inert.sdk/src/openapi/discovery.rs—item_type,required_by_spec.sdk/.sdk-ignore.json— excludeLICENSE.src/emitPublishWorkflow.ts— launcher exit codes; pre-release dist-tags;distbuild profile.src/writeLicense.ts(new),src/runPipeline.ts,src/cli.ts— config-driven LICENSE.src/emitReadme.ts— Attribution section.src/generateSdk.ts— bridge seedsbase_url.generators/cli/changes/unreleased/.Testing
1935 lib tests (up from 1916 at branch start) + 401 cli-generator TS tests. New coverage:
tests/idempotency_on_retry.rs— wiremock, reproducing the reported A/B on a 500ing mock: marked POST retries with the same key on every attempt; unmarked POST makes exactly one; an explicit key is used verbatim.test_validate_body_rejects_wrong_type_behind_a_ref(+ the inverse guard),resolve_schema_chaincoverage.test_ref_typed_parameter_resolves_through_component_schemas,test_array_typed_parameter_is_repeatable,test_nullable_array_parameter_is_repeatable,test_true_union_parameter_is_not_promoted,test_nullable_scalar_parameter_resolves_its_type.test_array_of_objects_records_its_element_type,test_recursed_object_property_keeps_the_spec_required_bit.test_pagination_flags_hidden_without_metadata/_shown_with_per_op_config.test_effective_base_url_falls_back_to_per_operation_server.names_only_the_sources_that_actually_hold_a_value,shadowing_advice_appears_only_with_more_than_one_source(rewritten — it had encoded the bug, asserting an unpopulated keyring gets named).shared_skill_omits_per_op_flags_the_spec_cannot_produce/_advertises_per_op_flags_when_the_spec_has_them/_says_set_one_of_for_alternative_schemes.emitPublishWorkflow.test.ts— signal exit codes, pre-release dist-tags, unified build profile.writeLicense.test.ts— 5 cases including the missing-mount (remote generation) path.required_drops_an_ancestor_whose_leaves_are_also_required— pins both directions: the ancestor is dropped, and a required parent with no required leaves survives.multipart_fields_appear_in_the_input_contract— required file part, repeated file part, text part, and a builtin-colliding name that must stay unadvertised.test_repeated_flag_value_name_uses_the_element_type.Both customers' CLIs regenerated, built, and driven end to end — this is what found the three defects above, two of which were regressions from this PR's own commits and neither of which any unit test caught. AgentMail: 13 of 15 checks clean, the two failures being the bugs now fixed. ElevenLabs: all 17 must-work checks pass, 333 dotted leaf flags, 0 of 337 operations accept
--page-all, POST=1/GET=4 retry attempts.The item-type collector change was the highest-risk item and is clean: string-element arrays are byte-identical (
--tags 123→["123"],--tags '{"a":1}'→ literal), object elements decode correctly, and a synthetic spec confirmed integer/number/boolean elements type correctly with clean validation errors on bad input. Thetry_resolvekeyring read on the auth-error path was also checked for OS prompts on macOS with a real keychain entry stored: no prompt, no hang, 0.05–0.09 s across repeated runs. (NoteBearerAuth::applyalready calledtry_resolveon every request before this PR, so keyring reads on the happy path are pre-existing.)Differential against two real specs. An exhaustive sweep of 336 of 337 ElevenLabs operations, synthesizing a spec-valid body for each from
openapi0.jsonand comparing pre-branch vs branch: 35 failures on both sides, zero regressions, and byte-identical dry-run payloads for all 301 operations that pass on both. The 35 shared failures are the synthesizer's limits, identical on each side. Plus 10 hand-picked operations on the shapes most at risk — that spec has 7 top-level request bodies that are pure unions with notypekeyword, all still accepted.Clippy clean in the changed files; the
too_many_argumentswarning onbuild_resource_commandis pre-existing (6 occurrences of that lint onmainwithout this change).Behavior changes to review deliberately
--page-allgo from exit 0 to exit 3.$ref'd enum parameters are now enforced — 22 params across 19 operations on the control spec. Off-enum values that were previously forwarded to the server are now rejected locally. Correct (inline enums always were), but a spec whose enum is narrower than the live API will feel it.--schemacontract to be usable. String-element arrays are unchanged.Pre-existing bugs surfaced, not fixed here
Present identically on this branch and on a pre-branch baseline; filing separately rather than widening this PR:
oneOfunion of objects is typedstringand serializes as a quoted string on the wire (api-keys create-public-key --scope). Same root cause as the array case below — a true union has no single shape to promote — and the property is optional there, so no advertised contract is broken.anyOf/oneOfunion of objects still serialize as JSON strings on the wire (5 operations).array_item_typereturnsNonefor a union with notype/properties/allOf, so the element stays literal. Pre-existing — before this PR all non-string array elements were literal — so the fix is incomplete rather than regressive.items: {type: array}, becauseitem_typeis only assigned in the body path. Harmless at runtime (the collector seesNoneand keeps literals, which is correct for these) but the advertised element type is wrong.conversational-ai knowledge-base searchadvertises the property asquerywhile the flag is--query-param(renamed to avoid the global JMESPath--query); the rename is not disclosed in--schema.voices search --page-size abcexits 0 and serializes?page_size=abc.