# PowerShell profile and test suite

> Source: <https://gist.github.com/mcb0035/759e125b24062e964d79db9f6a13c523>
> Published: 2026-07-07 09:04:55+00:00

|
#Requires -Version 5.1 |
|
# ============================================================================= |
|
# Matthew's Production-Ready PowerShell Prompt |
|
# Supports: PS 5.1 (Windows PowerShell) and PS 7+ (PowerShell Core) |
|
# Segments: Git · Python venv · Conda · Node · .NET SDK · Exit code · |
|
# Jobs · Admin · Kubernetes · Azure/AWS/GCP · Depth · Path · |
|
# Timestamp |
|
# Palette: Campbell PS7 (24-bit ANSI) / Campbell PS5 (ConsoleColor fallback) |
|
# ============================================================================= |
|
|
|
# ── Version detection ──────────────────────────────────────────────────────── |
|
$script:PS7 = $PSVersionTable.PSVersion.Major -ge 7 |
|
|
|
# ============================================================================= |
|
# SECTION 0 – ENVIRONMENT CAPABILITY PROBE (run ONCE at load) |
|
# The prompt renders only what the current host supports and skips work that |
|
# isn't relevant, so it stays correct AND fast in every environment: a full |
|
# ANSI console, a legacy/redirected host, a non-interactive script host, a |
|
# remoting/embedded host, or a Constrained-Language (WDAC/AppLocker) session. |
|
# Every probe is wrapped so a host that doesn't implement it can't break load. |
|
# ============================================================================= |
|
|
|
# Constrained Language Mode blocks .NET method/ctor/type calls that the rich |
|
# segments rely on; detect it so we serve a minimal, CLM-safe prompt instead. |
|
$script:ConstrainedLang = $false |
|
try { $script:ConstrainedLang = $ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage' } catch { } |
|
|
|
# Interactive session? Only these benefit from PSReadLine. Default to $true and |
|
# narrow ONLY on a strong non-interactive signal (a service context, or an |
|
# explicit -NonInteractive), so we never strip history search from a real |
|
# interactive host — note embedded consoles (e.g. the VS Code PowerShell |
|
# Extension) launch via -Command yet ARE interactive, so -Command is NOT a signal. |
|
$script:IsInteractive = $true |
|
try { |
|
if (-not [Environment]::UserInteractive) { $script:IsInteractive = $false } |
|
elseif (([Environment]::GetCommandLineArgs()) -match '^-NonInteractive') { $script:IsInteractive = $false } |
|
} catch { } |
|
|
|
# ANSI / virtual-terminal support → the coloured ANSI render path vs the plain |
|
# ConsoleColor (Write-Host) path. PS7 in any modern terminal supports VT; a host |
|
# that doesn't (or lacks the property) falls back to ConsoleColor output. |
|
$script:SupportsAnsi = $false |
|
try { $script:SupportsAnsi = $script:PS7 -and [bool]$Host.UI.SupportsVirtualTerminal } catch { } |
|
|
|
# Settable window title? Some remoting/embedded hosts have a null RawUI or throw. |
|
$script:SupportsTitle = $false |
|
try { $null = $Host.UI.RawUI.WindowTitle; $script:SupportsTitle = $true } catch { } |
|
|
|
# ============================================================================= |
|
# SECTION 1 – COLOR PALETTE |
|
# Campbell palette values match Windows Terminal's built-in Campbell theme. |
|
# ANSI-capable host → 24-bit ANSI escape sequences (ESC[38;2;R;G;Bm) |
|
# Otherwise → Write-Host -ForegroundColor (nearest ConsoleColor) |
|
# ============================================================================= |
|
|
|
if ($script:SupportsAnsi) { |
|
# ── Campbell 24-bit ANSI sequences ─────────────────────────────────────── |
|
$E = [char]27 # ESC character |
|
|
|
# Foreground helpers (returns an ANSI escape string) |
|
function script:Fg { param([int]$R,[int]$G,[int]$B) "$E[38;2;$R;$G;${B}m" } |
|
function script:Bg { param([int]$R,[int]$G,[int]$B) "$E[48;2;$R;$G;${B}m" } |
|
$script:Reset = "$E[0m" |
|
$script:Bold = "$E[1m" |
|
$script:Dim = "$E[2m" |
|
|
|
# Campbell named colours |
|
$script:CBlack = Fg 12 12 12 # Black |
|
$script:CRed = Fg 197 15 31 # Red |
|
$script:CGreen = Fg 19 161 14 # Green |
|
$script:CYellow = Fg 193 156 0 # Yellow |
|
$script:CBlue = Fg 0 55 218 # Blue |
|
$script:CMagenta = Fg 136 0 128 # Magenta |
|
$script:CCyan = Fg 58 150 221 # Cyan |
|
$script:CWhite = Fg 204 204 204 # White (light gray) |
|
$script:CBrBlack = Fg 118 118 118 # Bright Black (dark gray) |
|
$script:CBrRed = Fg 231 72 86 # Bright Red |
|
$script:CBrGreen = Fg 22 198 12 # Bright Green |
|
$script:CBrYellow= Fg 249 241 165 # Bright Yellow |
|
$script:CBrBlue = Fg 59 120 255 # Bright Blue |
|
$script:CBrMag = Fg 180 0 158 # Bright Magenta |
|
$script:CBrCyan = Fg 97 214 214 # Bright Cyan |
|
$script:CBrWhite = Fg 242 242 242 # Bright White |
|
|
|
# Semantic aliases |
|
$script:ColPath = $script:CBrCyan |
|
$script:ColGitBranch= $script:CBrMag |
|
$script:ColGitDirty = $script:CBrRed |
|
$script:ColGitClean = $script:CBrGreen |
|
$script:ColGitInfo = $script:CYellow |
|
$script:ColPython = $script:CBrYellow |
|
$script:ColConda = $script:CYellow |
|
$script:ColNode = $script:CGreen |
|
$script:ColDotNet = $script:CBrBlue |
|
$script:ColErrCode = $script:CBrRed |
|
$script:ColOK = $script:CBrGreen |
|
$script:ColAdmin = $script:CBrRed |
|
$script:ColJobs = $script:CCyan |
|
$script:ColK8s = $script:CBrBlue |
|
$script:ColCloud = $script:CBrCyan |
|
$script:ColDepth = $script:CBrBlack |
|
$script:ColTime = $script:CBrBlack |
|
$script:ColSep = $script:CBrBlack |
|
$script:ColPromptOK = $script:CBrGreen |
|
$script:ColPromptErr= $script:CBrRed |
|
|
|
# Render a styled string (PS7 path – ANSI) |
|
function script:Styled { |
|
param([string]$Color, [string]$Text, [switch]$Bold) |
|
if ($Bold) { return "$script:Bold$Color$Text$script:Reset" } |
|
return "$Color$Text$script:Reset" |
|
} |
|
|
|
} else { |
|
# ── Campbell PS5 ConsoleColor fallback ──────────────────────────────────── |
|
# No ANSI; we build the prompt as an array of Write-Host calls instead. |
|
# Prompt function writes each segment, then returns an empty string. |
|
|
|
$script:ColPath = 'Cyan' |
|
$script:ColGitBranch = 'Magenta' |
|
$script:ColGitDirty = 'Red' |
|
$script:ColGitClean = 'Green' |
|
$script:ColGitInfo = 'Yellow' |
|
$script:ColPython = 'Yellow' |
|
$script:ColConda = 'Yellow' |
|
$script:ColNode = 'Green' |
|
$script:ColDotNet = 'Blue' |
|
$script:ColErrCode = 'Red' |
|
$script:ColOK = 'Green' |
|
$script:ColAdmin = 'Red' |
|
$script:ColJobs = 'Cyan' |
|
$script:ColK8s = 'Blue' |
|
$script:ColCloud = 'Cyan' |
|
$script:ColDepth = 'DarkGray' |
|
$script:ColTime = 'DarkGray' |
|
$script:ColSep = 'DarkGray' |
|
$script:ColPromptOK = 'Green' |
|
$script:ColPromptErr = 'Red' |
|
|
|
# In PS5 mode the prompt pieces are accumulated for Write-Host |
|
$script:PromptPieces = [System.Collections.Generic.List[hashtable]]::new() |
|
|
|
function script:Styled { |
|
param([string]$Color, [string]$Text, [switch]$Bold) |
|
# Returns nothing; accumulates into PromptPieces for later Write-Host |
|
$null = $script:PromptPieces.Add(@{ Color = $Color; Text = $Text }) |
|
return '' |
|
} |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 2 – UTILITY HELPERS |
|
# ============================================================================= |
|
|
|
# Separator character (no Nerd Fonts dependency; swap to or if you have NF) |
|
$script:Sep = '|' |
|
|
|
# How to compare a path against $HOME for the '~' collapse. Windows paths are |
|
# case-insensitive (C:\ vs c:\, USERS vs Users), so an Ordinal compare can miss |
|
# the home prefix and leave the path un-collapsed; Linux/macOS file systems |
|
# (PS7 only) ARE case-sensitive, so stay Ordinal there. $IsWindows is only |
|
# evaluated on PS7 (the -and short-circuits on 5.1, where it doesn't exist). |
|
$script:PathComparison = [System.StringComparison]::OrdinalIgnoreCase |
|
if ($script:PS7 -and -not $IsWindows) { |
|
$script:PathComparison = [System.StringComparison]::Ordinal |
|
} |
|
|
|
# Add a segment string to the prompt line (ANSI returns inline; ConsoleColor queues) |
|
function script:Seg { |
|
param([string]$Color, [string]$Text) |
|
if ($script:SupportsAnsi) { |
|
return "$(Styled $Color "$Text")$( Styled $script:ColSep $script:Sep )" |
|
} else { |
|
$null = $script:PromptPieces.Add(@{ Color = $Color; Text = "$Text" }) |
|
$null = $script:PromptPieces.Add(@{ Color = $script:ColSep; Text = $script:Sep }) |
|
return '' |
|
} |
|
} |
|
|
|
# Safe command existence check (avoids exceptions), cached per session. |
|
# Use -ErrorAction Ignore, not SilentlyContinue: SilentlyContinue hides the |
|
# error but STILL appends it to $Error, so every prompt render would leave |
|
# "<cmd> is not recognized" noise for each missing CLI (aws, gcloud, ...). |
|
# Caching spares a Get-Command discovery for every CLI on every render; CLI |
|
# availability effectively never changes mid-session (restart to re-probe). |
|
$script:CmdCache = @{} |
|
# Per-directory caches (cwd -> @{ Ver=<string|$null>; Exp=<datetime> }) for the |
|
# project-aware Node/.NET segments, so their directory walk + the slow `node` / |
|
# `dotnet --version` spawn run at most once per 30 s per directory (cf. K8s). |
|
$script:NodeCache = @{} |
|
$script:DotNetCache = @{} |
|
function script:CommandExists { |
|
param([string]$cmd) |
|
if (-not $script:CmdCache.ContainsKey($cmd)) { |
|
$script:CmdCache[$cmd] = [bool](Get-Command $cmd -ErrorAction Ignore) |
|
} |
|
return $script:CmdCache[$cmd] |
|
} |
|
|
|
# Truncate a path to at most $MaxDepth leaf components |
|
function script:TruncatePath { |
|
param([string]$FullPath, [int]$MaxDepth = 4) |
|
$home_path = $HOME -replace '\\','/' |
|
$display = $FullPath -replace '\\','/' |
|
# Guard the empty/unset $HOME case (rare SYSTEM/service contexts): ''.StartsWith('') |
|
# is always true, which would prepend a bogus '~' to every path. |
|
if ($home_path -and $display.StartsWith($home_path, $script:PathComparison)) { |
|
$display = '~' + $display.Substring($home_path.Length) |
|
} |
|
$parts = $display.TrimStart('/').Split('/') |
|
if ($display.StartsWith('~')) { |
|
# Substring(2) throws when $display is exactly '~' (home dir, length 1), |
|
# so only strip the '~/' prefix when there is something after it. |
|
$rest = if ($display.Length -gt 2) { $display.Substring(2) } else { '' } |
|
$parts = @('~') + ($rest.Split('/') | Where-Object { $_ }) |
|
} |
|
$depth = $parts.Count |
|
if ($depth -le $MaxDepth) { return $display, $depth } |
|
# Use the -join operator, not Join-String: Join-String is a PS7 cmdlet that |
|
# does NOT exist in Windows PowerShell 5.1, so any path deeper than $MaxDepth |
|
# would throw here and the prompt would fall back to the bare "PS>" default. |
|
$tail = $parts | Select-Object -Last ($MaxDepth - 1) |
|
$truncated = '…/' + ($tail -join '/') |
|
return $truncated, $depth |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 3 – PROMPT SEGMENTS |
|
# ============================================================================= |
|
|
|
# ── 3.1 Admin / Elevated ──────────────────────────────────────────────────── |
|
function script:Get-AdminSegment { |
|
$isAdmin = $false |
|
if ($script:PS7 -and $IsLinux) { |
|
$isAdmin = (id -u) -eq '0' |
|
} else { |
|
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() |
|
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
|
} |
|
if ($isAdmin) { return Seg $script:ColAdmin '⚡ADMIN' } |
|
return '' |
|
} |
|
|
|
# ── 3.2 Exit-code indicator ───────────────────────────────────────────────── |
|
function script:Get-ExitCodeSegment { |
|
param([int]$LastExit) |
|
if ($LastExit -ne 0) { |
|
return Seg $script:ColErrCode "✘ $LastExit" |
|
} |
|
return '' |
|
} |
|
|
|
# ── 3.3 Background jobs ───────────────────────────────────────────────────── |
|
function script:Get-JobsSegment { |
|
$jobs = @(Get-Job -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Completed' -and $_.State -ne 'Failed' }) |
|
if ($jobs.Count -gt 0) { |
|
return Seg $script:ColJobs "⚙ $($jobs.Count)" |
|
} |
|
return '' |
|
} |
|
|
|
# ── 3.4 Directory path + depth ────────────────────────────────────────────── |
|
function script:Get-PathSegment { |
|
$raw = $PWD.ProviderPath |
|
$display, $depth = TruncatePath $raw 4 |
|
|
|
# Depth indicator: show only when >3 directories deep |
|
$depthStr = '' |
|
if ($depth -gt 3) { |
|
if ($script:SupportsAnsi) { $depthStr = "$(Styled $script:ColDepth "[$depth]")" } |
|
else { |
|
$null = $script:PromptPieces.Add(@{ Color = $script:ColDepth; Text = "[$depth]" }) |
|
} |
|
} |
|
|
|
$pathStr = Seg $script:ColPath $display |
|
if ($script:SupportsAnsi) { return "$depthStr$pathStr" } |
|
return '' |
|
} |
|
|
|
# ── 3.5 Git segment ───────────────────────────────────────────────────────── |
|
# Git stash (≡N) / worktree (⎇N) indicators each cost an extra `git` spawn, so |
|
# they're OFF by default (keeps the segment to a single `git status`). Toggle for |
|
# the session with Enable-GitExtras / Disable-GitExtras, or flip this to $true to |
|
# default them on. |
|
$script:GitShowExtras = $false |
|
|
|
function script:Get-GitSegment { |
|
if (-not (CommandExists 'git')) { return '' } |
|
|
|
# ONE spawn for branch, upstream ahead/behind AND per-file status. Porcelain |
|
# v2 --branch replaces the old rev-parse + symbolic-ref + status + rev-list |
|
# (4 git processes collapsed to 1). `git status` exits non-zero outside a |
|
# repo, which doubles as the in-repo check. |
|
$statusOut = git status --porcelain=v2 --branch 2>$null |
|
if ($LASTEXITCODE -ne 0) { return '' } |
|
|
|
$branch = ''; $oid = ''; $ahead = 0; $behind = 0 |
|
$xy = [System.Collections.Generic.List[string]]::new() |
|
foreach ($line in $statusOut) { |
|
if (-not $line) { continue } |
|
$c = $line[0] |
|
if ($c -eq '#') { |
|
if ($line.StartsWith('# branch.head ')) { $branch = $line.Substring(14) } |
|
elseif ($line.StartsWith('# branch.oid ')) { $oid = $line.Substring(13) } |
|
elseif ($line.StartsWith('# branch.ab ') -and $line -match '\+(\d+)\s+-(\d+)') { |
|
$ahead = [int]$Matches[1]; $behind = [int]$Matches[2] |
|
} |
|
} |
|
# Changed/renamed entries carry a 2-char <xy>; porcelain v2 writes '.' where |
|
# v1 used a space, so map it back and reuse the original v1 status regexes. |
|
elseif (($c -eq '1' -or $c -eq '2') -and $line.Length -ge 4) { $xy.Add(($line.Substring(2,2) -replace '\.',' ')) } |
|
elseif ($c -eq 'u' -and $line.Length -ge 4) { $xy.Add($line.Substring(2,2)) } # unmerged |
|
elseif ($c -eq '?') { $xy.Add('??') } # untracked |
|
} |
|
|
|
# Detached HEAD: branch.head is "(detached)"; show the short OID like before. |
|
if ($branch -eq '(detached)') { |
|
$branch = if ($oid.Length -ge 7) { "➦ $($oid.Substring(0,7))" } else { '➦ ?' } |
|
} |
|
|
|
# Same semantics as the old v1 parse (the @(...) keeps .Count defined for a |
|
# single scalar match / an empty AutomationNull result). |
|
$dirty = @($xy | Where-Object { $_ -match '^[ MADRCU?!][MADRCU?! ]' }).Count -gt 0 |
|
$staged = @($xy | Where-Object { $_ -match '^[MADRCU]' }).Count -gt 0 |
|
|
|
# Stash + worktree each need their OWN `git` process, so they're gated behind |
|
# the default-off $script:GitShowExtras toggle (Enable-GitExtras). With it off |
|
# the segment makes a single `git status` spawn. |
|
$stashCount = 0 |
|
$worktreeStr = '' |
|
if ($script:GitShowExtras) { |
|
$stashCount = (git stash list 2>$null | Measure-Object).Count |
|
$worktreeCount = (git worktree list --porcelain 2>$null | Where-Object { $_ -match '^worktree ' } | Measure-Object).Count |
|
if ($worktreeCount -gt 1) { $worktreeStr = " ⎇$worktreeCount" } |
|
} |
|
|
|
$branchColor = if ($dirty -or $staged) { $script:ColGitDirty } else { $script:ColGitClean } |
|
$gitStr = "$branch$worktreeStr" |
|
|
|
$suffix = '' |
|
if ($staged) { $suffix += ' ●' } # staged changes |
|
if ($dirty) { $suffix += ' ✚' } # unstaged changes |
|
if ($ahead -gt 0) { $suffix += " ↑$ahead" } # ahead of remote |
|
if ($behind -gt 0) { $suffix += " ↓$behind" } # behind remote |
|
if ($stashCount -gt 0) { $suffix += " ≡$stashCount" } # stashes |
|
|
|
return Seg $branchColor "$gitStr$suffix" |
|
} |
|
|
|
# ── 3.6 Python virtual environment ────────────────────────────────────────── |
|
function script:Get-PythonVenvSegment { |
|
$venv = $env:VIRTUAL_ENV |
|
if (-not $venv) { return '' } |
|
$venvName = Split-Path $venv -Leaf |
|
return Seg $script:ColPython "🐍 $venvName" |
|
} |
|
|
|
# ── 3.7 Conda environment ─────────────────────────────────────────────────── |
|
function script:Get-CondaSegment { |
|
$condaEnv = $env:CONDA_DEFAULT_ENV |
|
if (-not $condaEnv -or $condaEnv -eq 'base') { return '' } |
|
return Seg $script:ColConda "🅒 $condaEnv" |
|
} |
|
|
|
# ── 3.8 Node version (project-aware: .nvmrc / .node-version / package.json) ─ |
|
function script:Get-NodeSegment { |
|
if (-not (CommandExists 'node')) { return '' } |
|
# Cache the resolved version per directory for 30 s (raw value, not the Seg |
|
# result, so the ConsoleColor path still queues its piece every render). |
|
$cwd = $PWD.ProviderPath |
|
$now = [datetime]::UtcNow |
|
$entry = $script:NodeCache[$cwd] |
|
if (-not $entry -or $now -ge $entry.Exp) { |
|
$entry = @{ Ver = (Find-NodeVersion $cwd); Exp = $now.AddSeconds(30) } |
|
$script:NodeCache[$cwd] = $entry |
|
} |
|
if ($entry.Ver) { return Seg $script:ColNode "⬡ $($entry.Ver)" } |
|
return '' |
|
} |
|
|
|
# Walk up from $startDir; on the first Node project marker return `node --version`. |
|
function script:Find-NodeVersion { |
|
param([string]$startDir) |
|
$markers = @('package.json','.nvmrc','.node-version') |
|
$check = $startDir |
|
while ($check -and $check -ne (Split-Path $check -Parent)) { |
|
foreach ($m in $markers) { |
|
if (Test-Path (Join-Path $check $m)) { |
|
$v = node --version 2>$null |
|
return $(if ($v) { "$v" } else { $null }) |
|
} |
|
} |
|
$check = Split-Path $check -Parent |
|
} |
|
return $null |
|
} |
|
|
|
# ── 3.9 .NET SDK version (project-aware: *.csproj / *.sln / global.json) ─── |
|
function script:Get-DotNetSegment { |
|
if (-not (CommandExists 'dotnet')) { return '' } |
|
# Cache the resolved SDK version per directory for 30 s (raw value, not the |
|
# Seg result). This kills both the per-render marker walk AND the slow |
|
# `dotnet --version` spawn in the steady state. |
|
$cwd = $PWD.ProviderPath |
|
$now = [datetime]::UtcNow |
|
$entry = $script:DotNetCache[$cwd] |
|
if (-not $entry -or $now -ge $entry.Exp) { |
|
$entry = @{ Ver = (Find-DotNetVersion $cwd); Exp = $now.AddSeconds(30) } |
|
$script:DotNetCache[$cwd] = $entry |
|
} |
|
if ($entry.Ver) { return Seg $script:ColDotNet ".NET $($entry.Ver)" } |
|
return '' |
|
} |
|
|
|
# Walk up from $startDir; on the first .NET marker (global.json or a project/sln |
|
# file) return `dotnet --version`. One Test-Path + one -Include Get-ChildItem per |
|
# level (vs the old five -Filter calls); the result is cached by the caller. |
|
function script:Find-DotNetVersion { |
|
param([string]$startDir) |
|
$check = $startDir |
|
while ($check -and $check -ne (Split-Path $check -Parent)) { |
|
if ((Test-Path (Join-Path $check 'global.json')) -or |
|
(Get-ChildItem -Path (Join-Path $check '*') -Include '*.csproj','*.fsproj','*.vbproj','*.sln' -File -ErrorAction Ignore | Select-Object -First 1)) { |
|
$v = dotnet --version 2>$null |
|
return $(if ($v) { "$v" } else { $null }) |
|
} |
|
$check = Split-Path $check -Parent |
|
} |
|
return $null |
|
} |
|
|
|
# ── 3.10 Kubernetes context ────────────────────────────────────────────────── |
|
function script:Get-K8sSegment { |
|
if (-not (CommandExists 'kubectl')) { return '' } |
|
|
|
# Avoid spawning kubectl every keystroke – refresh the raw context/namespace |
|
# at most every 30 s. NB: cache the RAW values, not the Seg result. In PS5, |
|
# Seg has a side effect (it queues a Write-Host piece) and returns ''; caching |
|
# that '' and short-circuiting would drop the segment from the PS5 prompt on |
|
# every cached render. Calling Seg each render keeps PS5 correct while still |
|
# throttling the kubectl spawns. |
|
$now = [datetime]::UtcNow |
|
if (-not $script:K8sCacheExpiry -or $now -ge $script:K8sCacheExpiry) { |
|
$script:K8sCtx = kubectl config current-context 2>$null |
|
$script:K8sNs = kubectl config view --minify --output 'jsonpath={..namespace}' 2>$null |
|
$script:K8sCacheExpiry = $now.AddSeconds(30) |
|
} |
|
|
|
if ($script:K8sCtx) { |
|
$nsStr = if ($script:K8sNs -and $script:K8sNs -ne 'default') { ":$($script:K8sNs)" } else { '' } |
|
return Seg $script:ColK8s "☸ $($script:K8sCtx)$nsStr" |
|
} |
|
return '' |
|
} |
|
|
|
# ── 3.11 Cloud CLI context ─────────────────────────────────────────────────── |
|
function script:Get-CloudSegment { |
|
$parts = [System.Collections.Generic.List[string]]::new() |
|
|
|
# Azure CLI (az account show is slow; read cached JSON instead) |
|
if (CommandExists 'az') { |
|
# [IO.Path]::Combine, not 3-arg Join-Path: the 3+ argument form of |
|
# Join-Path is PS7-only and throws on Windows PowerShell 5.1. |
|
$azProfile = [IO.Path]::Combine($HOME, '.azure', 'azureProfile.json') |
|
if (Test-Path $azProfile) { |
|
try { |
|
$azData = Get-Content $azProfile -Raw | ConvertFrom-Json |
|
$sub = $azData.subscriptions | Where-Object { $_.isDefault } | Select-Object -First 1 |
|
if ($sub) { $parts.Add("☁ az:$($sub.name)") } |
|
} catch { } |
|
} |
|
} |
|
|
|
# AWS CLI (reads env var; falls back to ~/.aws/config active profile) |
|
if (CommandExists 'aws') { |
|
$awsProfile = $env:AWS_PROFILE |
|
if (-not $awsProfile) { $awsProfile = $env:AWS_DEFAULT_PROFILE } |
|
if (-not $awsProfile) { $awsProfile = 'default' } |
|
if ($awsProfile -ne 'default') { $parts.Add("aws:$awsProfile") } |
|
} |
|
|
|
# GCP / gcloud (reads active config; caches the slow `gcloud` spawn) |
|
if (CommandExists 'gcloud') { |
|
# [IO.Path]::Combine, not 4-arg Join-Path (PS7-only; throws on PS 5.1). |
|
$gcpConfig = [IO.Path]::Combine($HOME, '.config', 'gcloud', 'active_config') |
|
if (Test-Path $gcpConfig) { |
|
$cfg = (Get-Content $gcpConfig -ErrorAction SilentlyContinue).Trim() |
|
if ($cfg) { |
|
# `gcloud config get-value` shells out to Python (~0.5–1 s), so it ran |
|
# on EVERY prompt render. Cache the resolved project for 30 s, and |
|
# re-query immediately when the active config name changes (e.g. |
|
# `gcloud config configurations activate <name>`). |
|
$now = [datetime]::UtcNow |
|
if ($script:GcpProjConfig -ne $cfg -or -not $script:GcpProjExpiry -or $now -ge $script:GcpProjExpiry) { |
|
$script:GcpProj = gcloud config get-value project 2>$null |
|
$script:GcpProjConfig = $cfg |
|
$script:GcpProjExpiry = $now.AddSeconds(30) |
|
} |
|
if ($script:GcpProj) { $parts.Add("gcp:$($script:GcpProj)") } else { $parts.Add("gcp:$cfg") } |
|
} |
|
} |
|
} |
|
|
|
if ($parts.Count -eq 0) { return '' } |
|
return Seg $script:ColCloud ($parts -join ' ') |
|
} |
|
|
|
# ── 3.12 Timestamp ─────────────────────────────────────────────────────────── |
|
function script:Get-TimeSegment { |
|
return Seg $script:ColTime (Get-Date -Format 'HH:mm:ss') |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 4 – PROMPT FUNCTION |
|
# ============================================================================= |
|
|
|
# ── Rich prompt: full language, all segments. The dispatcher passes in the |
|
# captured $LASTEXITCODE and restores it afterwards. |
|
function script:Get-RichPrompt { |
|
param($RealLastExit) |
|
# $RealLastExit is the live $LASTEXITCODE captured by the dispatcher (which |
|
# restores it afterwards). Normalise $null → 0 so a fresh session |
|
# ($LASTEXITCODE = $null) isn't treated as a failure by the glyph / exit segment. |
|
$lastExit = if ($null -ne $RealLastExit) { $RealLastExit } else { 0 } |
|
|
|
if (-not $script:SupportsAnsi) { |
|
# ConsoleColor path: reset the Write-Host piece list each invocation. |
|
$script:PromptPieces = [System.Collections.Generic.List[hashtable]]::new() |
|
} |
|
|
|
# ── Assemble all segments ──────────────────────────────────────────────── |
|
$line1 = '' |
|
|
|
# Left side of line 1: Admin · Exit · Jobs · Path · Git · Envs · Tools · Cloud · K8s |
|
$line1 += Get-AdminSegment |
|
$line1 += Get-ExitCodeSegment -LastExit $lastExit |
|
$line1 += Get-JobsSegment |
|
$line1 += Get-PathSegment |
|
$line1 += Get-GitSegment |
|
$line1 += Get-PythonVenvSegment |
|
$line1 += Get-CondaSegment |
|
$line1 += Get-NodeSegment |
|
$line1 += Get-DotNetSegment |
|
$line1 += Get-CloudSegment |
|
$line1 += Get-K8sSegment |
|
$line1 += Get-TimeSegment |
|
|
|
# ── Prompt glyph on line 2 ─────────────────────────────────────────────── |
|
$glyphColor = if ($lastExit -eq 0) { $script:ColPromptOK } else { $script:ColPromptErr } |
|
$nestedDepth = $NestedPromptLevel |
|
$glyph = ('❯' * [Math]::Max(1, $nestedDepth + 1)) |
|
|
|
# Restore $LASTEXITCODE. The segment helpers above run native commands |
|
# (git outside a repo exits 128, kubectl, dotnet, …) that overwrite it; |
|
# without this, those internal codes leak into the NEXT render as a phantom |
|
# "✘ 128" / "✘ 1" even when you only pressed Enter. |
|
$global:LASTEXITCODE = $RealLastExit |
|
|
|
if ($script:SupportsAnsi) { |
|
# ── PS7: write everything as a single ANSI string ──────────────────── |
|
return "`n$line1` n$(Styled $glyphColor $glyph) " |
|
} else { |
|
# ── PS5: flush queued Write-Host calls, then return glyph ───────────── |
|
Write-Host "`n" -NoNewline |
|
foreach ($piece in $script:PromptPieces) { |
|
Write-Host $piece.Text -ForegroundColor $piece.Color -NoNewline |
|
} |
|
Write-Host "`n" -NoNewline |
|
Write-Host $glyph -ForegroundColor $glyphColor -NoNewline |
|
return ' ' |
|
} |
|
} |
|
|
|
# ── Minimal prompt (Constrained Language + last-resort fallback) ───────────── |
|
# Constrained-Language-safe: cmdlets + operators only — no .NET method/ctor/type |
|
# calls, no ANSI, no native commands, no font-dependent glyphs. This is what a |
|
# locked-down or exotic host gets, and what the catch-all below falls back to. |
|
function script:Get-MinimalPrompt { |
|
param($RealLastExit) |
|
$loc = (Get-Location).Path |
|
$code = if ($RealLastExit -and $RealLastExit -ne 0) { " [$RealLastExit]" } else { '' } |
|
$depth = '>' * (1 + $NestedPromptLevel) |
|
return "PS $loc$code$depth " |
|
} |
|
|
|
function global:prompt { |
|
# Canonical state capture FIRST — before Set-PromptTitle or any segment runs a |
|
# native command (git, kubectl, dotnet, …). $realLastExit preserves the user's |
|
# exit code; $errMark lets the finally scrub the prompt's OWN native-command |
|
# noise from $Error (Windows PowerShell 5.1 records git/kubectl stderr there |
|
# even with 2>$null — PS7 does not). |
|
$realLastExit = $global:LASTEXITCODE |
|
$errMark = $global:Error.Count |
|
try { |
|
if ($script:SupportsTitle) { Set-PromptTitle } |
|
if ($script:ConstrainedLang) { return Get-MinimalPrompt $realLastExit } |
|
return Get-RichPrompt $realLastExit |
|
} catch { |
|
# Never let an unexpected prompt error drop the shell to the bare "PS>". |
|
return Get-MinimalPrompt $realLastExit |
|
} finally { |
|
$global:LASTEXITCODE = $realLastExit |
|
# Drop only the error records the prompt itself just added (they sit at the |
|
# front of $Error); the user's earlier errors are left untouched. |
|
while ($global:Error.Count -gt $errMark) { $global:Error.RemoveAt(0) } |
|
} |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 5 – TITLE BAR |
|
# Updates the window/tab title to show current path & process name. |
|
# Called directly from the prompt (above) only when $script:SupportsTitle, and |
|
# still guarded so a mid-session failure can't take the prompt down. |
|
# ============================================================================= |
|
|
|
function script:Set-PromptTitle { |
|
$location = $PWD.ProviderPath |
|
$home_path = $HOME |
|
if ($home_path -and $location.StartsWith($home_path, $script:PathComparison)) { |
|
$location = '~' + $location.Substring($home_path.Length) |
|
} |
|
$proc = if ($script:PS7) { 'pwsh' } else { 'powershell' } |
|
# Not every host exposes a settable RawUI.WindowTitle (some remoting/embedded |
|
# hosts throw, or RawUI is $null); isolate it so a title failure never |
|
# downgrades the whole prompt. |
|
try { $host.UI.RawUI.WindowTitle = "$proc $location" } catch { } |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 6 – PSReadLine INTEGRATION (PS5 + PS7) |
|
# Multi-line editing, history search, smart paste |
|
# ============================================================================= |
|
|
|
# Interactive sessions only — scripts/CI don't render a prompt or read keys. |
|
if ($script:IsInteractive -and (Get-Module -Name PSReadLine -ListAvailable -ErrorAction SilentlyContinue)) { |
|
Import-Module PSReadLine -ErrorAction SilentlyContinue |
|
|
|
Set-PSReadLineOption -EditMode Windows |
|
Set-PSReadLineOption -HistorySearchCursorMovesToEnd |
|
Set-PSReadLineOption -MaximumHistoryCount 10000 |
|
Set-PSReadLineOption -HistoryNoDuplicates |
|
|
|
# Up/Down searches history that starts with current input |
|
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward |
|
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward |
|
|
|
# Ctrl+D exits cleanly |
|
Set-PSReadLineKeyHandler -Key 'Ctrl+d' -Function DeleteCharOrExit |
|
|
|
if ($script:PS7) { |
|
# Predictive IntelliSense from history (PS7 + PSReadLine 2.1+) |
|
try { |
|
Set-PSReadLineOption -PredictionSource History |
|
} catch { } |
|
} |
|
} |
|
|
|
# ============================================================================= |
|
# SECTION 7 – CONVENIENCE ALIASES & FUNCTIONS |
|
# ============================================================================= |
|
|
|
# Quick profile reload |
|
#function Reload-Profile { . $PROFILE } |
|
#Set-Alias -Name rp -Value Reload-Profile -Force -Scope Global |
|
|
|
# Open profile in VS Code (falls back to notepad) |
|
function Edit-Profile { |
|
if (CommandExists 'code') { code $PROFILE } |
|
elseif (CommandExists 'notepad++') { & 'notepad++' $PROFILE } |
|
else { notepad $PROFILE } |
|
} |
|
#Set-Alias -Name ep -Value Edit-Profile -Force -Scope Global |
|
|
|
# Toggle the optional git stash (≡) / worktree (⎇) indicators for this session. |
|
# They're off by default because each adds a `git` process to every prompt render. |
|
function Enable-GitExtras { $script:GitShowExtras = $true; Write-Host 'Git stash/worktree indicators: ON' -ForegroundColor Green } |
|
function Disable-GitExtras { $script:GitShowExtras = $false; Write-Host 'Git stash/worktree indicators: OFF' -ForegroundColor DarkGray } |
|
|
|
# ============================================================================= |
|
# END OF PROFILE |
|
# ============================================================================= |
