feat(powershell): named az CLI profile switcher (azs) replacing azw/azp - #39
Merged
Conversation
Get-AzCliAccountSegment hardcoded az:personal when AZURE_CONFIG_DIR was set and az:work when unset. Under the named-profile scheme an unset variable means az's own default ~/.azure, whatever is logged into it on that machine — rendering it as "work" was a lie and the reason the old design was unportable. Render the path leaf as the profile name so arbitrary names work, and az:default when unset. Still env-read plus string ops only: no az process and no filesystem access, since this runs on every prompt. Closes #34
The old switch was asymmetric: azp set AZURE_CONFIG_DIR to ~/.azure-personal while azw UNSET it so az fell back to the default ~/.azure. That made "work" mean "whatever is logged into the default dir on this machine" rather than a profile, so it did not survive a second machine, and it could not express a third account. Profiles are now named directories under ~/.azure-profiles/<name>, each a self-contained AZURE_CONFIG_DIR holding exactly one identity. That last part is load-bearing: az devops builds its auth candidate list from the config dir's cached subscriptions and uses the first identity that can list any project, so a dir holding several accounts can silently authenticate as one you did not pick. Switch-AzProfile (azs) takes -Name, or opens an fzf picker when omitted. The picker previews each profile's cached identity by reading its azureProfile.json directly, so no az process is spawned per keystroke, and it is the only path that enumerates. Restore-AzActiveProfile stays within its Phase 1 budget of one state-file read. AZURE_EXTENSION_DIR is now set unconditionally at a single shared extension store. Without it a non-default config dir sees zero extensions and both az devops and az graph break. Closes #33
Both files documented the old two-account asymmetry as a deliberate design decision, which made them actively misleading now that the design is gone. Rewritten in place rather than annotated. CLAUDE.md gains the four rejected alternatives with their deciding facts — the shared ~/.azure directory that rules out a junction, the non-atomic directory move, the ~/.azure-* glob collision, and the manifest as a second source of truth — so the design cannot be re-litigated from first principles later. powershell/README.md gains a worked example and a migration section covering why profile dirs are seeded empty rather than copied. Closes #35
Restore-AzActiveProfile changes the active account exactly as Switch-AzProfile does, but did not clear $global:__AdoAccessToken. A fresh shell has nothing cached so the gap was invisible there, but `. $PROFILE` in a session that had already run prr kept the previous account's bearer token — and after another shell rewrote the state file, prr would list the wrong account's PRs with no signal. Also strengthen the unset assertions: Should -BeNullOrEmpty passes when the variable still exists holding an empty string, which is a different state from removed and not what the contract promises. Test-Path Env: checks removal. Two comments still described the retired azw/azp scheme; neither file was owned by the implementation tickets, so they were missed. Found by a Codex review of the branch.
jinyeow
commented
Jul 27, 2026
| # Users first — that is the identity the one-identity-per-profile rule is about. A | ||
| # service-principal / managed-identity login writes no user.name, so fall back to the | ||
| # subscription names rather than mislabelling a real login as logged out. | ||
| [string[]]$users = @($subscriptions.user.name | Where-Object { $_ } | Select-Object -Unique) |
Owner
Author
There was a problem hiding this comment.
can we use $subscriptions.user.name.Where(...) ...?
The PowerShell pipeline carries ~80us of fixed setup cost per stage,
independent of collection size, so the method form wins most at the small
collection sizes this code actually sees. Measured per call, N=3:
| Where-Object { $_ } 108us -> 10us (10x)
@(| Where-Object { $_.isDefault })[0] 75us -> 3us (25x)
The first-match case gains more because .Where(..., 'First') short-circuits
where @(...)[0] always enumerates the whole collection.
.Where(..., 'First') yields an EMPTY collection rather than $null when
nothing matches. The existing truthiness test handles that, but indexing
[0] into it throws under StrictMode, so the bare form is used and the
constraint is noted in a comment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the asymmetric two-account az CLI switcher with named profiles.
Closes #33, closes #34, closes #35, closes #36. Design context and rejected alternatives: #37.
Why
azpsetAZURE_CONFIG_DIR=~/.azure-personal;azwunset it soazfell back to the default~/.azure. So "work" was not a profile — it was "whatever happens to be logged into the default dir on this machine". That didn't survive a second machine, the prompt confidently renderedaz:workover whatever was actually there, and a third account couldn't be expressed at all.A second reason emerged while investigating:
az devopsbuilds its auth candidate list from the config dir's cached subscriptions and uses the first identity that can list any project. A config dir holding several logins can therefore authenticate as an account you did not select, with no signal at the shell. The live default dir here held three identities. One identity per profile dir is what this design enforces.What changed
Switch-AzProfile(aliasazs) pointsAZURE_CONFIG_DIRat~/.azure-profiles/<name>.-Nameswitches directly; omitting it opens an fzf picker.azureProfile.jsondirectly — noazprocess per keystroke, and it works while logged out. Barefzfin a native pipeline, not PSFzf'sInvoke-Fzf(which desyncs under psmux's ConPTY).defaultis reserved: it means unsetAZURE_CONFIG_DIR, i.e. az's own~/.azure, and creates no directory.Restore-AzActiveProfilestays inside its Phase 1 budget of one state-file read — all enumeration lives in the picker.AZURE_EXTENSION_DIRis now set unconditionally at one shared store. Without it a fresh config dir has zero extensions andaz devops/az graphbreak outright.az:<name>, oraz:defaultwhen unset. The old code renderedaz:workon unset, which was the specific lie that made the design unportable.CLAUDE.mdandpowershell/README.md, including the four rejected alternatives with their deciding facts, plus a migration section. Wiki updated separately.Notable decisions
~/.azureis deliberately left untouched. On Windows it is one case-insensitive directory shared with Azure PowerShell —AzureRmContext.jsonsits besideazureProfile.json— so junctioning or swapping it would silently switch everyGet-Az*too. Azure PowerShell is out of scope here.There is no manifest file listing profiles. The directories are the only registry; adding a profile is "switch to it and log in", never "edit a file".
Verification
tests/AzCliAccount.Tests.ps1+tests/Set-Prompt.Tests.ps1: 50 passed, 0 failed. Written test-first throughout.psmux/plugins/, which the CI gate excludes).--delimiter/--with-nthcontract was smoke-tested directly rather than assumed:--with-nth 1still returns the whole line, so splitting on the tab to recover the name is correct.Review
Codex reviewed the branch. Applied from it:
Restore-AzActiveProfilenow clears$global:__AdoAccessToken(it changes accounts just as a switch does, so. $PROFILEin a session that had runprrkept a stale token); unset assertions now useTest-Path Env:rather thanShould -BeNullOrEmpty, which also passes for a variable that exists holding an empty string; two staleazw/azpcomments in files no ticket owned.Deferred deliberately: rejecting reparse points to guarantee filesystem confinement. The name regex guards against malformed names and string traversal, which it does completely. Planting a junction inside
~/.azure-profiles/requires write access to the home directory, and anyone with that can already read the token cache or edit the profile script — so the check would defend against an attacker who has already won by easier routes, at the cost of extra filesystem calls on every switch and breaking legitimate uses like relocating a profile dir to another drive.Migration
Not automatic — see the Migration section in
powershell/README.md. In short: move~/.azure-personalinto the new container, thenazs <name>+az loginonce per account, seeding each dir empty so none ever caches two identities.