feat(cli-generator): add named profiles for multi-tenant CLIs - #17654
feat(cli-generator): add named profiles for multi-tenant CLIs#17654rishabh-fern wants to merge 1 commit into
Conversation
A profile is a named bundle of request context — a credential slot, parameter defaults, server variables, an optional base URL and output format — resolved once per invocation. Enabling it adds a `profiles create | list | use | remove | current` group and a global `--profile` / `-p` flag, so a tenant identifier stops being typed on every command. Not new transport. A profile is a *source of defaults* for mechanisms that already accept defaults: the keyring account an `AuthCredentialSource::Keyring` reads, a `clap::Arg`'s `default_value`, `servers[].variables` substitution, `resolve_base_url_override`, `OutputPipeline`. That is what keeps it generic — nothing about auth, HTTP, retries, or command construction changes. Precedence per value is `flag -> env var -> profile -> spec default`. Env sits above profile so a CI pipeline that exports credentials is never silently overridden by a developer's stored profile; profile sits above spec defaults so it can change a parameter the spec defaults. For credentials the profile adds no rung to ADR-0008's chain — it only selects which keyring account the existing `Keyring` rung reads (`<scheme>` unprofiled, `<scheme>#<credential>` with a profile), so two tenants can hold separate credentials for one scheme. Also fixes a latent correctness bug in the OAuth token cache, which was keyed by `token_url` alone: two profiles authenticating against the same client-credentials endpoint would have clobbered each other's access and refresh tokens. The key is now `<token_url>#<credential>` when a profile is selected and byte-identical to before when none is, so existing caches keep resolving and no one is logged out by the upgrade. Storage is `~/.config/<bin>/profiles.toml`, beside the credential store. No secrets ever reach it: the file names a keychain account, and `profiles create --with-token` / `--from-env` write the credential to the OS keychain under an account scoped to that profile. Written through `toml_edit`, so a user's comments and any field a newer binary wrote survive a round-trip. `parameters` is a free-form map — the framework cannot know which parameter is the tenant key — but `--set` validates both halves at write time against the parsed operation table. An unvalidated key would make a typo a silent no-op, and an out-of-enum value would make *every* command carrying that parameter fail on a flag the caller never passed. Built-in commands (`auth`, `profiles`, `completion`, `man`) now appear in `--schema` under a new `builtinCommands` array. They were absent entirely, so an agent reading `--schema` could not discover that the CLI had any way to authenticate or switch tenant. Ships off unless `config.profiles.enabled` is set. A CLI generated without the block is byte-identical to one generated before this change; `tests/profiles.rs::with_no_profile_configured_nothing_changes` pins that. Flipping the default is a separate decision needing a generator major bump plus a migration, per the breaking-changes policy. See generators/cli/sdk/docs/adr/0011-profile-resolution-precedence.md. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
AI Review Summary
Large feature PR adding named profiles to the generated CLI SDK, plus regenerated seed fixtures. The diff is heavily truncated (only the generator/SDK core is visible), so review focused on app.rs, the changelog/docs, and the Cargo/lock changes. Main concerns: the --profile flag is registered on clap but never read from ArgMatches (pre-clap scan only), and resolve_and_install_profile silently drops non-UTF-8 argv, which can misresolve the profile.
- 🟡 1 warning(s)
- 🔵 4 suggestion(s)
To request another review, comment /ai-review on this pull request.
| let str_args: Vec<String> = args | ||
| .iter() | ||
| .filter_map(|a| a.to_str().map(String::from)) | ||
| .collect(); |
There was a problem hiding this comment.
🟡 warning
filter_map(|a| a.to_str()) silently drops non-UTF-8 args rather than replacing them, which shifts positions in str_args. A non-UTF-8 value immediately before -p (or a non-UTF-8 argv[1]) changes what the pre-clap scanner sees — e.g. first_positional_is could match a token that is actually the value of a preceding option. Prefer to_string_lossy().into_owned() so the vector stays positionally faithful to argv.
| if let Some(ref config) = self.profiles { | ||
| cli = cli.arg( | ||
| clap::Arg::new(crate::profiles::selection::PROFILE_FLAG) | ||
| .long(crate::profiles::selection::PROFILE_FLAG) | ||
| .short(crate::profiles::selection::PROFILE_SHORT) | ||
| .value_name("NAME") | ||
| .help(format!( | ||
| "Run this command under a named profile (see `{} {}`). \ | ||
| Overrides {} and the active profile.", | ||
| self.name, | ||
| config.command_name, | ||
| crate::profiles::selection::profile_env_var(&self.name), | ||
| )) | ||
| .global(true), | ||
| ); |
There was a problem hiding this comment.
🔵 suggestion
--profile is registered here purely for help/acceptance, but nothing ever reads it out of ArgMatches; resolution happens in the pre-clap scan. Worth asserting the two agree — the scanner and clap can disagree on forms clap accepts (--profile=x, -pfoo, -xp foo bundling, -- terminator). If the scanner misses a form clap accepts, the flag is silently ignored while the command still parses, which is the worst failure mode for a tenant selector. A test matrix over those spellings (or re-reading matches.get_one(PROFILE_FLAG) and erroring on mismatch) would close it.
| let spec_owns_profiles = self | ||
| .profiles | ||
| .as_ref() | ||
| .is_some_and(|config| merged_subtree.find_subcommand(&config.command_name).is_some()); | ||
| let profiles_vocabulary = self.profiles_vocabulary(); |
There was a problem hiding this comment.
🔵 suggestion
self.profiles_vocabulary() is computed here and again inside builtin_commands(...) at the --schema branch (line 938), each walking every binding's parameter specs. Minor, but for large specs this doubles a non-trivial traversal on --schema; consider computing once earlier in dispatch_pipeline and passing it down.
| "description": format!( | ||
| "Run this command under a named profile (see `{}`), overriding \ | ||
| the <NAME>_PROFILE env var and the active profile", | ||
| config.command_name, | ||
| ), | ||
| })); | ||
| } |
There was a problem hiding this comment.
🔵 suggestion
The description hardcodes the literal <NAME>_PROFILE while the clap help (line 1133) uses the actual profile_env_var(&self.name). Agents reading --schema get a placeholder they can't act on. Pass the CLI name in and use profile_env_var here too.
| None => entry.insert( | ||
| "argument".into(), | ||
| arg.get_id().as_str().to_string().into(), | ||
| ), |
There was a problem hiding this comment.
🔵 suggestion
For positionals you emit the arg id, but the doc comment says "name it by its value placeholder". get_value_names() is what the user actually sees in help; the id may differ (e.g. snake_case internal ids). Consider arg.get_value_names().and_then(|v| v.first()) with the id as fallback.
Docs Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on
Docs generation runs |
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via |
Description
Linear ticket: Refs
Named profiles for generated CLIs. A profile is a named bundle of request context — a credential slot, parameter defaults, server variables, an optional base URL and output format — resolved once per invocation, so a tenant identifier stops being typed on every command.
Framework feature in
generators/cli/sdk, not a per-customer custom command: it is equally correct for a CLI with no subaccounts, no regions, and a single bearer token.Not new transport. A profile is a source of defaults for mechanisms that already accept defaults — the keyring account an
AuthCredentialSource::Keyringreads, aclap::Arg'sdefault_value,servers[].variablessubstitution,resolve_base_url_override,OutputPipeline. Nothing about auth, HTTP, retries, or command construction changes. That is what keeps it tractable and generic.Ships off unless
config.profiles.enabledis set. A CLI generated without the block is byte-identical to one generated before this change.Precedence, and the reasoning behind it, is in a new ADR:
0011-profile-resolution-precedence.md. ADR-0008 now points to it.Changes Made
generators/cli/sdk/src/profiles/—store.rs(format-preserving TOML viatoml_edit, single-levelparentinheritance, cycle/depth rejection),selection.rs(pre-clap-pscan →<BIN>_PROFILE→active→ none, plus the process-global slot),commands.rs(create | list | use | remove | current),mod.rs(the read-side helpers every consumer goes through).flag → env → profile → spec defaultat all eight resolution points. Env sits above profile so a CI pipeline that exports credentials is never silently overridden by a developer's stored profile; profile sits above spec defaults so it can change a parameter the spec defaults.<scheme>unprofiled,<scheme>#<credential>with a profile — so two tenants hold separate credentials for one scheme and existing keychain entries keep resolving after upgrade.TokenCachewas keyed bytoken_urlalone, so two profiles authenticating against the same client-credentials endpoint would have clobbered each other's access and refresh tokens. Now<token_url>#<credential>when profiled, byte-identical when not;profiles removepurges that profile's entries and only those.profiles.toml. The file names a keychain account;--with-token/--from-envwrite the credential to the OS keychain under a profile-scoped account.oauth_client_idis in the file because a client id is public by construction (RFC 6749 §2.2) andprofiles listshould show it without unlocking the keychain.--setis validated at write time, both halves.parametershas to be a free-form map — the framework cannot know which parameter is the tenant key — but an unvalidated key makes a typo a silent no-op, and an out-of-enum value makes every command carrying that parameter fail on a flag the caller never passed. Accepted values are derived from the samePossibleValues clap builds its parser from, so the check cannot drift from the command itself.--schemaunder a newbuiltinCommandsarray (auth,profiles,completion,man). They were absent entirely, so an agent reading--schemacould not discover the CLI had any way to authenticate or switch tenant.config.profiles.{enabled,commandName}with boundary validation,renderMainRsemits.profiles(...)only when enabled, changelog entry,docs/customize.mdsection, newcli-profilesseed fixture.Bugs found while building that the design didn't anticipate
profiles create --regionpanicked every invocation. Spec server-variable flags areglobal(true); clap propagates a global into a subcommand only when the subcommand has no arg with the same id, so acreate-local--regionunder idserver-var:regionwas two args sharing one long name → clap rejects the whole tree at startup. Only reproduced on the seed fixture, whose spec declares a server variable.createnow reads the propagated global, counting onlyCommandLineso the spec'sdefaultisn't frozen into every profile.x-fern-default.collect_params_from_flagssubstitutes the typed spec default whenever clap reportsDefaultValue— which a profile value also is.profiles listnever marked the active profile, because the group runs unprofiled by design (so a staleactivecan be repaired).--parentand no explicitcredentialgot its own empty slot instead of the parent's — breaking the subaccount case the feature exists for.Also renamed
create's--formatto--default-format: a same-id arg would have shadowed the global output flag, makingprofiles create p --format jsonquietly store a format while looking like a request for JSON output.Testing
Unit tests added/updated
Manual testing completed
2108 SDK tests pass (2051 unit + 57 integration). New: 58 unit tests, and three integration files —
tests/profiles.rs(34, through the compiled fixture binary),tests/profiles_server_vars.rs(8, the regression above),tests/profiles_command_name.rs(5, renamed group + a spec that owns theprofilesnoun).452 generator tests pass, including new
copySpecs/customConfigcoverage for the config block and its injection guards.All 157 CLI seed cases regenerated and passing.
cli-profilessnapshots the emittedmain.rs; every othercli-*fixture is the negative control — profiles must be entirely absent from their output.The generated binary was driven by hand to confirm the whole flow in real output: inherited tenant path parameter, inherited credential, child-overridden region,
-pswitching tenant for one invocation without changing the active profile.cargo build --locked --all-features --testspasses in the generated fixture.Regression guards specifically for the compatibility claim: a profile-less CLI's
--help,auth status, credential resolution, andx-fern-defaultbehaviour are unchanged, and-p nopeerrors with the known-profile list rather than silently falling back to env credentials.Deliberately not in this PR
--revoke/revokeOperation. The design names an operation but nothing specifies how its parameters are supplied from a profile, and there is noBindingseam to invoke an operation without syntheticArgMatches. Left out rather than shipping a flag that only prints a hint.enabled: falsefor a default that is alreadyfalsewould be misleading. Noted in the ADR.Note for the reviewer
sdk/Cargo.lockgainstoml_edit+ 3 transitives, so pergenerators/cli/CLAUDE.mdthe seed image needs rebuilding before the seed scripts will pass:I verified
cargo build --locked --all-features --testsin the generated fixture natively but did not run the docker build.Generated with Claude Code