Tracking issue for the az CLI profile switcher rework. Design context, rejected alternatives, and the shared contract binding every child ticket.
Children: #33 (switcher), #34 (prompt segment), #35 (repo docs), #36 (wiki).
Problem
powershell/Profile/AzCliAccount.ps1 switches the Azure CLI between exactly two accounts asymmetrically: azp sets AZURE_CONFIG_DIR=~/.azure-personal, azw unsets it so az falls back to the default ~/.azure.
Two failures:
- Not portable. "Work" isn't a profile — it's "whatever happens to be logged into the default dir on this machine." On a second machine that's a guess, and the prompt confidently renders
az:work over whatever is actually there.
- Can't express more than two profiles, one of which is defined by absence.
Additional motivation found while investigating: az devops builds its auth candidate list from the config dir's cached subscriptions and returns the first identity that can list any project. A config dir holding several identities can therefore silently authenticate as an account you did not select, with no signal at the shell. One identity per profile dir is the mitigation this design enforces.
Design
Named profiles, each a self-contained AZURE_CONFIG_DIR under ~/.azure-profiles/<name>, switched by Switch-AzProfile (alias azs) with an fzf picker. The prompt renders the profile name; an unset AZURE_CONFIG_DIR renders az:default.
Rejected alternatives — do not reintroduce
- Junction or symlink
~/.azure at the active profile. Mechanically the best "everything follows the active account" option — retargeting a reparse point is one metadata write and succeeds even with open file handles (verified). Rejected because on Windows the default dir is ONE case-insensitive directory (C:\Users\<user>\.Azure) shared by Azure CLI and Azure PowerShell — AzureRmContext.json and AzureRmContextSettings.json sit beside azureProfile.json. Junctioning it would silently switch every Get-Az* call too. Azure PowerShell is out of scope; ~/.azure stays untouched.
- Directory swapping (profiles parked in a store, swapped into
~/.azure). Move-Item on a directory is copy-then-delete, not atomic, and leaves torn state on failure (verified: a failed swap left an empty destination while the source still held the payload). True atomic rename via [IO.Directory]::Move fails outright when any file inside is open, and az holds msal_token_cache.bin open during every command. Structurally it also makes the active account machine-global mutable state — switching in one shell silently changes the account under every other shell.
- Discovery by a
~/.azure-* glob. The home directory already contains .azure-devops, .azure-functions-core-tools, and .azurefunctions, which such a glob would offer as az profiles. Hence a container directory.
- A manifest/config file (json/yaml/toml) listing profiles. A second source of truth that drifts from the directories actually holding the credentials. Adding a profile must be "switch to it and log in", never "edit a file". Do not add one "for robustness".
Shared contract — binds every child ticket
- Container
~/.azure-profiles; profile dir ~/.azure-profiles/<name>.
- The profile name is exactly the leaf of
$env:AZURE_CONFIG_DIR. Unset = the reserved name default = az's own ~/.azure.
- Valid names match
^[A-Za-z0-9][A-Za-z0-9_-]*$. default is reserved: switching to it unsets AZURE_CONFIG_DIR and creates no directory.
- State file
~/.azure-active-profile, first line = profile name.
- No component ever runs
az to learn the active profile — the prompt reads the env var, the picker reads azureProfile.json files.
AZURE_EXTENSION_DIR is set once, unconditionally, at a single shared extension store. Verified: with a fresh AZURE_CONFIG_DIR and no override az extension list is empty; with the override pointed at the existing cliextensions dir it lists the installed extensions. Without it, every non-default profile loses az devops and az graph.
Restore-AzActiveProfile runs in profile Phase 1 — one state-file read, zero enumeration, zero JSON parsing. Discovery lives only in the picker.
- Every switch clears
$global:__AdoAccessToken so prr re-authenticates against the now-active account.
- TDD throughout: Pester cases written first and seen RED, then implemented to GREEN. Existing tests are updated to pin new behaviour, never deleted to pass.
- The repo is public: no real account names, tenant GUIDs, employer names, or organization URLs in code, tests, fixtures, comments, docs, or commits.
Migration
- Create
~/.azure-profiles and move ~/.azure-personal → ~/.azure-profiles/personal.
- For each other account:
azs <name> (creates an empty dir), then az login once inside it. Seed empty so no profile dir ever caches two identities — that is the entire point.
- Leave
~/.azure as-is; the Azure PowerShell context lives there. Stop logging the CLI into it.
- Run
azs once after migrating so the state file maps onto the new container.
Verification
Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1
$config = New-PesterConfiguration
$config.Run.Path = 'tests'
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $config
Manual smoke: a new shell shows az:default or the restored profile name; azs opens the picker with identity preview; azs <newname> creates the dir and prints the login hint; az extension list under a non-default profile still shows the shared extensions.
Tracking issue for the az CLI profile switcher rework. Design context, rejected alternatives, and the shared contract binding every child ticket.
Children: #33 (switcher), #34 (prompt segment), #35 (repo docs), #36 (wiki).
Problem
powershell/Profile/AzCliAccount.ps1switches the Azure CLI between exactly two accounts asymmetrically:azpsetsAZURE_CONFIG_DIR=~/.azure-personal,azwunsets it so az falls back to the default~/.azure.Two failures:
az:workover whatever is actually there.Additional motivation found while investigating:
az devopsbuilds its auth candidate list from the config dir's cached subscriptions and returns the first identity that can list any project. A config dir holding several identities can therefore silently authenticate as an account you did not select, with no signal at the shell. One identity per profile dir is the mitigation this design enforces.Design
Named profiles, each a self-contained
AZURE_CONFIG_DIRunder~/.azure-profiles/<name>, switched bySwitch-AzProfile(aliasazs) with an fzf picker. The prompt renders the profile name; an unsetAZURE_CONFIG_DIRrendersaz:default.Rejected alternatives — do not reintroduce
~/.azureat the active profile. Mechanically the best "everything follows the active account" option — retargeting a reparse point is one metadata write and succeeds even with open file handles (verified). Rejected because on Windows the default dir is ONE case-insensitive directory (C:\Users\<user>\.Azure) shared by Azure CLI and Azure PowerShell —AzureRmContext.jsonandAzureRmContextSettings.jsonsit besideazureProfile.json. Junctioning it would silently switch everyGet-Az*call too. Azure PowerShell is out of scope;~/.azurestays untouched.~/.azure).Move-Itemon a directory is copy-then-delete, not atomic, and leaves torn state on failure (verified: a failed swap left an empty destination while the source still held the payload). True atomic rename via[IO.Directory]::Movefails outright when any file inside is open, andazholdsmsal_token_cache.binopen during every command. Structurally it also makes the active account machine-global mutable state — switching in one shell silently changes the account under every other shell.~/.azure-*glob. The home directory already contains.azure-devops,.azure-functions-core-tools, and.azurefunctions, which such a glob would offer as az profiles. Hence a container directory.Shared contract — binds every child ticket
~/.azure-profiles; profile dir~/.azure-profiles/<name>.$env:AZURE_CONFIG_DIR. Unset = the reserved namedefault= az's own~/.azure.^[A-Za-z0-9][A-Za-z0-9_-]*$.defaultis reserved: switching to it unsetsAZURE_CONFIG_DIRand creates no directory.~/.azure-active-profile, first line = profile name.azto learn the active profile — the prompt reads the env var, the picker readsazureProfile.jsonfiles.AZURE_EXTENSION_DIRis set once, unconditionally, at a single shared extension store. Verified: with a freshAZURE_CONFIG_DIRand no overrideaz extension listis empty; with the override pointed at the existingcliextensionsdir it lists the installed extensions. Without it, every non-default profile losesaz devopsandaz graph.Restore-AzActiveProfileruns in profile Phase 1 — one state-file read, zero enumeration, zero JSON parsing. Discovery lives only in the picker.$global:__AdoAccessTokensoprrre-authenticates against the now-active account.Migration
~/.azure-profilesand move~/.azure-personal→~/.azure-profiles/personal.azs <name>(creates an empty dir), thenaz loginonce inside it. Seed empty so no profile dir ever caches two identities — that is the entire point.~/.azureas-is; the Azure PowerShell context lives there. Stop logging the CLI into it.azsonce after migrating so the state file maps onto the new container.Verification
Manual smoke: a new shell shows
az:defaultor the restored profile name;azsopens the picker with identity preview;azs <newname>creates the dir and prints the login hint;az extension listunder a non-default profile still shows the shared extensions.