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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ 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`), dot-source `Profile/AzCliAccount.ps1` followed by an explicit `Restore-AzActiveProfile` (az CLI account switch — see below), 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 named-profile switch — one state-file read, 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`.
**az CLI named-profile switch (`Profile/AzCliAccount.ps1`, `azs`)**: `Switch-AzProfile` (alias `azs`) points `AZURE_CONFIG_DIR` at `~/.azure-profiles/<name>` — every profile is a self-contained config dir, symmetric, with no profile defined by absence. **One identity per profile dir is the whole point**: `az devops` builds its auth candidate list from the config dir's cached subscriptions and returns the first identity that can list *any* project, so a dir holding two logins can silently authenticate as an account you did not select, with no signal at the shell. The name is exactly the leaf of `$env:AZURE_CONFIG_DIR`, validated against `^[A-Za-z0-9][A-Za-z0-9_-]*$` (that regex is also the path-injection guard, since the name becomes a directory). `default` is reserved: it means **unset** `AZURE_CONFIG_DIR`, i.e. az's own `~/.azure`, and creates no directory.

**Rejected alternatives — do not reintroduce.** *Junction/symlink `~/.azure` at the active profile*: mechanically the best option (retargeting a reparse point is one metadata write and succeeds with open handles — verified), but on Windows `~/.azure` is **one case-insensitive directory shared with Azure PowerShell** (`AzureRmContext.json` sits beside `azureProfile.json`), so junctioning it would silently switch every `Get-Az*` too. Az PowerShell is out of scope; `~/.azure` stays untouched. *Directory swapping*: `Move-Item` on a directory is copy-then-delete, not atomic, and leaves torn state (verified: a failed swap emptied the destination while the source still held the payload); true `[IO.Directory]::Move` fails outright while any file inside is open, and `az` holds `msal_token_cache.bin` open on every command — and it makes the active account machine-global mutable state. *Discovery by a `~/.azure-*` glob*: the home dir already contains `.azure-devops`, `.azure-functions-core-tools`, and `.azurefunctions`, which a glob would offer as profiles — hence a container dir. *A manifest file listing profiles*: a second source of truth that drifts from the dirs actually holding the credentials. **The directories are the only registry** — adding a profile is "switch to it and log in", never "edit a file". Do not add one "for robustness".

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 calls `Restore-AzActiveProfile`, whose cost budget is **exactly one `~/.azure-active-profile` read — no enumeration, no JSON**; all discovery lives in the picker. A `default`/missing/empty/invalid state **actively unsets** `AZURE_CONFIG_DIR`, never merely skips, so an inherited value can't survive. It also sets **`AZURE_EXTENSION_DIR` unconditionally and first** at one shared store (`~/.azure/cliextensions`): the extension store must be decoupled from the config dir — verified: with a fresh `AZURE_CONFIG_DIR` and no override, `az extension list` is empty, so every non-default profile loses `az devops` and `az graph` outright. Bare `azs` opens the **fzf picker** (bare `fzf` in a native pipeline, *not* PSFzf's `Invoke-Fzf`, whose redirected-stdout process launcher desyncs under psmux's ConPTY); each candidate's identity is precomputed from that dir's `azureProfile.json` into a tab-delimited field so the preview is a bare `echo` — never a `pwsh` or `az` process per keystroke, and it still works logged out. Only an *omitted* `-Name` picks interactively; `azs ''` throws. 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 profile is **always shown in the prompt** — `Set-Prompt.ps1`'s `Get-AzCliAccountSegment` renders `az:<name>` (or `az:default` when unset) 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.

Expand Down
9 changes: 5 additions & 4 deletions powershell/Microsoft.PowerShell_profile.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,11 @@ 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.
# --- az CLI named-profile switch (azs) --------------------------------------
# Defines functions and the azs alias only (no environment, filesystem, or
# process side effects at dot-source); the explicit Restore call below applies
# the persisted last-used profile. Cheap enough for Phase 1 — one state-file
# read plus env-var sets.
. "$(Split-Path -Path $PROFILE)/Profile/AzCliAccount.ps1"
Restore-AzActiveProfile

Expand Down
Loading