Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>"`, 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`)
Expand Down
7 changes: 7 additions & 0 deletions powershell/Microsoft.PowerShell_profile.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 92 additions & 0 deletions powershell/Profile/AzCliAccount.ps1
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions powershell/Profile/Set-Prompt.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions powershell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<change-id> <bookmark> ↑<dist> *<files> ∅ ✎` (`∅` 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
Expand Down
102 changes: 102 additions & 0 deletions tests/AzCliAccount.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
18 changes: 18 additions & 0 deletions tests/Set-Prompt.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down