Languages No. 114

PowerShell Scripting skill for Claude Code

Object pipelines that stop on errors and honor -WhatIf. Claude writes PowerShell that passes objects instead of formatted text, uses CmdletBinding with validated parameters and pipeline input, and makes failures stop the script, including native commands such as git and az. Destructive work goes through ShouldProcess and a -WhatIf dry run first, modules export explicit function lists, and PSScriptAnalyzer and Pester gate every change.

  • namepowershell-scripting
  • size257 lines · ≈2,800 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes PowerShell 7 scripts and modules that pass objects through the pipeline, use advanced functions with CmdletBinding, typed and validated parameters, ShouldProcess for anything destructive, terminating-error handling with try/catch and ErrorActionPreference, native command exit codes, splatting, and Pester 5 tests with PSScriptAnalyzer. Covers cross-platform paths and encodings, modules and manifests, and Windows PowerShell 5.1 compatibility. Use when writing or reviewing .ps1, .psm1, or .psd1 files, automating Windows, Azure, or Microsoft 365 admin tasks, porting batch or bash scripts, or fixing errors that do not stop a script.

Try asking

  • Write a PowerShell script that cleans up old log folders with -WhatIf support
  • My try/catch never catches this Remove-Item failure — why?
  • Turn these loose .ps1 helper scripts into a module with Pester tests

Use it when

  • Writing or reviewing .ps1, .psm1, or .psd1 files
  • Automating Windows, Active Directory, Azure, or Microsoft 365 administration
  • Porting batch or Windows PowerShell 5.1 scripts to PowerShell 7
  • Adding Pester tests or PSScriptAnalyzer to a scripts repository

Skip it when

  • The script targets POSIX shells on Linux or macOS servers — use bash-scripting
  • Building a full CLI application with subcommands and packaging — use cli-tool-building

How to check the result

Invoke-ScriptAnalyzer reports nothing, Invoke-Pester -CI passes on every target edition, and a forced failure makes the script exit non-zero.

The common mistake it prevents

Assuming try/catch handles every error, when most cmdlet failures are non-terminating and native commands only set $LASTEXITCODE unless ErrorAction or the native preference is set.

~/.claude/skills/powershell-scripting/SKILL.md

--- name: powershell-scripting description: Writes PowerShell 7 scripts and modules that pass objects through the pipeline, use advanced functions with CmdletBinding, typed and… license: MIT ---

Done well means: functions emit objects (not formatted text), errors stop the script where they should, destructive actions support -WhatIf, parameters validate their own input, the script runs on PowerShell 7.4+ on Windows, macOS, and Linux unless it is deliberately Windows-only, and Invoke-ScriptAnalyzer plus Pester are clean.

First: which PowerShell

TargetImplication
PowerShell 7.6 LTS (Mar 2026, .NET 10) or 7.4 LTS (.NET 8)Default. pwsh, cross-platform, &&/`
Windows PowerShell 5.1 (powershell.exe)Still the in-box shell on Windows; needed for some legacy modules. No 7.x syntax
Azure Automation / Functions / GitHub Actions shell: pwsh7.x; check the runtime’s configured version

Declare it at the top of the script: #Requires -Version 7.4. If a script must also run on 5.1, read references/ps51-vs-7.md before writing it.

Ground rules

  • Output objects, never Write-Host for data. Write-Host is for UI only; use Write-Verbose, Write-Information, Write-Warning, Write-Error for streams.
  • Every pipeline output becomes the function’s return value. Suppress unwanted output with $null = ... (faster than | Out-Null); .Add() on List[T] returns nothing but ArrayList.Add() returns an index.
  • Approved verbs only (Get-Verb): Get-, Set-, New-, Remove-, Invoke-, Test-.
  • Full cmdlet and parameter names in scripts; aliases (%, ?, ls, gci) only at the prompt. ls/curl aliases do not exist on Linux and mean different things elsewhere.
  • Set-StrictMode -Version Latest in scripts and modules to catch undefined variables and missing properties.
  • Never store secrets in scripts. Use Microsoft.PowerShell.SecretManagement, managed identities, or environment variables injected by CI.

Script skeleton

#Requires -Version 7.4
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path $_ -PathType Container })]
    [string] $Path,

    [ValidateRange(1, 3650)]
    [int] $OlderThanDays = 30,

    [switch] $Force
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true   # non-zero exit codes throw (7.4+)

$cutoff = (Get-Date).AddDays(-$OlderThanDays)

Get-ChildItem -LiteralPath $Path -File -Recurse |
    Where-Object LastWriteTime -lt $cutoff |
    ForEach-Object {
        if ($PSCmdlet.ShouldProcess($_.FullName, 'Remove file')) {
            Remove-Item -LiteralPath $_.FullName -Force:$Force
        }
    }

Run destructive scripts with -WhatIf first and show the user the output; only run without it after explicit confirmation.

Advanced functions

function Get-StaleUser {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('SamAccountName')]
        [string[]] $UserName,

        [ValidateSet('Enabled', 'Disabled', 'Any')]
        [string] $State = 'Any'
    )
    begin   { $now = Get-Date }
    process {
        foreach ($name in $UserName) {          # handles both pipeline and array input
            $u = Get-SomeUser -Name $name
            [pscustomobject]@{
                PSTypeName = 'Contoso.StaleUser'
                UserName   = $name
                LastLogon  = $u.LastLogon
                DaysIdle   = ($now - $u.LastLogon).Days
            }
        }
    }
    end {}
}
  • Put per-item work in process {}; code outside a block runs once in end and sees only the last pipeline item.
  • Accept arrays ([string[]]) and loop, so -UserName a,b and pipeline input both work.
  • Return [pscustomobject] with a PSTypeName for custom formatting; never return formatted strings or Format-Table output from a function.
  • Use [Parameter(ParameterSetName = ...)] for mutually exclusive modes instead of checking combinations by hand.

Error handling

KindBehaviorHandle with
Terminating (exceptions, throw, -ErrorAction Stop)Stops the pipelinetry { } catch { } finally { }
Non-terminating (Write-Error, most cmdlet failures)Writes to error stream, continues-ErrorAction Stop to make catchable, or inspect $Error
Native command failureSets $LASTEXITCODE, does not throw by default$PSNativeCommandUseErrorActionPreference = $true or check $LASTEXITCODE
try {
    $resp = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 30
}
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
    $status = $_.Exception.Response.StatusCode.value__
    throw "API call failed with HTTP $status for $uri"
}
catch {
    Write-Error -ErrorRecord $_          # preserve the original record
    throw
}
  • try/catch does not catch non-terminating errors. Set $ErrorActionPreference = 'Stop' at script scope, or pass -ErrorAction Stop per call.
  • Inside catch, $_ is the ErrorRecord; $_.Exception is the .NET exception. Do not rely on $Error[0] after other commands ran.
  • In a function, use $PSCmdlet.ThrowTerminatingError($record) to report errors against the caller’s line rather than an internal line.
  • In older code, git, az, and docker failures go unnoticed; after each native call without the preference variable, if ($LASTEXITCODE -ne 0) { throw "git failed: $LASTEXITCODE" }.

Pipeline and performance

PatternPrefer
Growing an array with += in a loop$list = [System.Collections.Generic.List[object]]::new(); $list.Add($x) or assign the loop output: $results = foreach (...) { ... }
Where-Object { $_.Status -eq 'Running' } on huge sets.Where({ ... }) method or filter at the source (-Filter on AD, server-side OData)
Many independent slow callsForEach-Object -Parallel { } -ThrottleLimit 8 (7.x; use $using:var for outer variables)
String building in a loop[System.Text.StringBuilder] or -join
Reading large files line by line[System.IO.File]::ReadLines($path) or Get-Content -ReadCount 1000
  • -Parallel runs each block in a separate runspace: functions and modules defined in the parent are not visible; pass data with $using: and re-import modules inside.
  • Filter left, format right: Get-Process | Where-Object CPU -gt 100 | Sort-Object CPU | Select-Object -First 5.

Splatting, strings, and data

$params = @{
    Uri         = 'https://api.example.com/items'
    Method      = 'Post'
    Body        = ($payload | ConvertTo-Json -Depth 10)
    ContentType = 'application/json'
}
Invoke-RestMethod @params
  • ConvertTo-Json defaults to depth 2 and silently truncates deeper objects; always pass -Depth.
  • ConvertFrom-Json -AsHashtable when keys differ only in case or are not valid identifiers.
  • Single quotes for literals, double quotes only for interpolation; inside double quotes use $($obj.Property) for member access.
  • Here-strings (@' ... '@) for multi-line text; the closing marker must start the line.
  • -eq, -like, -match are case-insensitive; use -ceq, -clike, -cmatch when case matters.
  • A collection on the left of -eq filters instead of comparing: put $null on the left ($null -eq $value).

Cross-platform hygiene

  • Build paths with Join-Path (7.x accepts multiple child segments) or [System.IO.Path]::Combine; never hard-code \.
  • Use $IsWindows, $IsLinux, $IsMacOS (7.x only) for platform branches.
  • Default encoding in 7.x is UTF-8 without BOM; 5.1 writes UTF-16 LE from Out-File/> and ANSI from Set-Content. Pass -Encoding utf8NoBOM (7.x) or utf8 explicitly when the file is consumed by other tools.
  • Environment variables: $env:HOME on Unix, $env:USERPROFILE on Windows; prefer [Environment]::GetFolderPath('UserProfile').
  • File system is case-sensitive on Linux; match casing in Import-Module and paths.

Modules

MyTools/
├── MyTools.psd1            # manifest: ModuleVersion, FunctionsToExport, RequiredModules
├── MyTools.psm1            # dot-sources Public/*.ps1 and Private/*.ps1
├── Public/Get-StaleUser.ps1
├── Private/ConvertTo-Thing.ps1
└── Tests/Get-StaleUser.Tests.ps1
  • Create the manifest with New-ModuleManifest; list exported functions explicitly in FunctionsToExport (never '*') so command discovery stays fast.
  • Install dependencies with PSResourceGet (Install-PSResource -Name Pester -Scope CurrentUser), which ships with 7.4+; Install-Module (PowerShellGet v2) is the 5.1-era equivalent.
  • Pin versions in CI: Install-PSResource Pester -Version '[5.5,6.0)'.

Testing with Pester 5

BeforeAll {
    . $PSScriptRoot/../Public/Get-StaleUser.ps1
}

Describe 'Get-StaleUser' {
    BeforeEach {
        Mock Get-SomeUser { [pscustomobject]@{ LastLogon = (Get-Date).AddDays(-40) } }
    }
    It 'reports days idle' {
        (Get-StaleUser -UserName 'ann').DaysIdle | Should -BeGreaterOrEqual 40
    }
    It 'accepts pipeline input' {
        ('ann', 'bob' | Get-StaleUser).Count | Should -Be 2
    }
}
  • Pester 5 runs discovery first: put setup code in BeforeAll/BeforeEach, not loose in Describe blocks, or variables are empty at run time.
  • Mock the commands your function calls, not the function under test; Should -Invoke Get-SomeUser -Times 1 -Exactly checks calls.
  • Invoke-Pester -Output Detailed -CI in pipelines (writes NUnit XML, sets exit code).

Gotchas

  • A function that returns a single-element array unrolls it to a scalar; use return ,$array or Write-Output -NoEnumerate when callers need an array, and wrap calls in @(...) when you need to count results.
  • $null comparisons: if ($result) is false for 0, empty string, and empty arrays.
  • Get-ChildItem -Path treats [ and ] as wildcards; use -LiteralPath for real paths.
  • Start-Process without -Wait -PassThru does not give you the exit code.
  • Execution policy is not a security boundary; do not tell users to set Unrestricted globally. Use -Scope Process or sign scripts per org policy.
  • Invoke-Expression on built strings is the PowerShell eval injection; build argument arrays and call with & instead: & $exe @argList.
  • Scope: variables set inside a script block called with & do not leak; dot-sourcing (.) does. Avoid $global: except for deliberate session state.

Verification

  1. Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSGallery returns nothing (or only justified suppressions with [Diagnostics.CodeAnalysis.SuppressMessageAttribute]).
  2. Invoke-Pester -CI passes on pwsh on every target OS (matrix in CI); also on powershell.exe if 5.1 is a target.
  3. Run destructive scripts with -WhatIf and -Verbose and review the plan before a real run.
  4. Confirm errors stop: feed a bad path or failing native command and check the script exits non-zero (pwsh -File script.ps1; $LASTEXITCODE).

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/ps51-vs-7.md 91 lines · 4.2 KB
# Windows PowerShell 5.1 vs PowerShell 7.x

Read when a script must run on both `powershell.exe` (5.1, .NET Framework) and `pwsh`
(7.x, modern .NET), or when porting a 5.1 script forward.

## Detect the edition

```powershell
$PSVersionTable.PSEdition    # 'Desktop' = 5.1, 'Core' = 6+/7+
$PSVersionTable.PSVersion
```

Manifests can declare `CompatiblePSEditions = @('Desktop', 'Core')` and
`PowerShellVersion = '5.1'`.

## Syntax only in 7.x (parse errors on 5.1)

| Feature | 7.x | 5.1 equivalent |
|---|---|---|
| Ternary | `$a ? 'y' : 'n'` | `if ($a) { 'y' } else { 'n' }` |
| Null-coalescing | `$x ?? 'default'`, `$x ??= 1` | `if ($null -eq $x) { 'default' } else { $x }` |
| Null-conditional member | `${obj}?.Prop`, `${arr}?[0]` | explicit `$null` check |
| Pipeline chain | `git pull && npm ci` | `git pull; if ($LASTEXITCODE -eq 0) { npm ci }` |
| Parallel loop | `ForEach-Object -Parallel` | `Start-ThreadJob` module, runspace pools, or `Start-Job` |
| Clean block | `clean { }` in functions (7.3+) | `try/finally` in `end` |

Because these are parse errors, a script that contains them anywhere fails on 5.1 even
if the branch never runs. Keep 7-only code in a separate file loaded conditionally.

## Behavior differences that bite

| Area | 5.1 | 7.x |
|---|---|---|
| Default file encoding (`Out-File`, `>`) | UTF-16 LE with BOM | UTF-8 without BOM |
| `Set-Content` default | ANSI code page | UTF-8 without BOM |
| `-Encoding utf8` | UTF-8 **with** BOM | UTF-8 without BOM (`utf8BOM` to get one) |
| Script file without BOM containing non-ASCII | Read as ANSI (mojibake) | Read as UTF-8 |
| `Invoke-WebRequest` | Uses IE engine unless `-UseBasicParsing` | Basic parsing always; no `ParsedHtml` |
| HTTP errors | `WebException` | `HttpResponseException` |
| `ConvertFrom-Json -AsHashtable`, `-Depth` | Not available | Available |
| `ConvertTo-Json` enum output | Numbers | Numbers; `-EnumsAsStrings` available |
| Native arg passing | Legacy quoting rules | `$PSNativeCommandArgumentPassing` (`Windows` default on Windows) |
| `$PSNativeCommandUseErrorActionPreference` | Not available | Available (7.4+) |
| Workflows, `*-WmiObject`, PSSnapins | Available | Removed; use `*-CimInstance` |
| `Out-GridView`, `Show-Command` | Built in | Windows only (via compatibility) |
| `Get-Content -AsByteStream` | `-Encoding Byte` | `-AsByteStream` |
| `Join-Path` multiple children | One child only | `Join-Path a b c` |
| Platform variables | `$IsWindows` undefined (falsy) | `$IsWindows`, `$IsLinux`, `$IsMacOS` |

## Writing for both

```powershell
# Encoding that works the same on both: write via .NET
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($path, $content, $utf8NoBom)

# Platform check that works on 5.1 too
$onWindows = $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows

# Web requests
$resp = Invoke-WebRequest -Uri $uri -UseBasicParsing   # flag is accepted (ignored) in 7.x
```

- Save `.ps1` files as UTF-8 **with** BOM if they contain non-ASCII and must run on 5.1.
- Avoid 7-only parameters; test with `powershell.exe -NoProfile -File script.ps1`.
- Run PSScriptAnalyzer with the compatibility rules targeting both:
  `PSUseCompatibleSyntax` and `PSUseCompatibleCommands` with target profiles for 5.1 and 7.x.

## Windows-only modules from 7.x

Many Windows modules (some ActiveDirectory, ServerManager, older vendor modules) load in
7.x through the Windows PowerShell compatibility layer:

```powershell
Import-Module ServerManager -UseWindowsPowerShell
```

This proxies commands through an implicit 5.1 remoting session: objects come back
deserialized (properties only, no methods). If you need live objects, run that part
under `powershell.exe` instead.

## Porting checklist (5.1 to 7.x)

- [ ] Replace `Get-WmiObject` with `Get-CimInstance`
- [ ] Remove `-UseBasicParsing` dependencies on `ParsedHtml`
- [ ] Re-check every file write for encoding expectations of downstream tools
- [ ] Catch `HttpResponseException` instead of `WebException`
- [ ] Replace `-Encoding Byte` with `-AsByteStream`
- [ ] Re-test native commands with quoted arguments (argument passing changed)
- [ ] Replace workflows with `ForEach-Object -Parallel` or jobs
- [ ] Run Pester on both editions until 5.1 support is formally dropped

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc