powershell-expert

Deep PowerShell expertise for automation and systems administration. Use when writing PowerShell scripts, building modules, managing Windows/Azure.

SKILL.md

GitHub

PowerShell Expert

Specialized expertise in PowerShell scripting and automation, providing deep guidance on modern patterns, error handling, module development, pipeline design, remote management, .NET interop, and testing with Pester. Covers both Windows PowerShell 5.1 and PowerShell 7+ (cross-platform).

When to Use This Skill

Use this skill for:

  • Writing production-grade PowerShell scripts and modules
  • Automating Windows and Azure infrastructure
  • Building CI/CD pipelines with PowerShell
  • Implementing advanced functions with proper parameter validation
  • Managing remote systems with PSRemoting and SSH
  • Integrating PowerShell with .NET libraries and classes
  • Writing and running Pester tests

Trigger phrases: "powershell", "pwsh", "ps1", "cmdlet", "PSRemoting", "Pester", "PowerShell module", "PowerShell automation"

What This Skill Does

Provides PowerShell expertise including:

  • Modern Patterns: Splatting, pipeline design, CmdletBinding, parameter validation, ShouldProcess
  • Error Handling: ErrorActionPreference, terminating vs non-terminating errors, error records
  • Module Development: Manifests, exports, PSScriptAnalyzer, publishing to PSGallery
  • Pipeline and Data: Custom objects, filtering, grouping, JSON/CSV conversion
  • Remote Management: PSRemoting, Invoke-Command, SSH remoting, parallel execution
  • .NET Interop: Using .NET types, Add-Type, PowerShell classes, calling C# from PS
  • Testing: Pester 5 with Describe/Context/It, mocking, TestDrive, code coverage

Instructions

Step 1: Master Modern PowerShell Patterns

Splatting for Clean Parameter Passing:

# Without splatting (hard to read)
Get-ChildItem -Path "C:\Logs" -Filter "*.log" -Recurse -Force -ErrorAction SilentlyContinue

# With splatting (clean and composable)
$params = @{
    Path        = "C:\Logs"
    Filter      = "*.log"
    Recurse     = $true
    Force       = $true
    ErrorAction = "SilentlyContinue"
}
Get-ChildItem @params

# Conditional splatting: add parameters only when needed
$params = @{ Path = $SourcePath; Filter = "*.csv" }
if ($Recurse) { $params["Recurse"] = $true }
if ($Since)   { $params["After"] = $Since }
Get-ChildItem @params

Advanced Functions with CmdletBinding and ShouldProcess:

Always use [CmdletBinding()] on functions intended for production use. It enables common parameters (-Verbose, -Debug, -ErrorAction) and unlocks ShouldProcess support for destructive operations.

function Remove-StaleLogFiles {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [ValidateRange(1, 365)]
        [int]$DaysOld = 30,

        [ValidateSet("Delete", "Archive", "Compress")]
        [string]$Action = "Delete"
    )

    begin { $cutoff = (Get-Date).AddDays(-$DaysOld); $count = 0 }

    process {
        foreach ($p in $Path) {
            Get-ChildItem -Path $p -File |
                Where-Object { $_.LastWriteTime -lt $cutoff } |
                ForEach-Object {
                    if ($PSCmdlet.ShouldProcess($_.FullName, $Action)) {
                        Remove-Item -Path $_.FullName -Force
                        $count++
                    }
                }
        }
    }

    end { Write-Verbose "Processed $count files" }
}

# Usage: -WhatIf previews without deleting; -Confirm prompts per file
Remove-StaleLogFiles -Path "C:\Logs" -DaysOld 90 -WhatIf

Parameter Validation Attributes:

function Deploy-Application {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern("^[a-zA-Z][\w\-]{2,49}$")]
        [string]$AppName,

        [ValidateScript({
            if (-not (Test-Path $_ -PathType Container)) { throw "Directory '$_' not found" }
            $true
        })]
        [string]$SourcePath,

        [ValidateSet("Development", "Staging", "Production")]
        [string]$Environment = "Development",

        [ValidateRange(1, 65535)]
        [int]$Port = 8080
    )
    Write-Verbose "Deploying $AppName to $Environment on port $Port"
}

Cross-Platform Considerations (5.1 vs 7+):

# Detect version and platform
if ($PSVersionTable.PSEdition -eq "Core") {
    Write-Output "PowerShell 7+ on $($PSVersionTable.OS)"
} else {
    Write-Output "Windows PowerShell 5.1"
}

# Cross-platform path handling (avoid hardcoded backslashes)
$configPath = Join-Path -Path $HOME -ChildPath ".config" -AdditionalChildPath "myapp", "settings.json"

# PS 7+ only: ternary, null-coalescing, pipeline chain operators
$status = $IsWindows ? "Windows" : "Non-Windows"
$value = $env:MY_VAR ?? "default-value"
Get-Process notepad && Write-Output "Running"

# For 5.1 compatibility, use traditional constructs
$value = if ($env:MY_VAR) { $env:MY_VAR } else { "default-value" }

Step 2: Handle Errors and Debug Effectively

Terminating vs Non-Terminating Errors:

PowerShell has two error categories. Non-terminating errors write to the error stream but continue. Terminating errors halt execution. Understanding this distinction is essential for reliable automation.

# Non-terminating: cmdlet writes error, next line still runs
Get-Item -Path "C:\nonexistent"
Write-Output "This still executes"

# Convert non-terminating to terminating with -ErrorAction Stop
try {
    Get-Item -Path "C:\nonexistent" -ErrorAction Stop
} catch {
    Write-Warning "Caught: $($_.Exception.Message)"
}

# Script-wide preference (use cautiously)
$ErrorActionPreference = "Stop"

Structured Error Handling:

function Import-DataFile {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$FilePath)

    $stream = $null
    try {
        if (-not (Test-Path -Path $FilePath)) {
            throw [System.IO.FileNotFoundException]::new("File not found: $FilePath")
        }
        $stream = [System.IO.StreamReader]::new($FilePath)
        $data = $stream.ReadToEnd() | ConvertFrom-Json
        return $data
    }
    catch [System.IO.FileNotFoundException] {
        Write-Error "File does not exist: $($_.Exception.Message)"
    }
    catch {
        Write-Error "Unexpected error: $($_.Exception.GetType().Name) - $($_.Exception.Message)"
        Write-Verbose "Stack trace: $($_.ScriptStackTrace)"
    }
    finally {
        if ($stream) { $stream.Dispose() }
    }
}

Error Records, Write-Error vs throw, and StrictMode:

# Inspect the last error in detail
$record = $Error[0]
$record.Exception.Message       # Human-readable message
$record.FullyQualifiedErrorId   # Unique error identifier
$record.ScriptStackTrace        # PowerShell call stack

# Write-Error: non-terminating (respects -ErrorAction)
Write-Error -Message "Soft failure" -ErrorId "SoftFail" -Category InvalidOperation

# throw: terminating (always stops unless caught)
throw [System.InvalidOperationException]::new("Hard failure")

# Set-StrictMode catches undeclared variables and missing properties
Set-StrictMode -Version Latest

# ErrorView (PS 7+)
$ErrorView = "ConciseView"   # Short single-line errors (default in PS 7)

Step 3: Build PowerShell Modules

Module Structure:

MyModule/
    MyModule.psd1          # Module manifest
    MyModule.psm1          # Root module (dot-sources functions)
    Public/                # Exported functions
        Get-Widget.ps1
        Set-Widget.ps1
    Private/               # Internal helpers (not exported)
        Invoke-WidgetApi.ps1
    Tests/
        Get-Widget.Tests.ps1
# MyModule.psm1 - Root module file
$Public  = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1"  -ErrorAction SilentlyContinue)
$Private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue)

foreach ($import in @($Public + $Private)) {
    try { . $import.FullName }
    catch { Write-Error "Failed to import $($import.FullName): $_" }
}

Export-ModuleMember -Function $Public.BaseName

Module Manifest and Publishing:

# Generate a manifest
$manifestParams = @{
    Path              = ".\MyModule\MyModule.psd1"
    RootModule        = "MyModule.psm1"
    ModuleVersion     = "1.0.0"
    Author            = "Your Name"
    Description       = "Manages widget resources via the Widget API"
    PowerShellVersion = "5.1"
    FunctionsToExport = @("Get-Widget", "Set-Widget", "Remove-Widget")
    CmdletsToExport   = @()
    VariablesToExport  = @()
    AliasesToExport    = @()
    Tags              = @("Widget", "API", "Automation")
}
New-ModuleManifest @manifestParams

# Run PSScriptAnalyzer
Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force
Invoke-ScriptAnalyzer -Path .\MyModule -Recurse -Severity Warning

# Publish to PSGallery
Publish-Module -Path ".\MyModule" -NuGetApiKey $env:PSGALLERY_API_KEY -Repository "PSGallery"

Step 4: Work with the Object Pipeline and Data

Custom Objects and Pipeline Operators:

Everything in PowerShell is an object. Use [PSCustomObject] for structured data and pipeline operators for filtering, sorting, grouping, and transforming.

# Create custom objects
$servers = foreach ($i in 1..5) {
    [PSCustomObject]@{
        Name   = "web-{0:D2}" -f $i
        CPU    = Get-Random -Minimum 10 -Maximum 100
        Status = ("Running", "Stopped", "Degraded") | Get-Random
    }
}

# Filter
$critical = $servers | Where-Object { $_.CPU -gt 80 -and $_.Status -eq "Running" }

# Project with calculated properties
$report = $servers | Select-Object Name, Status, @{
    Name = "CPUPercent"; Expression = { "{0:N1}%" -f $_.CPU }
}

# Group and measure
$servers | Group-Object -Property Status | ForEach-Object { "$($_.Name): $($_.Count)" }
$stats = $servers | Measure-Object -Property CPU -Average -Minimum -Maximum

Data Format Conversion:

# JSON round-trip
$data = @{ Name = "config"; Values = @(1, 2, 3) }
$json = $data | ConvertTo-Json -Depth 10
$restored = $json | ConvertFrom-Json

# CSV import/export
$servers | Export-Csv -Path "servers.csv" -NoTypeInformation -Encoding UTF8
$imported = Import-Csv -Path "servers.csv"

# Hashtable lookup from CSV
$lookup = @{}
Import-Csv -Path "mapping.csv" | ForEach-Object { $lookup[$_.Code] = $_.Description }

# Format for display only (never use Format-* for data processing)
$servers | Format-Table -AutoSize -Property Name, Status, CPU

Step 5: Manage Remote Systems and Parallel Execution

PSRemoting with Invoke-Command:

# Run on multiple machines in parallel (fan-out)
$servers = "web-01", "web-02", "web-03", "db-01"
$results = Invoke-Command -ComputerName $servers -ScriptBlock {
    [PSCustomObject]@{
        Hostname   = $env:COMPUTERNAME
        DiskFreeGB = [math]::Round((Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'").FreeSpace / 1GB, 2)
    }
} -ThrottleLimit 10

# Pass variables with $using: scope modifier
$serviceName = "W3SVC"
Invoke-Command -ComputerName "server01" -ScriptBlock {
    Restart-Service -Name $using:serviceName -Force
}

# Persistent sessions (avoids repeated authentication)
$sessions = New-PSSession -ComputerName "web-01", "web-02" -Credential (Get-Credential)
Invoke-Command -Session $sessions -ScriptBlock { Get-Process | Measure-Object }
Copy-Item -Path ".\deploy.zip" -Destination "C:\Temp\" -ToSession $sessions[0]
$sessions | Remove-PSSession

# SSH remoting (PowerShell 7+ on any platform)
$session = New-PSSession -HostName "linux-server" -UserName "admin" -KeyFilePath "~/.ssh/id_rsa"
Invoke-Command -Session $session -ScriptBlock { uname -a }

Background Jobs and Parallel Execution:

# ThreadJob (faster than Start-Job, uses threads instead of processes)
$jobs = 1..10 | ForEach-Object {
    Start-ThreadJob -ScriptBlock {
        param($id)
        [PSCustomObject]@{ WorkerId = $id; Result = "Done" }
    } -ArgumentList $_
}
$results = $jobs | Wait-Job | Receive-Job
$jobs | Remove-Job

# ForEach-Object -Parallel (PowerShell 7+ only)
$threshold = 80
$servers | ForEach-Object -Parallel {
    try {
        $ping = Test-Connection -ComputerName $_ -Count 1 -ErrorAction Stop
        [PSCustomObject]@{ Server = $_; Latency = $ping.Latency; Status = "Online" }
    } catch {
        [PSCustomObject]@{ Server = $_; Latency = -1; Status = "Offline" }
    }
} -ThrottleLimit 5

Step 6: Leverage .NET Interop and PowerShell Classes

Using .NET Types Directly:

# Static methods and constructors
$guid = [System.Guid]::NewGuid()
$base64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("Hello"))
$uri = [System.Uri]::new("https://api.example.com/v2/users")
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

# .NET generic collections (faster than arrays for large datasets)
$list = [System.Collections.Generic.List[string]]::new()
$list.Add("item1")
$list.AddRange(@("item2", "item3"))

$dict = [System.Collections.Generic.Dictionary[string, int]]::new()
$dict["key1"] = 100

# StringBuilder for efficient string building in loops
$sb = [System.Text.StringBuilder]::new(1024)
foreach ($line in $lines) { [void]$sb.AppendLine($line) }
$result = $sb.ToString()

Add-Type for Inline C#:

Add-Type -TypeDefinition @"
using System;
using System.Security.Cryptography;
using System.Text;

public static class HashHelper
{
    public static string ComputeSha256(string input)
    {
        using (var sha = SHA256.Create())
        {
            byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
            return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
        }
    }
}
"@

$hash = [HashHelper]::ComputeSha256("my-secret-data")

PowerShell Classes and Enums:

enum DeploymentStatus {
    Pending = 0; InProgress = 1; Succeeded = 2; Failed = 3; Cancelled = 4
}

class ServerInventory {
    [string]$Name
    [string]$Environment
    [DeploymentStatus]$Status

    ServerInventory([string]$name, [string]$env) {
        $this.Name = $name
        $this.Environment = $env
        $this.Status = [DeploymentStatus]::Pending
    }

    [bool] IsHealthy() { return $this.Status -eq [DeploymentStatus]::Succeeded }
    [string] GetSummary() { return "$($this.Name) [$($this.Environment)] - $($this.Status)" }

    static [ServerInventory[]] FromCsv([string]$path) {
        return Import-Csv -Path $path | ForEach-Object {
            [ServerInventory]::new($_.Name, $_.Environment)
        }
    }
}

# Inheritance
class WebServer : ServerInventory {
    [int]$Port
    WebServer([string]$name, [int]$port) : base($name, "Production") { $this.Port = $port }
    [string] GetUrl() { return "https://$($this.Name):$($this.Port)" }
}

Step 7: Test with Pester

Pester 5 Fundamentals:

Pester is the standard testing framework for PowerShell. Version 5 uses BeforeAll, BeforeEach, container-scoped discovery, and improved mocking.

BeforeAll {
    . "$PSScriptRoot\..\Public\Get-Widget.ps1"
}

Describe "Get-Widget" {
    Context "when the widget exists" {
        It "returns a widget by ID" {
            Mock Invoke-WidgetApi { [PSCustomObject]@{ Id = 1; Name = "TestWidget" } }
            $result = Get-Widget -Id 1
            $result | Should -Not -BeNullOrEmpty
            $result.Name | Should -Be "TestWidget"
        }
    }

    Context "when the widget does not exist" {
        It "returns null and writes a warning" {
            Mock Invoke-WidgetApi { return $null }
            Mock Write-Warning {}
            $result = Get-Widget -Id 999
            $result | Should -BeNullOrEmpty
            Should -Invoke Write-Warning -Times 1 -Exactly
        }
    }
}

Should Assertions:

Describe "Should assertion examples" {
    It "equality"    { 42 | Should -Be 42; "Hello" | Should -BeExactly "Hello" }
    It "patterns"    { "hello" | Should -BeLike "hel*"; "hello world" | Should -Match "hello\s\w+" }
    It "collections" { @(1,2,3) | Should -Contain 2; @(1,2,3) | Should -HaveCount 3 }
    It "types"       { 42 | Should -BeOfType [int]; Get-Date | Should -BeOfType [datetime] }
    It "comparisons" { 10 | Should -BeGreaterThan 5; $true | Should -BeTrue }
    It "exceptions"  { { throw "boom" } | Should -Throw -ExpectedMessage "boom" }
}

Mocking and TestDrive:

Describe "Service deployment" {
    BeforeAll { . "$PSScriptRoot\..\Public\Deploy-Service.ps1" }

    Context "successful deployment" {
        BeforeEach {
            Mock Test-Connection { $true }
            Mock Copy-Item {}
            Mock Invoke-Command { [PSCustomObject]@{ Status = "Running" } }
        }

        It "copies files to the target server" {
            Deploy-Service -Name "MyApp" -Server "web-01" -SourcePath "C:\Build"
            Should -Invoke Copy-Item -Times 1 -Exactly -ParameterFilter {
                $Destination -like "*web-01*"
            }
        }
    }

    Context "file operations with TestDrive" {
        It "creates and reads a config file" {
            $path = Join-Path $TestDrive "config.json"
            @{ Server = "web-01"; Port = 8080 } | ConvertTo-Json | Set-Content -Path $path
            $path | Should -Exist
            (Get-Content $path | ConvertFrom-Json).Port | Should -Be 8080
        }
    }
}

Code Coverage and CI Integration:

$config = New-PesterConfiguration
$config.Run.Path = ".\Tests"
$config.Run.PassThru = $true
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = @(".\Public\*.ps1", ".\Private\*.ps1")
$config.CodeCoverage.CoveragePercentTarget = 80
$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = "NUnitXml"
$config.TestResult.OutputPath = ".\TestResults.xml"

$result = Invoke-Pester -Configuration $config

if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) tests failed" }
if ($result.CodeCoverage.CoveragePercent -lt 80) {
    throw "Coverage $($result.CodeCoverage.CoveragePercent)% below 80% threshold"
}

Best Practices

  • Use CmdletBinding on every function intended for production; it adds -Verbose, -Debug, and -ErrorAction support for free
  • Prefer splatting over long parameter lines; it makes conditional parameter construction trivial
  • Never suppress errors silently; use -ErrorAction SilentlyContinue only when you explicitly handle the absence of a result
  • Emit objects, not formatted text; let the caller decide how to display or process output
  • Validate parameters at the boundary with [Validate*] attributes rather than manual checks inside the function body
  • Run PSScriptAnalyzer on every commit; treat warnings as errors in CI
  • Use Pester for all testable logic; target 80% code coverage
  • Avoid aliases in scripts (gci, %, ?, sls); always use full cmdlet names for clarity and portability

Common Patterns

Pattern 1: Retry with Exponential Backoff

function Invoke-WithRetry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [scriptblock]$ScriptBlock,
        [int]$MaxRetries = 3,
        [int]$BaseDelaySeconds = 2
    )

    for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
        try { return & $ScriptBlock }
        catch {
            if ($attempt -eq $MaxRetries) { throw }
            $delay = [math]::Pow($BaseDelaySeconds, $attempt)
            Write-Warning "Attempt $attempt failed. Retrying in ${delay}s..."
            Start-Sleep -Seconds $delay
        }
    }
}

$response = Invoke-WithRetry -ScriptBlock {
    Invoke-RestMethod -Uri "https://api.example.com/data" -TimeoutSec 10
}

Pattern 2: Configuration Management

function Get-AppConfig {
    [CmdletBinding()]
    param(
        [string]$ConfigPath = (Join-Path $PSScriptRoot "config.json"),
        [string]$Environment = ($env:APP_ENVIRONMENT ?? "Development")
    )

    if (-not (Test-Path $ConfigPath)) { throw "Config not found: $ConfigPath" }
    $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
    $env = $config.$Environment
    if (-not $env) { throw "No config section for: $Environment" }

    [PSCustomObject]@{
        DatabaseServer = $env.DatabaseServer ?? $config.Defaults.DatabaseServer
        LogLevel       = $env.LogLevel ?? "Information"
        MaxRetries     = $env.MaxRetries ?? 3
        Environment    = $Environment
    }
}

Quality Checklist

  • All functions use [CmdletBinding()]
  • Parameters have appropriate [Validate*] attributes
  • Destructive functions implement SupportsShouldProcess
  • Errors are handled explicitly (no silent failures)
  • Output is objects, not formatted strings
  • PSScriptAnalyzer produces zero warnings
  • Pester tests cover all public functions
  • Code coverage meets 80% threshold
  • No aliases used in script files
  • Script works on both PS 5.1 and PS 7+ (or documents the requirement)

Related Skills

  • cicd-architect - PowerShell in CI/CD pipelines
  • code-quality - PowerShell code standards and PSScriptAnalyzer
  • csharp-expert - Deep .NET integration from PowerShell
  • kubernetes-expert - PowerShell for AKS and container management

Version: 1.0.0 Last Updated: March 2026 Based on: PowerShell best practices, Pester 5 documentation, PSScriptAnalyzer rules

Iterative Refinement Strategy

This skill is optimized for an iterative approach:

  1. Execute: Perform the core steps defined above.
  2. Review: Critically analyze the output (coverage, quality, completeness).
  3. Refine: If targets aren't met, repeat the specific implementation steps with improved context.
  4. Loop: Continue until the definition of done is satisfied.