Skip to content

feat(cli-generator): identify generated CLIs by binary name in User-Agent, with consumer-appendable suffix#17107

Open
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/1784217390-cli-user-agent-binary-name
Open

feat(cli-generator): identify generated CLIs by binary name in User-Agent, with consumer-appendable suffix#17107
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/1784217390-cli-user-agent-binary-name

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Description

Linear ticket: Refs

Makes generated CLIs identify themselves in the User-Agent header, 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> (from env!("CARGO_PKG_NAME")), so a backend couldn't tell one customer's CLI from another. CARGO_PKG_NAME is always fern-cli-sdk because the generator deliberately leaves [package] name untouched (only the [[bin]] name is patched). The version half was already correct — patchCargoToml stamps the generated release version into the crate's [package] version, so env!("CARGO_PKG_VERSION") already resolves to the CLI's release version.

Now:

  • Base User-Agent is {binaryName}-cli/{version}, e.g. elevenlabs-cli/1.4.0 — the product token comes from HttpConfig.name (the CliApp::new("<name>") value), normalized to end with -cli (elevenlabselevenlabs-cli; an already--cli name is left unchanged, not doubled). The version comes from the stamped crate version.
  • A consumer wrapping the CLI can append a product token — appended, not substituted: elevenlabs-cli/1.4.0 partner-app/3.1. This is the CLI equivalent of the Stripe/OpenAI/Anthropic appInfo pattern. Two mechanisms, both supported:
    • a global flag — explicit, per-invocation.
    • a scoped env var (e.g. ELEVENLABS_USER_AGENT_SUFFIX=partner-app/3.1) — ambient, inherited by a wrapped subprocess.
    • Precedence: 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:

config:
  binaryName: elevenlabs
  userAgentSuffixFlag: via     # -> exposes --via and ELEVENLABS_VIA
  • The value is the flag's long name without the leading --, validated at the generator boundary against ^[a-z][a-z0-9-]*$ (a clap-safe kebab identifier).
  • The derived env var is <NAME>_ + the flag uppercased with -_ (so viaELEVENLABS_VIA; the default user-agent-suffixELEVENLABS_USER_AGENT_SUFFIX).
  • No IR involvement: it's a generator customConfig field (like binaryName), threaded through runPipeline → copySpecs → renderMainRs and emitted into the generated main.rs as .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 / --schema text, 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 -param suffix (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:

// http.rs
fn user_agent_suffix(&self) -> Option<String> {
    let raw = match &self.user_agent_suffix_override {   // <-- suffix flag override
        Some(s) => Some(s.to_string()),
        None => first_env([scoped(&self.prefix, &user_agent::suffix_env_segment())]),  // <-- env fallback
    };
    raw.map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty() && HeaderValue::from_str(s).is_ok())
}

Changes Made

  • generators/cli/src/customConfig.ts: new optional userAgentSuffixFlag field + boundary validation (^[a-z][a-z0-9-]*$, rejects a leading --).
  • generators/cli/src/runPipeline.ts / copySpecs.ts: thread userAgentSuffixFlag through the pipeline and emit .user_agent_suffix_flag("<name>") into main.rs when 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 from user_agent::suffix_env_segment().
  • generators/cli/sdk/src/app.rs: CliApp::user_agent_suffix_flag() builder; the root global flag / --schema output use the configured long name and derived env var.
  • generators/cli/sdk/src/openapi/commands.rs: global flag long name, --help env-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).
  • Regenerated CLI seed snapshots under seed/cli/**.
  • Changelog entry under generators/cli/changes/unreleased/.

Testing

  • Unit tests added/updated
    • SDK (cargo test --all-features) → 1744 passed: UA uses {name}-cli product token (not fern-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; new user_agent module tests for env-segment derivation (via_VIA, default → _USER_AGENT_SUFFIX) and custom-name collision. (cargo clippy reports only pre-existing lints in untouched files under a newer toolchain.)
    • Generator (vitest) → 192 passed: customConfig.test.ts covers valid/kebab/undefined/non-string/---prefixed/uppercase/leading-digit/underscore/empty userAgentSuffixFlag; copySpecs.test.ts asserts .user_agent_suffix_flag("via") is emitted when configured, omitted by default, and that an unsafe value is rejected.
  • CLI seed validation — seed test --generator cli --skip-scripts134/134 pass; dist:cli build → 50/50.
  • Manual end-to-end capture of the configured (userAgentSuffixFlag: via) flow — built the query-parameters-openapi seed CLI (stamped 1.0.0) with .user_agent_suffix_flag("via"), pointed it at a local mock server, and read the outbound header:
    • --help / --schema show --via (and env QUERY_PARAMETERS_API_VIA); the old --user-agent-suffix is rejected as unknown.
    • no suffix → query-parameters-api-cli/1.0.0
    • --via partner/3.1query-parameters-api-cli/1.0.0 partner/3.1
    • QUERY_PARAMETERS_API_VIA=envapp/2.0query-parameters-api-cli/1.0.0 envapp/2.0
    • flag + env → query-parameters-api-cli/1.0.0 flagapp/3.1 (flag wins)
    • blank --via "" + env → query-parameters-api-cli/1.0.0 envapp/2.0 (falls back to env)
    • old QUERY_PARAMETERS_API_USER_AGENT_SUFFIX set → query-parameters-api-cli/1.0.0 (ignored; the config renamed the env var)

Link to Devin session: https://app.devin.ai/sessions/a823c420aa8046feaa24d4e211a8e1a1


Open in Devin Review

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +266 to 267
let user_agent = self.user_agent();
if let Ok(header_value) = HeaderValue::from_str(&user_agent) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@devin-ai-integration devin-ai-integration Bot changed the title feat(cli-generator): identify generated CLIs by binary name in User-Agent feat(cli-generator): identify generated CLIs by binary name in User-Agent, with consumer-appendable suffix Jul 16, 2026
rishabh-fern and others added 9 commits July 17, 2026 14:56
…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>
@rishabh-fern
rishabh-fern force-pushed the devin/1784217390-cli-user-agent-binary-name branch from 8b84f90 to 793f985 Compare July 17, 2026 19:00
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants