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.
- name
powershell-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.
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
| Target | Implication |
|---|---|
| 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: pwsh | 7.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-Hostfor data.Write-Hostis for UI only; useWrite-Verbose,Write-Information,Write-Warning,Write-Errorfor streams. - Every pipeline output becomes the function’s return value. Suppress unwanted output
with
$null = ...(faster than| Out-Null);.Add()onList[T]returns nothing butArrayList.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/curlaliases do not exist on Linux and mean different things elsewhere. Set-StrictMode -Version Latestin 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 inendand sees only the last pipeline item. - Accept arrays (
[string[]]) and loop, so-UserName a,band pipeline input both work. - Return
[pscustomobject]with aPSTypeNamefor custom formatting; never return formatted strings orFormat-Tableoutput from a function. - Use
[Parameter(ParameterSetName = ...)]for mutually exclusive modes instead of checking combinations by hand.
Error handling
| Kind | Behavior | Handle with |
|---|---|---|
Terminating (exceptions, throw, -ErrorAction Stop) | Stops the pipeline | try { } catch { } finally { } |
Non-terminating (Write-Error, most cmdlet failures) | Writes to error stream, continues | -ErrorAction Stop to make catchable, or inspect $Error |
| Native command failure | Sets $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/catchdoes not catch non-terminating errors. Set$ErrorActionPreference = 'Stop'at script scope, or pass-ErrorAction Stopper call.- Inside
catch,$_is theErrorRecord;$_.Exceptionis 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, anddockerfailures go unnoticed; after each native call without the preference variable,if ($LASTEXITCODE -ne 0) { throw "git failed: $LASTEXITCODE" }.
Pipeline and performance
| Pattern | Prefer |
|---|---|
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 calls | ForEach-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 |
-Parallelruns 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-Jsondefaults to depth 2 and silently truncates deeper objects; always pass-Depth.ConvertFrom-Json -AsHashtablewhen 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,-matchare case-insensitive; use-ceq,-clike,-cmatchwhen case matters.- A collection on the left of
-eqfilters instead of comparing: put$nullon 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 fromSet-Content. Pass-Encoding utf8NoBOM(7.x) orutf8explicitly when the file is consumed by other tools. - Environment variables:
$env:HOMEon Unix,$env:USERPROFILEon Windows; prefer[Environment]::GetFolderPath('UserProfile'). - File system is case-sensitive on Linux; match casing in
Import-Moduleand 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 inFunctionsToExport(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 inDescribeblocks, or variables are empty at run time. Mockthe commands your function calls, not the function under test;Should -Invoke Get-SomeUser -Times 1 -Exactlychecks calls.Invoke-Pester -Output Detailed -CIin pipelines (writes NUnit XML, sets exit code).
Gotchas
- A function that returns a single-element array unrolls it to a scalar; use
return ,$arrayorWrite-Output -NoEnumeratewhen callers need an array, and wrap calls in@(...)when you need to count results. $nullcomparisons:if ($result)is false for0, empty string, and empty arrays.Get-ChildItem -Pathtreats[and]as wildcards; use-LiteralPathfor real paths.Start-Processwithout-Wait -PassThrudoes not give you the exit code.- Execution policy is not a security boundary; do not tell users to set
Unrestrictedglobally. Use-Scope Processor sign scripts per org policy. Invoke-Expressionon 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
Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSGalleryreturns nothing (or only justified suppressions with[Diagnostics.CodeAnalysis.SuppressMessageAttribute]).Invoke-Pester -CIpasses onpwshon every target OS (matrix in CI); also onpowershell.exeif 5.1 is a target.- Run destructive scripts with
-WhatIfand-Verboseand review the plan before a real run. - 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
# 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.