PS C:\> Get-Standard · v0.7.0

AGENTS.md: PowerShell Engineer Standard

Version: 0.7.0 · Maintainer: Jim Tyler, Microsoft MVP, PowerShell Engineer (powershellengineer.com) Applies to: all PowerShell (.ps1, .psm1, .psd1) authored in this repository.

Not affiliated with or endorsed by Microsoft. PowerShell is a trademark of Microsoft Corporation.

Write for the engineer who inherits this code and has to run it against production at 2am. When a rule below conflicts with an explicit instruction from the user, follow the user and say which rule you set aside and why.


0. Non-negotiables

Every function you emit must satisfy all of these. If you cannot, stop and say so.

0.1 Do not invent API surface

The most common defect in generated PowerShell is not style. It is a parameter that does not exist. Get-ADUser -IncludeDisabled reads perfectly, passes review, and fails the moment it runs. Code that is wrong in this way is worse than code that is ugly, because nothing about it looks wrong.

or .NET method signature exists, do not use it.

Where-Object { -not $_.Enabled } that you know works beats a -Filter string you half remember.

rather than guessing at something plausible.

as illustrative.

Get-Help against the version they actually have.

Version-specific surface is where this fails most often. Parameters added in later module releases, renames between AzureRM and Az, Graph SDK v1 against v2, Exchange Online v2 against v3. Being right about one version is being wrong about another.

When you are unsure and the task still needs it, do both of these:

  1. Mark it in the code where the reader will see it:

# VERIFY: confirm -IncludeDisabled exists in your module version

  1. Give the command that settles it:
Get-Command Get-ADUser -Syntax
(Get-Command Get-ADUser).Parameters.Keys
Get-Help Get-ADUser -Parameter IncludeDisabled
$result | Get-Member

Stating uncertainty costs the reader five seconds. An invented parameter costs them a debugging session, and costs you their trust in everything else in the file.


1. Canonical shape

This is the reference implementation. Match its structure.

function Remove-PSEStaleAccount {
    <#
    .SYNOPSIS
        Disables Active Directory accounts inactive beyond a threshold.

    .DESCRIPTION
        Finds enabled user accounts whose LastLogonDate precedes the cutoff and
        disables them. Requires delegated write access to the target OU. Emits one
        result object per account evaluated, including skipped accounts, so the
        caller can audit the full run rather than only the changes.

    .PARAMETER Identity
        The account to evaluate. Accepts pipeline input from Get-ADUser so you can
        filter with the AD provider's server-side query before piping in.

    .PARAMETER InactiveDays
        Days of inactivity before an account is considered stale. Set this to match
        your organization's account lifecycle policy; the default is deliberately
        conservative.

    .INPUTS
        Microsoft.ActiveDirectory.Management.ADUser

    .OUTPUTS
        PSEngineer.AccountResult

    .EXAMPLE
        Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $ou -Properties LastLogonDate |
            Remove-PSEStaleAccount -InactiveDays 90 -WhatIf

        Previews the full run without modifying anything.

    .EXAMPLE
        Remove-PSEStaleAccount -Identity jdoe -InactiveDays 30 -Verbose

        Evaluates a single account with progress detail on the verbose stream.

    .EXAMPLE
        $stale = Get-Content .\accounts.txt | Remove-PSEStaleAccount -InactiveDays 180
        $stale | Where-Object Action -eq 'Disabled' | Export-Csv .\disabled.csv -NoTypeInformation

        Captures results as objects for downstream reporting.

    .NOTES
        Requires: ActiveDirectory module, delegated write access to the target OU.

    .LINK
        Get-ADUser
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('PSEngineer.AccountResult')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Identity,

        [Parameter()]
        [ValidateRange(1, 3650)]
        [int]$InactiveDays = 90
    )

    begin {
        $cutoff = (Get-Date).AddDays(-$InactiveDays)
        Write-Verbose "Cutoff date: $cutoff"
    }

    process {
        foreach ($id in $Identity) {
            try {
                $getParams = @{
                    Identity    = $id
                    Properties  = 'LastLogonDate', 'Enabled'
                    ErrorAction = 'Stop'
                }
                $user = Get-ADUser @getParams
            }
            catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
                Write-Error -Message "Account not found: $id" -Category ObjectNotFound -TargetObject $id
                continue
            }

            $isStale = $user.LastLogonDate -and $user.LastLogonDate -lt $cutoff
            $action  = 'Skipped'

            if ($isStale -and $PSCmdlet.ShouldProcess($id, 'Disable account')) {
                try {
                    Disable-ADAccount -Identity $user -ErrorAction Stop
                    $action = 'Disabled'
                }
                catch {
                    $PSCmdlet.WriteError($_)
                    $action = 'Failed'
                }
            }

            [pscustomobject]@{
                PSTypeName    = 'PSEngineer.AccountResult'
                Identity      = $id
                LastLogonDate = $user.LastLogonDate
                Action        = $action
            }
        }
    }

    end {
        Write-Verbose 'Evaluation complete.'
    }
}

2. Naming


3. Parameters and validation

Push constraints into attributes so Get-Help and tab-completion expose them for free:

[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Environment

[ValidateRange(1, 100)]
[int]$BatchSize = 25

[ValidateScript({
    if (-not (Test-Path -LiteralPath $_)) { throw "Path not found: $_" }
    $true
})]
[string]$ConfigPath

failure text tells the user nothing.

Completion

[ValidateSet] handles closed sets that are known at authoring time. When the valid values depend on the environment, supply a completer instead of leaving the user to guess:

[Parameter(Mandatory)]
[ArgumentCompleter({
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    (Get-PSEProfile).Name |
        Where-Object { $_ -like "$wordToComplete*" } |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new($_) }
})]
[string]$ProfileName

anything that crosses the network. A completer that queries a directory on each keystroke makes the whole shell feel broken.

display name that differs from the inserted value.

class and reference it with [ArgumentCompleter([PSEProfileCompleter])].

use it as a substitute for declaring the completer on your own parameters.


4. Splatting

Splat any call with three or more parameters, or any call whose line would exceed ~100 characters. Build the hashtable immediately above the call.

# Yes
$requestParams = @{
    Uri         = "$BaseUri/v1.0/users"
    Method      = 'Get'
    Headers     = $authHeader
    ContentType = 'application/json'
    ErrorAction = 'Stop'
}
Invoke-RestMethod @requestParams

# No. Backticks are a maintenance hazard
Invoke-RestMethod -Uri "$BaseUri/v1.0/users" -Method Get `
    -Headers $authHeader -ContentType 'application/json' -ErrorAction Stop

Conditional parameters are added to the hashtable, never assembled as string fragments:

if ($Credential) { $params['Credential'] = $Credential }

Never use backtick line continuation. Splat instead.


5. Output and the pipeline

prefer Write-Information even then.

ordering or summary requires it.

than one function gets a Format.ps1xml defining its default view, otherwise you have paid the cost of typing the output and taken none of the benefit. Register it through FormatsToProcess in the manifest.

DefaultDisplayPropertySet. DefaultDisplayPropertySet is what lets an object carry twenty properties while showing the four that matter, which removes most of the temptation to reach for Format-Table in the first place.

declare both files in the manifest.

cmdlet exists. This is the most commonly skipped requirement in generated code; treat it as mandatory unless the function genuinely takes no item-shaped input.


6. Error handling

SituationDo this
One item of many failsWrite-Error + continue, keep processing
Precondition invalid, cannot proceed$PSCmdlet.ThrowTerminatingError($errorRecord)
Calling a cmdlet you must catchPass -ErrorAction Stop on that call
Re-surfacing a caught error$PSCmdlet.WriteError($_), which preserves the record

Strict mode

every module. It turns typo'd variable names, missing properties, and out-of-range array indexes into errors instead of silent $null.

Latest changes meaning as the engine changes, which is the point of it and also the risk.

validation. It does nothing about a caller passing a valid-but-wrong value.

once at the top and fix what it surfaces.


7. Safety: ShouldProcess

Any function that creates, modifies, or deletes state declares SupportsShouldProcess.

so a -WhatIf run doubles as an audit.

ShouldProcess, and always paired with a -Force switch to bypass it.


8. Help quality bar

Decorative help is worse than no help, because it implies documentation exists.

modules read as a folder of scripts rather than a product.

Test: everything in Get-Help <function> -Examples must be copy-pasteable.


9. Testing with Pester v5

Pester v5 only. v4 syntax fails in v5 because of discovery/run phase scoping. Do not mix them.


10. Module structure

PSEngineer/
├── src/
│   ├── Public/          # one exported function per file, named for the function
│   ├── Private/         # helpers, not exported
│   ├── Classes/         # loaded before functions
│   └── en-US/           # about_ help, localized strings
├── tests/
├── build/
├── PSEngineer.psd1
├── PSEngineer.Format.ps1xml     # default views for emitted PSTypeNames
├── PSEngineer.Types.ps1xml      # DefaultDisplayPropertySet, ScriptProperties
└── PSEngineer.psm1

parse on discovery and measurably slow every Import-Module and tab-completion.

Dependencies

Every entry in RequiredModules is an install step, a version conflict, and a support question. Target zero. The whole of onboarding should be:

Install-Module PSEngineer -Scope CurrentUser
Import-Module PSEngineer

No build step, no manual assembly copying, no elevation, no post-install script, no network call at import time.

for it at call time and fail with the exact command that fixes it: throw "Requires the Az.Accounts module. Install with: Install-Module Az.Accounts -Scope CurrentUser"

become yours the moment you do.

for people who cannot meet your minimum.


11. Performance

[System.Collections.Generic.List[T]]::new() or emit straight to the pipeline.

Where-Object (client-side) for AD, Exchange, and file system queries.

when you do optimize.

Parallelism

Sequential until measurement says otherwise. Every runspace is a fresh session that re-imports modules and re-resolves commands, and that startup cost swamps the work in most scripts people reach for parallelism to fix.

remote queries, and there are enough items to amortize runspace startup. It rarely helps CPU-bound PowerShell.

and the right number depends on what is being waited on.

need a thread-safe type such as [System.Collections.Concurrent.ConcurrentBag[object]]. A plain List[T] will corrupt or throw under contention.

ForEach-Object -Parallel suits the same job.

saying which constraint forced it.

without synchronization, or depends on ordering.


12. Security

12.1 Secret handling

logging, and transcription capture expanded string values, so Write-Verbose "pass: $plain" writes the secret to the event log on every endpoint that runs it. This applies to verbose, debug, warning, error, and progress alike.

table to any local user. Use stdin, an environment variable scoped to the child process, or a temp file with a restrictive ACL.

scope, and never assign the result to a variable that outlives the call.

obfuscation, not encryption. Do not present it as protection.

12.2 Untrusted input

Assume every parameter value is hostile. Banning Invoke-Expression alone is not enough. These are the equivalent surfaces:

SurfaceRule
Invoke-ExpressionBanned. Use & with an argument array, or splatting.
[scriptblock]::Create($input)Banned on any non-literal input.
Add-Type -TypeDefinition $inputBanned on any non-literal input.
LDAP -Filter / -LDAPFilterEscape input; never concatenate raw values into a filter.
Invoke-SqlcmdParameterized queries only. Never interpolate into -Query.
Native command argsQuote deliberately; know when --% applies and the PS 7.3 argument-passing change.
Regex patterns[regex]::Escape() on any user-supplied fragment.
Paths-LiteralPath when the value may contain wildcard characters.

Also:

require an inline justification. Setting [ServicePointManager]::ServerCertificateValidationCallback = { $true } is banned outright. It disables TLS validation session-wide, far beyond the call that set it.

it at untrusted input. Use JSON for interchange.

the destination directory (zip-slip).

restrictive ACL before anything sensitive is written.

problem with JEA or resource-based constrained delegation instead.

definitions, most type accelerators, Add-Type) simply won't run in WDAC/AppLocker environments. Fail with a clear message rather than a type-resolution error.

12.3 Supply chain

documentation and README examples, no matter how many upstream projects recommend it.

12.4 Execution context

as a workaround.


13. Cross-platform

no parallel ForEach-Object).


14. When to use classes

PowerShell classes earn their place for:

They are the wrong tool when:

Idiomatic PowerShell is functions plus objects. Do not produce Java-flavored PowerShell: no service-locator patterns, no abstract base classes, no interface hierarchies. Reach for a class when it removes duplication or enforces an invariant you would otherwise have to check by hand.


15. Output, logging, and accessibility

15.1 Presentation

15.2 Logging for unattended and long-running work

The streams are built for someone watching a console. A script that runs for six hours on a schedule with nobody attached needs a durable record, and Write-Verbose is not one.

Export-Csv should not also have to parse a log file to learn what happened.

severity, the run identifier, and the target object's identity.

single invocation can be reconstructed from a shared log.

New-WinEvent for scheduled tasks, or stdout where a container runtime collects it. A bare .log file next to the script is the fallback, not the default.

everything typed and returned, including anything sensitive that crossed the session.

operates the script, so take a path parameter and document the expectation.


16. Localization

User-facing strings in modules come from Import-LocalizedData, not string literals:

Import-LocalizedData -BindingVariable Strings -FileName Strings.psd1
Write-Verbose ($Strings.ProcessingAccount -f $Identity)

with src/en-US/Strings.psd1 holding the defaults. Verbose, warning, and error text are localizable. Parameter names, property names, and PSTypeName values are not, because they are API surface and stay English.


17. Style


18. Graphical interfaces

One rule outranks everything else in this section: the GUI is a client of the module, never the home of its logic. Every action a window can trigger must be reachable as a function call with the same parameters and the same output. Behavior that exists only behind a button cannot be tested, scheduled, logged, or composed, and a module in that state has failed at its main job.

Ship the interface as a separate companion module (PSEngineer.Gui) that depends on the core. WPF and WinForms are Windows only, and putting them in the core module makes an otherwise cross-platform module fail to load on Linux.

18.1 Choosing an approach

NeedUse
Let the user pick from a listOut-GridView -PassThru, or Out-ConsoleGridView where cross-platform matters
A form for one function's parametersShow-Command
Small utility, roughly a dozen controlsWinForms
Layout, styling, theming, or data bindingWPF
Must run on Linux or macOSA local web UI or a terminal UI. Not WPF, not WinForms.

Show-Command deserves a look before anything gets built. It generates a form directly from a function's parameter metadata, so a function written to section 3 of this document already has a working interface at no cost. Reach for a custom window only once you know why that one is not enough.

18.2 WPF

A several-hundred-line here-string cannot be opened in a designer and will not be maintained.

resolve code-behind and throws when it finds a reference to it.

PowerShell with $button.Add_Click({ ... }).

waiting to happen.

18.3 WinForms

high-DPI displays that are now the default.

costs more to maintain than the XAML would have.

18.4 Responsiveness

The most common defect in PowerShell GUIs is a window that locks up the moment work begins, because the work is running on the thread that draws the window.

separate runspace.

$control.Invoke([Action]{ ... }) in WinForms. Touching a control from another thread throws or corrupts state.

the runspace, not through module-scoped variables.

no feedback is indistinguishable from a crash.

differed across Windows PowerShell and PowerShell 7 releases, so set it explicitly in whatever launches the window.

18.5 Behavior

Two confirmation prompts, one of them a console dialog hidden behind the GUI, is worse than either on its own.

has swallowed the error.

AutomationProperties.Name in WPF on every control that carries meaning, keep tab order logical, and give every action a keyboard route.


19. Prose: comments, help, and READMEs

This document produces text as well as code. Comment-based help, inline comments, README files, and changelog entries all ship with the module, and they are where generated work gives itself away fastest.

Write like a competent colleague explaining something in a hurry. The reader is stuck, in a rush, and does not want to be reading this at all.

19.1 Voice

configurations may present challenges."

19.2 Words and shapes to avoid

Instead ofWrite
leverage, utilizeuse
robust, powerful, seamless, comprehensivewhat it actually does
simply, just, easilynothing, delete the word
In today's fast-paced worldthe first real sentence
Let's dive in, let's explorenothing
In conclusion, by following these stepsnothing, stop writing
It is not just X, it is Yone clear claim
Whether you are a beginner or an expertnothing

Also out: em dashes, emoji in headings, rhetorical questions as transitions, three adjectives where one works, and any paragraph that restates the heading above it.

19.3 Comments

someone else's module with a link to the issue.

19.4 Comment-based help

Section 8 covers what the help must contain. This covers how it should sound. .DESCRIPTION is where modules give themselves away, because it is the only field long enough to hold filler.

Before:

.DESCRIPTION
    This powerful function leverages the ActiveDirectory module to seamlessly
    retrieve and process user account information, providing a comprehensive
    solution for identifying accounts that may require attention.

After:

.DESCRIPTION
    Finds enabled accounts that have not signed in since the cutoff date and
    disables them. You need write access to the OU. Returns a result object for
    every account it checked, including the ones it skipped, so you can pipe the
    output to Export-Csv and keep a record of the whole run.

The second is longer and says more. Length was never the problem. Filler was.

19.5 READMEs

made of adjectives.


20. Files and reports

20.1 Emitting and exporting are different jobs

A function that queries data emits objects. It does not write files. The caller decides where the data goes:

Get-PSEStaleAccount -InactiveDays 90 | Export-Csv .\stale.csv -NoTypeInformation -Encoding utf8

Not Get-PSEStaleAccount -OutputCsv .\stale.csv. The moment a query function owns a file path, it can no longer be piped, filtered, tested without touching disk, or reused by the next script that needs the same data in a different shape.

A dedicated export or report function is fine when the formatting itself is the work, for example Export-PSEAccountReport. It takes objects from the pipeline. It does not do its own querying.

Rules for anything that writes a file:

and PowerShell 7, and the failure shows up later as mangled non-ASCII characters in someone else's spreadsheet. Use utf8 unless a consumer demands otherwise.

UTC.

20.2 CSV

CSV is for handing data to a human with Excel. It is a poor interchange format between scripts, because every value comes back as a string and the types are gone.

type name, which is worse than useless. Use calculated properties to reduce it to a string first.

can vary, write a new file.

matters more often than people expect.

is executed as a formula when the file opens in Excel, which turns an exported display name into code execution on someone else's workstation. Prefix any untrusted string field with a single quote, or reject those leading characters. This is the same class of problem as section 12.2 and gets the same treatment.

20.3 JSON

JSON is the default when a script hands data to another script, another language, or an API.

the resulting file looks fine until someone reads it. There is no good reason to leave it unset.

PSCustomObject graphs for large payloads.

offset, which is what a consumer in another timezone needs.

generated JSON readable.

20.4 HTML reports

A report is an artifact someone forwards, archives, and opens six months later on a machine with no network. Build for that.

rather than accepting the default output, which is a bare unstyled page.

stylesheets, no remote fonts, no script tags pulling from the internet. A report that renders as broken text in an offline Outlook preview has failed, and a remote reference in an emailed file is a tracking beacon whether you intended one or not.

-PreContent, -PostContent, or -Head. Anything user-supplied that reaches those parameters must be encoded first with [System.Net.WebUtility]::HtmlEncode().

Every report carries a provenance block, or it cannot be trusted later:

Accessibility applies here exactly as in section 15:

the email client will use.

For anything beyond tables, a here-string template with placeholder substitution stays readable far longer than deeply nested ConvertTo-Html calls. A community module such as PSWriteHTML is a reasonable choice for complex output, but it is an optional dependency and section 10 applies: check for it at call time and fail with the install command.


Definition of done

Invoke-ScriptAnalyzer -Path .\src -Recurse -Settings .\PSScriptAnalyzerSettings.psd1
Invoke-Pester -Path .\tests -Output Detailed
Get-Help <Function> -Full        # examples run unmodified
<Function> -WhatIf               # produces useful output, changes nothing

All four clean. No warnings suppressed without an inline justification comment.


Lineage

This document assembles existing consensus rather than inventing a new one. Where it agrees with the sources below, they came first:

with community maintainers. Roughly a third of this document is mechanically enforced by rules that already ship in that module.

are the origin of most of sections 2 through 7.

to an agreed style baseline, though it predates PowerShell 7 and Pester v5.

Where those sources disagree with each other, this document picks one and says why in the relevant section. The material on security, accessibility, localization, and writing for generation rather than for reading goes beyond them, and that is where to aim criticism.

Corrections and disputes belong in the issue tracker at powershellengineer.com.


Deeper guidance

Load the matching skill rather than expanding this file. These are planned and not yet published. If one is unavailable, apply this document and state which guidance you lacked:

TaskSkill
New module scaffoldpse-module-scaffold
Pester v5 suitespse-testing
Comment-based + about_ helppse-help-authoring
Refactoring inherited scriptspse-refactor
Threat modeling, JEA, signing, hardening reviewpse-security
5.1 → 7.x migrationpse-migration
Microsoft Graph, Entra, Exchange, ADpse-domain-<name>