This skill enforces enterprise-grade PowerShell standards when writing, reviewing, or generating any PowerShell code. Apply all rules below automatically — do not wait to be asked.
Triggers
- "write a PowerShell script"
- "create a function in PowerShell"
- "review this PowerShell"
- "generate a PS1 file"
- "help me with PowerShell code"
Resource Files
Load these when needed for detailed patterns, examples, and gotchas:
| File | When to load |
|---|---|
resources/common-patterns.md |
Need a code pattern (script skeleton, error handling, collections, splatting, regex, pipeline functions, string building, hashtable lookups, safe property accessor helper, module structure, ShouldProcess+Force, etc.) |
resources/compatibility-and-clm.md |
Need PS5.1 vs PS7+ compatibility details, String.Split() changes, $IsWindows portability, or writing code that may run under AppLocker/WDAC (CLM) |
resources/traps-and-gotchas.md |
Debugging unexpected behaviour around nulls, booleans, comparison operators, pipeline output pollution, $PSBoundParameters, VerbosePreference in modules, PS class limitations, or defensive null-safe patterns (property access guards, ContainsKey null guards, hashtable key/value guards, null loop entries, Add-Member verification, array double-wrapping) |
resources/get-help-and-get-member.md |
Using an unfamiliar cmdlet or object — discover parameters, properties, and online docs before writing code |
resources/performance-patterns.md |
Writing performance-sensitive code — benchmarked patterns for collections, strings, filtering, object creation, hashtable lookups, large file processing, function call overhead |
resources/api-and-web.md |
Calling REST APIs — authentication, pagination, rate limiting/retry, JSON depth, credential management, PSReadLine history protection |
resources/security-hardening.md |
Security controls — CLM enforcement (WDAC vs AppLocker), PowerShell logging (Module/Script Block/Transcription), Protected Event Logging, JEA, code signing, script injection prevention |
Quick Reference
| Area | Rule |
|---|---|
| Structure | #Requires → help → param() → Functions → Main → Cleanup |
| Strict Mode | Set-StrictMode -Version Latest always |
| Indent | 4 spaces, ≤120 chars per line |
| Encoding | UTF-8 without BOM; always specify -Encoding utf8NoBOM explicitly |
| Naming | PascalCase functions/params, camelCase locals |
| Functions | Always [CmdletBinding()]; Verb-Noun singular nouns |
| State-changing | SupportsShouldProcess + $PSCmdlet.ShouldProcess() |
| Output | Emit [PSCustomObject]; never Write-Host for data |
| Errors | try/catch with -ErrorAction Stop; never empty catch |
| Arrays | Capture foreach output directly — never += in loops |
| WMI | Get-CimInstance not Get-WmiObject |
| Events | Get-WinEvent not Get-EventLog |
| Secrets | PSCredential/SecureString/SecretManagement vault — no plaintext |
| Native cmds | Check $LASTEXITCODE after native executables; use try/catch for cmdlets |
| Aliases | No aliases in scripts — always full cmdlet names |
| Paths | Join-Path $PSScriptRoot 'file.csv' — never assume working directory |
| CLM | Check $ExecutionContext.SessionState.LanguageMode; avoid .NET::new(), Add-Type if CLM is possible |
Critical Rules
These are non-negotiable. Apply them to every script and function.
Set-StrictMode -Version Latestat the top of every script (not inside functions).[CmdletBinding()]on every advanced function, no exceptions.-ErrorAction Stopon every cmdlet call inside atryblock, or set$ErrorActionPreference = 'Stop'for the scope. Non-terminating errors do NOT triggercatchwithout this.- Never empty
catchblocks. Always log at minimumWrite-WarningorWrite-Error. - Save
$_immediately at the start of a catch block:$err = $_ - No
+=in loops. Captureforeachoutput directly, or use[System.Collections.Generic.List[PSObject]]::new()+.Add()in FullLanguage mode only. - No
Invoke-Expressionon untrusted or constructed input — ever. - No plaintext credentials in code, parameters, or log output.
- No aliases in scripts (
%,?,gci,ft, etc.). - No
Format-*mid-pipeline — emit objects; let the caller format. SupportsShouldProcesson any function that modifies state (files, registry, AD, etc.).- No
begin/process/endat script top level — only inside pipeline-aware functions. - No positional parameters in scripts — always use named parameters (e.g.,
Get-Item -Path $pnotGet-Item $p). - Always specify
-Encodingon file I/O cmdlets — default encoding differs between PS5.1 and PS7+.
Anti-Patterns
| Anti-Pattern | Replace With |
|---|---|
Get-WmiObject |
Get-CimInstance |
Get-EventLog |
Get-WinEvent -FilterHashtable @{...} |
$array += $item in loops |
Capture foreach output, or [List[PSObject]]::new() + .Add() |
Write-Host for data output |
Write-Output / emit objects |
Format-Table mid-pipeline |
Emit objects; format at end |
Invoke-Expression $cmd |
Parameterised calls / splatting |
| Plaintext password in param | [PSCredential] + SecretManagement |
Empty catch {} |
Always log or re-throw |
$? after native executables |
$LASTEXITCODE (for cmdlets use try/catch) |
ConvertTo-Json without -Depth |
Always ConvertTo-Json -Depth 10 (default 2 silently truncates) |
$global: for cross-function state |
$script: scope — contained to the script file |
| Building HTML without encoding | Regex replace for 5 HTML special chars (CLM-safe); HttpUtility in FullLanguage only |
Read-Host for required input |
[Parameter(Mandatory)] |
Aliases (gci, %, ?) |
Full cmdlet names |
begin/process/end at script top |
Only inside pipeline-aware functions |
Positional parameters (Get-Item $p) |
Named parameters (Get-Item -Path $p) |
Out-Null in hot paths |
[void](...) or $null = ... (no pipeline overhead) |
Where-Object when source has -Filter |
Use -Filter on the source cmdlet |
ForEach-Object { $_.Prop } for single property |
Select-Object -ExpandProperty Prop |
New-Object PSObject -Property @{} |
[PSCustomObject]@{} (3x faster, cleaner) |
[array]::new() or List[T]::new() in CLM |
Capture foreach output directly |
Add-Type in potentially CLM environments |
Cmdlet-based alternatives |
continue inside ForEach-Object |
return (acts as continue in pipeline context) |
if ($array -eq $null) |
if ($null -eq $array) (left-side null check) |
if ($results) to test for empty collection |
if ($results.Count -gt 0) |
-Encoding utf8 without knowing PS version |
-Encoding utf8NoBOM (explicit, portable) |
"abc" -contains "ab" for substring |
"abc".Contains("ab") or "abc" -match "ab" |
-like "pattern\d+" (regex in glob) |
-match "pattern\d+" for regex patterns |
String "False" as a boolean |
Explicit -eq 'True' or -eq $true comparison |
$list.Add($item) on ArrayList |
[void]$list.Add($item) — .Add() returns the index to the pipeline |
New-Item/New-Object output leaked |
$null = New-Item ... or assign to variable |
Write-Output $x (usually) |
Just emit $x implicitly; use Write-Output -NoEnumerate only for arrays |
| Inconsistent output types per code path | Always emit the same type; use error stream for errors |
No [OutputType()] on functions |
Declare [OutputType([PSCustomObject])] on functions with defined output |
Invoke-RestMethod without $ProgressPreference |
Set $ProgressPreference = 'SilentlyContinue' at script top for non-interactive use |
Parameter named Verbose, Debug, WhatIf, etc. |
These are reserved by [CmdletBinding()] — rename to avoid conflicts |
Parameter named Error, Input, Host, Args |
These shadow automatic variables — use distinct names |
$string += "text" in loops |
-join operator (790× faster at scale) |
Nested Where-Object for cross-collection joins |
Hashtable lookup — O(n+m) vs O(n×m) |
FunctionsToExport = '*' in manifest |
Explicit function list — avoids ~15s import penalty |
"str".Split('ab') for multi-char splitting |
"str".Split([char[]]'ab') — portable across PS5.1 and PS7+ |
if ($IsWindows) without edition check |
$PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows |
Invoke-RestMethod -Authentication + manual Authorization header |
Use only one — -Authentication silently overrides the header |
-FollowRelLink without -MaximumFollowRelLink |
Always set -MaximumFollowRelLink to prevent infinite loops |
| Class method with implicit output | Class methods discard all output except return — assign or return explicitly |
Import-Module MyModule; [ClassType]::new() |
using module MyModule required for class types |
hidden property assumed private |
hidden properties ARE serialized by ConvertTo-Json |
$obj.Prop on object that may lack Prop |
if ($obj.PSObject.Properties['Prop']) { $obj.Prop } — safe under Set-StrictMode and avoids PropertyNotFoundException |
$hash[$key] without null-guarding the key |
if ($key) { $hash[$key] } — null key crashes Dictionary<TKey,TValue>; guard first |
$hash.ContainsKey($key) without null guard |
$key -and $hash.ContainsKey($key) — null argument throws on Dictionary<> types |
Loop body with no null guard on $item |
if ($null -eq $item) { continue } at top — API/pipeline collections can contain null entries |
| Building lookup without guarding key or value | Check if ($id -and $value) before $lookup[$id] = $value — null keys create silent corrupt entries |
| `$connector | Add-Member ...; $connector._Prop` |
return ,$array in function + @(Func) at call site |
Double-wrap bug: $items = @(Func) when function returns ,$arr gives a 1-element wrapper; use direct assignment $items = Func |
Review Checklist
Before finalising any generated PowerShell, verify:
-
Set-StrictMode -Version Latestpresent at script top -
[CmdletBinding()]on every advanced function -
-ErrorAction Stopon all cmdlet calls intryblocks (or$ErrorActionPreference = 'Stop'set) - No empty
catchblocks;$err = $_saved at catch start - No
+=inside loops for collection building - No aliases used anywhere
- No
Write-Hostused for data (only acceptable for interactive UI messaging) - No
Invoke-Expressionon dynamic/user-supplied input - No plaintext secrets in code or output
-
SupportsShouldProcesson all state-modifying functions -
finallyblock for any resource cleanup - Output is objects (
[PSCustomObject]), not pre-formatted strings -
Get-CimInstanceused instead ofGet-WmiObject -
$LASTEXITCODEchecked after every native executable call -
$PSScriptRootused for all paths relative to the script file - Parameter variables not mutated; copied to local variables first
- Lines ≤120 characters; 4-space indentation throughout
- No positional parameter usage — all parameters named explicitly
-
-Encoding utf8NoBOMspecified on all file I/O operations -
$nullis on the LEFT side of all null comparisons - Empty collection checked with
.Count -eq 0, not bareif ($collection) -
@($results)used when.Countis needed on cmdlet output -
return(notcontinue) used to skip items inForEach-Object - CLM-unsafe patterns (
.NET::new(),Add-Type) avoided if script may run under AppLocker/WDAC - PS7-only syntax (
??,? :,-Parallel) annotated or avoided if PS5.1 support required -
-Filterused on source cmdlets rather than downstreamWhere-Objectwhere possible -
-likeused for glob patterns,-matchused for regex — not mixed -
-contains/-inused for collection membership, not string substring checks -
[void]$list.Add(...)used when callingArrayList.Add()(it returns the index) - Intermediate cmdlets (
New-Item,New-Object, etc.) inside functions have their output suppressed or assigned - No parameter names clash with common parameters (
Verbose,Debug,WhatIf,Confirm,ErrorAction, etc.) - No parameter names shadow automatic variables (
Error,Input,Host,Args,This) -
[OutputType()]declared on functions that emit a defined object type -
$ProgressPreference = 'SilentlyContinue'set in non-interactive scripts that callInvoke-WebRequest/Invoke-RestMethod -
Write-Hostnot used for data — only for interactive UI messages - String building in loops uses
-join, not+=(790× slower at scale) - Cross-collection joins use hashtable lookup, not nested
Where-Object(O(n+m) vs O(n×m)) - Module manifest uses explicit
FunctionsToExportlist, not'*'(~15s penalty at import) -
String.Split()with multi-char argument uses[char[]]cast for portable behaviour -
$IsWindowsportability uses$PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows - External/API object property access is guarded:
if ($obj.PSObject.Properties['Prop'])before$obj.Prop(strict-mode-safe) - Hashtable keys are null-guarded before indexing:
if ($key) { $hash[$key] }not bare$hash[$key] -
ContainsKey()calls are null-guarded:$key -and $hash.ContainsKey($key) - Loop bodies guard against null items at the top:
if ($null -eq $item) { continue } - Lookup hashtable population guards both key and value before inserting:
if ($id -and $value) { $lookup[$id] = $value } -
Add-Membernote properties are verified before access:if ($obj.PSObject.Properties['_Name']) { $obj._Name } - Functions using
return ,$arrayconvention are called with direct assignment ($x = Func), NOT$x = @(Func)(double-wrap bug)