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