feat(cli-generator): identify generated CLIs by binary name in User-Agent, with consumer-appendable suffix#17107
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
AI Review Summary
Small, well-scoped change swapping the User-Agent product token from the shared crate name to the CLI's binary name. Logic is correct and tested. One consideration: binary names with characters invalid in HTTP header values could silently drop the User-Agent header.
- 🔵 1 suggestion(s)
| let user_agent = self.user_agent(); | ||
| if let Ok(header_value) = HeaderValue::from_str(&user_agent) { |
There was a problem hiding this comment.
🔵 suggestion
If self.name ever contains characters illegal in a header value (control chars, etc.), HeaderValue::from_str fails and the User-Agent is silently dropped entirely. Previously the token was a hardcoded crate name so this couldn't happen. Probably fine if binary names are already validated upstream, but worth confirming that constraint exists.
…gent Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ange Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…dence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…a userAgentSuffixFlag Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…uilt-in flag A configured userAgentSuffixFlag matching a built-in global flag name (e.g. base-url, format, help) would register a second clap arg with the same long name and panic at CLI startup. Reject such names at the generator boundary in validateCustomConfig, mirroring the SDK's BUILTIN_FLAG_NAMES (minus user-agent-suffix, the default's own slot). Co-Authored-By: Claude <noreply@anthropic.com>
…e is unusable with_user_agent_suffix_override now clears the override when the value is blank OR not valid header content, so an unusable --user-agent-suffix flag no longer suppresses a valid <NAME>_USER_AGENT_SUFFIX env suffix (behavior is now symmetric for blank and invalid input). Regenerated the vendored SDK copy across CLI seed fixtures. Co-Authored-By: Claude <noreply@anthropic.com>
8b84f90 to
793f985
Compare
AsyncAPI/WebSocket CLIs sent no User-Agent because the tungstenite
handshake (into_client_request) does not set one and the connect path
never added it — so realtime traffic was invisible to backend analytics
and the suffix override wired through asyncapi/binding.rs was inert.
Extract build_handshake_request and attach HttpConfig::user_agent() (the
same {binary}-cli/{version}[ suffix] value HTTP traffic uses) to the
handshake, layered before auth headers so an explicit auth User-Agent can
still override. Invalid header content is skipped rather than failing the
handshake. Regenerated the vendored SDK copy across CLI seed fixtures.
Co-Authored-By: Claude <noreply@anthropic.com>
Description
Linear ticket: Refs
Makes generated CLIs identify themselves in the
User-Agentheader, and lets tools built on top of a generated CLI voluntarily tag their own traffic without hiding the CLI's identity.Before, every Fern-generated CLI sent the shared crate name:
fern-cli-sdk/<version>(fromenv!("CARGO_PKG_NAME")), so a backend couldn't tell one customer's CLI from another.CARGO_PKG_NAMEis alwaysfern-cli-sdkbecause the generator deliberately leaves[package] nameuntouched (only the[[bin]]name is patched). The version half was already correct —patchCargoTomlstamps the generated release version into the crate's[package] version, soenv!("CARGO_PKG_VERSION")already resolves to the CLI's release version.Now:
User-Agentis{binaryName}-cli/{version}, e.g.elevenlabs-cli/1.4.0— the product token comes fromHttpConfig.name(theCliApp::new("<name>")value), normalized to end with-cli(elevenlabs→elevenlabs-cli; an already--cliname is left unchanged, not doubled). The version comes from the stamped crate version.elevenlabs-cli/1.4.0 partner-app/3.1. This is the CLI equivalent of the Stripe/OpenAI/AnthropicappInfopattern. Two mechanisms, both supported:ELEVENLABS_USER_AGENT_SUFFIX=partner-app/3.1) — ambient, inherited by a wrapped subprocess.flag > env > none. A blank/whitespace flag value clears the override and falls back to the env var. Malformed suffixes (blank / invalid header content) are ignored so they can never drop the CLI's own identity.Configurable suffix flag name (
userAgentSuffixFlag)The suffix flag/env name is configurable at generation time. It defaults to
--user-agent-suffix/<NAME>_USER_AGENT_SUFFIX, but an API owner can rename it via the CLI generator's custom config:--, validated at the generator boundary against^[a-z][a-z0-9-]*$(a clap-safe kebab identifier).<NAME>_+ the flag uppercased with-→_(sovia→ELEVENLABS_VIA; the defaultuser-agent-suffix→ELEVENLABS_USER_AGENT_SUFFIX).customConfigfield (likebinaryName), threaded throughrunPipeline → copySpecs → renderMainRsand emitted into the generatedmain.rsas.user_agent_suffix_flag("via")(emitted only when configured; the SDK applies the default otherwise).In the SDK the configured name is a single value set once at startup (
CliApp::user_agent_suffix_flag) and read back by the clap flag registration, the--help/--schematext, and the env-var lookup, so they stay in agreement. The clap arg id stays the stable"user-agent-suffix"regardless of the long name, so command construction and runtime value lookup can't drift. A parameter whose flag would collide with a custom suffix name is mangled with a-paramsuffix (the default name is already covered by the built-in reserved list).Precedence and validation live in
HttpConfig, so all binding kinds (OpenAPI / GraphQL / AsyncAPI) get identical behavior:Changes Made
generators/cli/src/customConfig.ts: new optionaluserAgentSuffixFlagfield + boundary validation (^[a-z][a-z0-9-]*$, rejects a leading--).generators/cli/src/runPipeline.ts/copySpecs.ts: threaduserAgentSuffixFlagthrough the pipeline and emit.user_agent_suffix_flag("<name>")intomain.rswhen configured.generators/cli/sdk/src/user_agent.rs(new): configured flag name, derived env-var segment, and parameter-collision helper.generators/cli/sdk/src/http.rs:HttpConfig::user_agent()composes{name}-cli/{version}and appends the resolved suffix; env fallback derives its var name fromuser_agent::suffix_env_segment().generators/cli/sdk/src/app.rs:CliApp::user_agent_suffix_flag()builder; the root global flag /--schemaoutput use the configured long name and derived env var.generators/cli/sdk/src/openapi/commands.rs: global flag long name,--helpenv-var footer, and built-in collision guard use the configured name.generators/cli/sdk/src/cli_args.rs: resolver reads by the stable"user-agent-suffix"arg id (name-independent).seed/cli/**.generators/cli/changes/unreleased/.Testing
cargo test --all-features) → 1744 passed: UA uses{name}-cliproduct token (notfern-cli-sdk/) and doesn't double-cli; appends a valid env suffix; ignores blank/invalid suffixes; flag override wins over env; blank flag falls back to env; newuser_agentmodule tests for env-segment derivation (via→_VIA, default →_USER_AGENT_SUFFIX) and custom-name collision. (cargo clippyreports only pre-existing lints in untouched files under a newer toolchain.)vitest) → 192 passed:customConfig.test.tscovers valid/kebab/undefined/non-string/---prefixed/uppercase/leading-digit/underscore/emptyuserAgentSuffixFlag;copySpecs.test.tsasserts.user_agent_suffix_flag("via")is emitted when configured, omitted by default, and that an unsafe value is rejected.seed test --generator cli --skip-scripts→ 134/134 pass;dist:clibuild → 50/50.userAgentSuffixFlag: via) flow — built thequery-parameters-openapiseed CLI (stamped1.0.0) with.user_agent_suffix_flag("via"), pointed it at a local mock server, and read the outbound header:--help/--schemashow--via(and envQUERY_PARAMETERS_API_VIA); the old--user-agent-suffixis rejected as unknown.query-parameters-api-cli/1.0.0--via partner/3.1→query-parameters-api-cli/1.0.0 partner/3.1QUERY_PARAMETERS_API_VIA=envapp/2.0→query-parameters-api-cli/1.0.0 envapp/2.0query-parameters-api-cli/1.0.0 flagapp/3.1(flag wins)--via ""+ env →query-parameters-api-cli/1.0.0 envapp/2.0(falls back to env)QUERY_PARAMETERS_API_USER_AGENT_SUFFIXset →query-parameters-api-cli/1.0.0(ignored; the config renamed the env var)Link to Devin session: https://app.devin.ai/sessions/a823c420aa8046feaa24d4e211a8e1a1