diff --git a/CLAUDE.md b/CLAUDE.md index 8cda56c..baaa7a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,11 +30,13 @@ The PowerShell profile (`powershell/Microsoft.PowerShell_profile.ps1`) is a sing `Microsoft.PowerShell_profile.ps1` is structured as **three phases** to keep startup fast: -1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), aliases, tool wrapper functions (`y` for yazi). +1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), dot-source `Profile/AzCliAccount.ps1` followed by an explicit `Restore-AzActiveProfile` (az CLI account switch — see below), aliases, tool wrapper functions (`y` for yazi). 2. **Phase 2a (first idle)**: `Initialize-DeferredProfile` loads PSFzf and zoxide, upgrades PSReadLine to `HistoryAndPlugin`, sets the fd-backed `FZF_*_COMMAND` vars, defines the eza `ll`/`la`/`lt` helpers, and calls `Initialize-AzTimer` (Az context timer — `runspace.Open()` + first eventing call ~200ms, moved off the load path). Guarded by `$global:ProfileDeferredDone`. These are the interactive tools needed immediately after the prompt appears. 3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers, defines the `prr`/`Invoke-AdoPrReview` ADO PR review helper (az-gated: REST-backed fzf PR picker — `ConvertFrom-AdoRemoteUrl` + a session-cached az bearer token (`Get-AdoAccessToken`), ~0.2s warm vs ~2-3s for `az repos pr list` — → detached `review` worktree → `nvim "+AdoPrReview "`, pairing with ado-pr.nvim), and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. 4. **Phase 3 (async runspace)**: Az context refresh on a 60s timer. The timer is created in Phase 2a (`Initialize-AzTimer`); the refresh itself runs in a background runspace wired up in `Set-Prompt.ps1`. +**az CLI account switch (`Profile/AzCliAccount.ps1`, `azw`/`azp`)**: `Switch-AzWork` / `Switch-AzPersonal` flip `az` between the work account (default `~/.azure`, `AZURE_CONFIG_DIR` **unset**) and the personal account (`~/.azure-personal`, `AZURE_CONFIG_DIR` set) — asymmetric on purpose so the existing work login is never disturbed. The file **defines functions only, no load-time side effects** (mirroring how `Set-Prompt.ps1` defines `Initialize-AzTimer` but the profile calls it); Phase 1 dot-sources it and then calls `Restore-AzActiveProfile`, which reads the `~/.azure-active-profile` state file and re-applies the last-used account (a `work`/missing/empty/unrecognized state **actively unsets** `AZURE_CONFIG_DIR`, never merely skips, so an inherited value can't survive). Each switch **clears `$global:__AdoAccessToken`** so `prr`/`Invoke-AdoPrReview` (Phase 2b) re-authenticates against the now-active account instead of listing the previous account's PRs from the session-cached token. The active account is **always shown in the prompt** — `Set-Prompt.ps1`'s `Get-AzCliAccountSegment` renders an `az:work`/`az:personal` tag on the context line from `$env:AZURE_CONFIG_DIR` alone (no `az` process), so a new shell's silent `Restore-AzActiveProfile` is still visible at a glance. Covered by `tests/AzCliAccount.Tests.ps1` and `tests/Set-Prompt.Tests.ps1`. + When adding to the profile, classify by cost first — anything that loads .NET assemblies or scans the filesystem belongs in Phase 2, not Phase 1. Use the `$global:ProfileModules` cache rather than calling `Get-Module -ListAvailable` again. ## Prompt architecture (`Set-Prompt.ps1`) diff --git a/powershell/Microsoft.PowerShell_profile.ps1 b/powershell/Microsoft.PowerShell_profile.ps1 index 89f5001..15eec47 100644 --- a/powershell/Microsoft.PowerShell_profile.ps1 +++ b/powershell/Microsoft.PowerShell_profile.ps1 @@ -117,6 +117,13 @@ if ($global:ProfileInteractiveConsole) { # --- Prompt ----------------------------------------------------------------- . "$(Split-Path -Path $PROFILE)/Profile/Set-Prompt.ps1" +# --- az CLI account switch (azw/azp) ---------------------------------------- +# Defines functions only (no load-time side effects); the explicit Restore call +# below applies the persisted last-used account. Cheap enough for Phase 1 — one +# state-file read plus an env-var set. +. "$(Split-Path -Path $PROFILE)/Profile/AzCliAccount.ps1" +Restore-AzActiveProfile + # Native tab-completers for az + zellij are registered in Phase 2b # (Initialize-DeferredProfileSecondary) — they only matter once you tab-complete, # so registering them at load just delayed the first prompt. diff --git a/powershell/Profile/AzCliAccount.ps1 b/powershell/Profile/AzCliAccount.ps1 new file mode 100644 index 0000000..6e5b546 --- /dev/null +++ b/powershell/Profile/AzCliAccount.ps1 @@ -0,0 +1,92 @@ +# ============================================================================ +# AzCliAccount.ps1 — switch the Azure CLI between PERSONAL and WORK accounts +# ============================================================================ +# Defines functions only; NO side effects at dot-source. The profile calls +# Restore-AzActiveProfile explicitly in Phase 1 (mirroring how Set-Prompt.ps1 +# defines Initialize-AzTimer but the profile calls it). +# ============================================================================ + +function Show-AzAccountStatus { + <# + .SYNOPSIS + Best-effort: append the active az account (or a login hint) after a switch + announce. Must never throw or block — az may be slow, absent, or logged out, and + the deterministic announce has already printed. Never runs `az login` itself. + #> + try { + $account = az account show --query 'user.name' --output tsv 2>$null + if ($LASTEXITCODE -eq 0 -and $account) { + Write-Host " active: $account" -ForegroundColor DarkGray + } else { + Write-Host ' not logged in — run: az login' -ForegroundColor DarkGray + } + } catch { + Write-Host ' not logged in — run: az login' -ForegroundColor DarkGray + } +} + +function Switch-AzPersonal { + <# + .SYNOPSIS + Switch the Azure CLI to the PERSONAL account by pointing AZURE_CONFIG_DIR at an + isolated per-account config dir (~/.azure-personal), so its token cache never + mixes with the work login under the default ~/.azure. + .DESCRIPTION + Persists the choice to the ~/.azure-active-profile state file so a new shell + restores it, clears the session-cached ADO bearer token (so prr re-authenticates + against this account), then announces the switch and — best effort — the active + account. Aliased as azp. + #> + $dir = Join-Path $HOME '.azure-personal' + $env:AZURE_CONFIG_DIR = $dir + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'personal' + # Force prr/Invoke-AdoPrReview to re-fetch a token for the now-active account + # instead of listing the previous account's PRs from the stale cached token. + $global:__AdoAccessToken = $null + Write-Host "→ az account → personal ($dir)" -ForegroundColor Magenta + Show-AzAccountStatus +} + +function Switch-AzWork { + <# + .SYNOPSIS + Switch the Azure CLI back to the WORK account by unsetting AZURE_CONFIG_DIR, so az + falls back to the default ~/.azure that already holds the work login. + .DESCRIPTION + Persists the choice to the ~/.azure-active-profile state file so a new shell + restores it, clears the session-cached ADO bearer token (so prr re-authenticates + against this account), then announces the switch and — best effort — the active + account. Aliased as azw. + #> + Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'work' + # Force prr/Invoke-AdoPrReview to re-fetch a token for the now-active account + # instead of listing the previous account's PRs from the stale cached token. + $global:__AdoAccessToken = $null + Write-Host '→ az account → work (default ~/.azure)' -ForegroundColor Cyan + Show-AzAccountStatus +} + +function Restore-AzActiveProfile { + <# + .SYNOPSIS + Apply the last-used az account by reading the ~/.azure-active-profile state file + and setting AZURE_CONFIG_DIR accordingly. Silent by design — the profile calls it + in Phase 1 and startup must stay quiet. Not aliased; called once at profile load. + #> + $stateFile = Join-Path $HOME '.azure-active-profile' + $state = if (Test-Path -LiteralPath $stateFile) { + (Get-Content -LiteralPath $stateFile -ErrorAction Ignore | Select-Object -First 1) + } + if ($state) { $state = $state.Trim() } + if ($state -eq 'personal') { + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + } else { + # work, missing, empty, or unrecognized — ACTIVELY unset, never merely skip, so an + # inherited/parent AZURE_CONFIG_DIR can't survive against a non-personal state. + Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore + } +} + +Set-Alias -Name azp -Value Switch-AzPersonal +Set-Alias -Name azw -Value Switch-AzWork diff --git a/powershell/Profile/Set-Prompt.ps1 b/powershell/Profile/Set-Prompt.ps1 index 812e4d7..3c5c391 100644 --- a/powershell/Profile/Set-Prompt.ps1 +++ b/powershell/Profile/Set-Prompt.ps1 @@ -519,6 +519,22 @@ function Get-ShortenedBranch { return $Branch.Substring(0, $keep) + '...' + $Branch.Substring($Branch.Length - $keep) } +function Get-AzCliAccountSegment { + <# + .SYNOPSIS + Render the active az CLI account tag (az:work / az:personal) for the prompt + context line, so the current account is always visible. Cheap by design — + reads $env:AZURE_CONFIG_DIR only, never spawns az. Set (→ ~/.azure-personal) + means personal; unset (the default ~/.azure) means work — the asymmetric + contract owned by Profile/AzCliAccount.ps1 (Switch-Az*). + #> + $c = $global:PromptConst + if ($env:AZURE_CONFIG_DIR) { + return "$($c.DimWhite)az:$($c.Magenta)personal$($c.Reset) " + } + return "$($c.DimWhite)az:$($c.Green)work$($c.Reset) " +} + # --- Prompt function -------------------------------------------------------- function prompt { # Capture immediately before any commands corrupt these @@ -665,6 +681,11 @@ function prompt { $null = $contextLine.Append("$($c.Cyan)$($az.Subscription)$($c.Reset) ") } + # az CLI active account — always shown so the current account (work/personal) + # is never in doubt. Cheap: an env-var read, no az process. This makes the + # context line render on every prompt (there is now always something to show). + $null = $contextLine.Append((Get-AzCliAccountSegment)) + # Background jobs count $runningJobs = @(Get-Job -State Running).Count if ($runningJobs -gt 0) { diff --git a/powershell/README.md b/powershell/README.md index d9cf7fc..f06c42e 100644 --- a/powershell/README.md +++ b/powershell/README.md @@ -22,6 +22,7 @@ PowerShell 7 profile and prompt for Windows (and Linux where applicable). |---|---| | `Microsoft.PowerShell_profile.ps1` | Shared profile — used on all machines | | `Profile/Set-Prompt.ps1` | Prompt definition (jj/git + Azure context) | +| `Profile/AzCliAccount.ps1` | Azure CLI account switch (`azw`/`azp`) — defines functions only; the profile calls `Restore-AzActiveProfile` in Phase 1 | The installer generates a stub at `~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1` that dot-sources the repo file — changes are live immediately without re-running setup. @@ -100,10 +101,41 @@ in `Set-Prompt.ps1`. - jj (Jujutsu) change-id, closest bookmark, ahead count, and state — shown instead of git in jj repos (takes precedence in colocated repos; toggle `ShowJj`). Gate is a filesystem walk; up to 3 jj processes, all `--ignore-working-copy`. Renders `jj: * ∅ ✎` (`∅` empty, `✎` no description, `!` conflict) - Git branch and status — synchronous, 3 git processes per prompt (used when not in a jj repo) - Azure subscription context — async via background runspace, refreshed every 60s +- Active **az CLI account** tag (`az:work` / `az:personal`) — always shown so the current account is never in doubt; a cheap `$env:AZURE_CONFIG_DIR` read, no `az` process (see the account-switch section below) - Last command exit status (colour coded) and execution time - Truncated path for long directories - Windows Terminal OSC 9;9 CWD tracking; also syncs the Win32 process CWD so Zellij opens new panes in the current directory +## Azure CLI account switch (`azw` / `azp`) + +Flip the Azure CLI (`az`) between a **work** and a **personal** account in the running +shell — no new terminal, no re-login. `az` keeps all state (including the token cache) +under one config dir, so each account gets its own dir via `AZURE_CONFIG_DIR`: + +| Alias | Function | Account | `AZURE_CONFIG_DIR` | +|---|---|---|---| +| `azw` | `Switch-AzWork` | work | **unset** → default `~/.azure` (keeps the existing work login) | +| `azp` | `Switch-AzPersonal` | personal | `~/.azure-personal` (isolated token cache) | + +The strategy is deliberately **asymmetric**: work stays on the default `~/.azure` (so an +existing work login is untouched), and only personal gets a redirected config dir. + +The active account is **always visible in the prompt** as an `az:work` / `az:personal` tag +(green for work, magenta for personal) on the context line — driven purely by +`$env:AZURE_CONFIG_DIR`, so it costs nothing and never spawns `az`. + +- **Persistence**: each switch writes `work` or `personal` to the `~/.azure-active-profile` + state file. On startup the profile calls `Restore-AzActiveProfile` (Phase 1) which reads + that file and re-applies the choice — so the active account survives new shells. A `work`, + missing, empty, or unrecognized state **actively unsets** `AZURE_CONFIG_DIR` (it never + merely skips), so an inherited value can't leak a stale personal dir into a work shell. +- **`prr` follows the active account**: switching clears the session-cached ADO bearer token + (`$global:__AdoAccessToken`), so `prr` / `Invoke-AdoPrReview` (Phase 2b) re-authenticates + against whichever account is now active. Run `prr` under the account that owns the PRs. +- Each switch announces the target account immediately, then makes a best-effort + `az account show` to append the active user (or an `az login` hint). The announce never + blocks or throws if `az` is slow, absent, or logged out; it never runs `az login` for you. + ## Per-machine differences There is a single shared profile — no per-machine variants. Machine-specific diff --git a/tests/AzCliAccount.Tests.ps1 b/tests/AzCliAccount.Tests.ps1 new file mode 100644 index 0000000..1e09b73 --- /dev/null +++ b/tests/AzCliAccount.Tests.ps1 @@ -0,0 +1,102 @@ +#Requires -Version 7 +# Pester tests for powershell/Profile/AzCliAccount.ps1 — the az CLI account switch +# (azw/azp). The file defines functions with no load-time side effects, so it is +# dot-sourced directly (unlike the profile, which is AST-lifted). `az` is shadowed by +# a function stub so the best-effort `az account show` announce never hits the network +# or requires az to be installed. Assertions are on STATE — $env:AZURE_CONFIG_DIR, the +# state-file content, and the cleared $global:__AdoAccessToken — never on az invocation. + +BeforeAll { + $scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell' 'Profile' 'AzCliAccount.ps1' + . $scriptPath + + # Shadow the az CLI: a function always beats an external application by command + # precedence, so this keeps the announce offline whether or not az is installed. + function az { } +} + +Describe 'AzCliAccount' { + BeforeEach { + # Redirect $HOME to a fresh, isolated dir so path resolution (state file + + # personal config dir) is scoped per test and no state file leaks across tests. + # $HOME is a ReadOnly, AllScope automatic variable — AllScope defeats a child-scope + # shadow, so the only way to redirect it is Set-Variable -Force (which overrides + # ReadOnly), saved and restored in AfterEach. Set-Variable (not `$HOME = …`) also + # sidesteps the PSAvoidAssignmentToAutomaticVariable analyzer error a direct + # assignment raises. + $script:origHome = $HOME + Set-Variable -Name HOME -Value (Join-Path $TestDrive ([guid]::NewGuid().ToString())) -Force + New-Item -ItemType Directory -Path $HOME -Force | Out-Null + + # Save mutated global state; the functions overwrite these. + $script:origConfigDir = $env:AZURE_CONFIG_DIR + $script:origAdoToken = $global:__AdoAccessToken + } + + AfterEach { + Set-Variable -Name HOME -Value $script:origHome -Force + if ($null -eq $script:origConfigDir) { + Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore + } else { + $env:AZURE_CONFIG_DIR = $script:origConfigDir + } + $global:__AdoAccessToken = $script:origAdoToken + } + + Context 'Switch-AzPersonal' { + It 'points AZURE_CONFIG_DIR at the personal config dir' { + Switch-AzPersonal + $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-personal') + } + + It 'persists "personal" to the state file' { + Switch-AzPersonal + $state = (Get-Content -LiteralPath (Join-Path $HOME '.azure-active-profile')).Trim() + $state | Should -Be 'personal' + } + + It 'clears the cached ADO access token so prr re-auths against the new account' { + $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } + Switch-AzPersonal + $global:__AdoAccessToken | Should -BeNullOrEmpty + } + } + + Context 'Switch-AzWork' { + It 'unsets AZURE_CONFIG_DIR, persists "work", and clears the cached token' { + # Pre-set both so the unset and the clear are observable transitions. + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + $global:__AdoAccessToken = @{ Token = 'stale'; Expires = [DateTimeOffset]::MaxValue } + + Switch-AzWork + + $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty + (Get-Content -LiteralPath (Join-Path $HOME '.azure-active-profile')).Trim() | Should -Be 'work' + $global:__AdoAccessToken | Should -BeNullOrEmpty + } + } + + Context 'Restore-AzActiveProfile' { + It 'sets AZURE_CONFIG_DIR to the personal dir when the state says "personal"' { + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'personal' + Restore-AzActiveProfile + $env:AZURE_CONFIG_DIR | Should -Be (Join-Path $HOME '.azure-personal') + } + + It 'actively unsets a pre-set AZURE_CONFIG_DIR when the state says "work"' { + # An inherited AZURE_CONFIG_DIR must not survive a "work" state — restore must + # unset it, not merely skip. + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + Set-Content -LiteralPath (Join-Path $HOME '.azure-active-profile') -Value 'work' + Restore-AzActiveProfile + $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty + } + + It 'unsets a pre-set AZURE_CONFIG_DIR when the state file is missing' { + # Fresh $HOME has no state file; an inherited AZURE_CONFIG_DIR must not survive. + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + Restore-AzActiveProfile + $env:AZURE_CONFIG_DIR | Should -BeNullOrEmpty + } + } +} diff --git a/tests/Set-Prompt.Tests.ps1 b/tests/Set-Prompt.Tests.ps1 index b391cb4..0a9d9bd 100644 --- a/tests/Set-Prompt.Tests.ps1 +++ b/tests/Set-Prompt.Tests.ps1 @@ -264,6 +264,24 @@ Describe 'prompt skips VCS helpers on non-FileSystem providers' { } } +Describe 'Get-AzCliAccountSegment — az CLI account tag' { + BeforeEach { $script:origAzDir = $env:AZURE_CONFIG_DIR } + AfterEach { + if ($null -eq $script:origAzDir) { Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore } + else { $env:AZURE_CONFIG_DIR = $script:origAzDir } + } + + It 'renders az:personal when AZURE_CONFIG_DIR is set (personal account active)' { + $env:AZURE_CONFIG_DIR = Join-Path $HOME '.azure-personal' + Get-AzCliAccountSegment | Should -Match 'az:.*personal' + } + + It 'renders az:work when AZURE_CONFIG_DIR is unset (default work account)' { + Remove-Item Env:AZURE_CONFIG_DIR -ErrorAction Ignore + Get-AzCliAccountSegment | Should -Match 'az:.*work' + } +} + AfterAll { if ($global:PromptCache.AzInvocation) { try { $global:PromptCache.AzInvocation.PowerShell.Dispose() } catch {}