Keep more than one HEY sign-in on one computer: a second HEY account, or staging or a beta beside the account you use every day. Each is a named profile with its own server, mail account and credential, chosen per command with -P/--profile or HEY_PROFILE, or by default_profile.
With no profiles configured nothing changes: the same config reads, the same credential key, the same answers.
What it ports
basecamp-cli's profiles at 48b01aaf, adapted to hey's names:
profiles and default_profile in the global config (internal/config/config.go's ProfileConfig: base_url, account_id), merged across files by mergeProfile's rule.
-P/--profile, HEY_PROFILE, and resolveProfile (internal/cli/root.go) with its order and its error words: flag, env, default_profile, a lone profile, a picker at a terminal (never under HEY_NONINTERACTIVE or machine output), else multiple profiles configured but none selected; use --profile or set default_profile (available: …).
Credentials keyed profile:<name> (ProfileCredentialKey); hey mcp's apart as mcp:profile:<name>. A response cache per profile.
hey profile list|show|create|delete|set-default (internal/commands/profile.go) with its fields and messages. create signs in before it writes anything and the first profile becomes the default; delete revokes the profile's sign-ins at its own server, removes them and clears default_profile.
hey-specific:
A repository's .hey/config.json sets profiles/default_profile only once hey config trust-local trusts them: they join the digest trusted_local_configs already records, so a change needs trust again, and a config trusted before profiles keeps its digest. Untrusted, they are ignored with basecamp-cli's warning.
meta.profile on every JSON answer while a profile is active; hey auth status, hey config show and hey doctor name it.
A profile that names no account uses All Accounts, not the top-level account default, which belongs to the top-level sign-in; hey account use under a profile saves the account on the profile.
hey profile … and the commands that read no server (version, upgrade, skill, setup subcommands) run with no profile selected, so hey profile set-default can answer the refusal that names it. A profile hey would refuse (plain HTTP to a remote host) doesn't lock out profile delete/set-default.
A sign-in hint under a profile reads Run: hey auth login -P <name>.
A profile's credential goes only to the profile's own server. basecamp-cli lets --base-url or BASECAMP_BASE_URL override a profile's server; hey refuses an override naming another server (HEY_BASE_URL https://… is not profile "work"'s server (…)), and refuses a stored sign-in whose token endpoint is on another server, so an edited base_url never carries the old token over.
A repository entry refines a global profile only on the same server; one that would move it elsewhere is skipped with a warning. A repository's account_id follows a profile only on the files' own server.
delete refuses a profile a repository config sets, alone or beside the global entry, and revokes only at the server that issued the sign-in, warning where it can't; hey auth logout --mcp -P <name> revokes only there too, and a profile create whose config write fails revokes the new sign-in.
Verification
make check passes.
Four fresh review rounds: the last, over the final fixes alone, found no High or Medium.
Each guard added after review (issuer check, server override, repository account, local-only delete, account type, default count, completion fallback, config subcommands, status hints) has a test that fails with the guard disabled.
Red first, on origin/main (e6d5bcb) with the new tests: TestProfilesConfiguredButNoneSelectedRefuses, TestProfileSelectionNamesWhatIsConfigured, TestProfileResolutionOrder, TestEnvironmentAndFlagsOverrideTheProfile, TestAProfilesCredentialIsNeverReadByAnother and TestRepositoryLocalProfilesNeedTrust failed; TestNoProfilesChangesNothing passed there and passes here.
TestNoProfilesChangesNothing pins the no-profile path: the token is stored under the origin, mcp::<origin> stays hey mcp's key, auth status and config show answer as before, and no answer names a profile. TestLocalConfigDigestWithoutProfilesIsUnchanged pins the trust digest.
hey --help against a build of origin/main differs only by profile Manage named profiles and -P, --profile Use a named profile; each subcommand's help only by the inherited -P flag.
.surface gains the profile commands and --profile, nothing else.
Left out on purpose
The HEY app endpoint (endpoint: app, the gate, the per-endpoint checks) is the next PR, A, built on this one.
--scope and client_id on a profile. hey's CLI client holds only full, and a read-only sign-in against it is unverified; basecamp-cli's own profile scope is a record of the grant, not used at sign-in.
--expect-identity, auth login creating a profile (AnnotationProfileMayCreate), and the per-field origin bookkeeping basecamp-cli keeps for its account-binding hints.
hey setup creating profiles: a later PR, C.
Confidence Score: 2/5
Fix the refresh issuer check and repository profile account saves before merging.
### Issue 1
internal/auth/auth.go:438-440
`checkProfileIssuer` runs on the first credential load, but not when `refreshLocked` rereads the store. Start `hey tui -P work` on server A, change `work.base_url` to server B, and sign in again as `work`. When the running TUI refreshes, it reads B's credential under the same key. Unlike MCP, the CLI manager has no pinned grant. If the access token changed, `refreshLocked` caches it and returns immediately, so the next request sends B's token to A.
Check the issuer of `stored` before caching or using it in `refreshLocked`.
**How this was verified:** The refresh path rereads the profile's shared credential key and accepts a changed access token without comparing its issuer to the running manager's server.
### Issue 2
internal/config/config.go:504-506
`SaveAccountID` sends every active profile to a writer that only edits the global config. This breaks two supported cases:
- For a profile defined only in a trusted `.hey/config.json`, `hey -P work account use all` fails with `Profile "work" not found`, even though `work` is active.
- If both files define the profile and the local entry sets `account_id`, the write succeeds but the local value still wins on the next run, with no warning.
Make the save path account for where the profile and its account value came from.
### Issue 3
internal/cmd/profile_select.go:76-84
`mayLeaveUnselected` is checked only after choosing the default, choosing a lone profile, and opening the picker. With a lone profile whose `base_url` uses remote plain HTTP, `hey version` fails in `ApplyProfile` instead of printing the version. With several profiles and no default, it unnecessarily opens the picker at a terminal.
Skip automatic selection for commands that read no server, rather than only suppressing the final selection error.
### Issue 4
internal/cmd/root.go:598-599
`profileOption` is added only to successful responses. A command that fails after selecting a profile, such as `hey -P work --json box list` while signed out, goes through `writer.Err` and returns no `meta.profile`. Programs therefore cannot consistently identify which sign-in failed.
Add the active profile to error metadata too, while keeping the no-profile output unchanged.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary
Adds named profiles with separate credentials and caches, trusted repository configuration, profile selection, and management commands.
HEY commands can use a named profile's server and mail account.
The hey profile commands manage named sign-ins and their settings.
Each profile keeps its own CLI and MCP sign-in and response cache.
Repository profiles take effect only after the repository config is trusted.
Active profiles are named in JSON answers and diagnostic output.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Load global and repository config] --> B[Trust repository profile keys]
B --> C[Choose profile]
C --> D[Apply server and account]
D --> E[Use profile credential key]
E --> F[Check stored credential issuer]
F --> G[Make requests]
G --> H[Refresh rereads shared credential key]
H --> I[Cache replacement without issuer check]
I --> G