How I Use Claude to Write PowerShell Scripts in 30 Seconds

My workflow for getting usable PowerShell out of Claude: a prompt with constraints, -WhatIf first, PSScriptAnalyzer, and the 5.1 vs 7 traps it falls into.

Share
How I Use Claude to Write PowerShell Scripts in 30 Seconds

The "30 seconds" in the title is the generation part. Describe the job, get a script back, done. The part that actually takes time is reading it before it touches a production box — and that part I don't skip, ever. This post is about making the 30 seconds produce something worth reading.

TL;DR: tell it the PowerShell version, demand -WhatIf support, ask for read-only first, lint the output, run it with -WhatIf before you run it for real.

Write a PowerShell script for Windows PowerShell 5.1 on Windows Server 2022.
Task: <what it should do>.
Constraints: CmdletBinding with SupportsShouldProcess, parameters instead of
hardcoded paths, no aliases, no external modules unless I name them,
Write-Verbose for progress, fail loudly on errors.
Output: only the script, then a short list of assumptions you made.
Install-Module PSScriptAnalyzer -Scope CurrentUser
Invoke-ScriptAnalyzer -Path .\script.ps1
.\script.ps1 -WhatIf -Verbose

Why constraints beat clever prompts

Ask "write me a script that cleans up old logs" and you get a script. It might use PowerShell 7 syntax on your 5.1 server. It might hardcode C:\Logs. It might call Remove-Item -Recurse -Force on whatever path it guessed. None of that is the model being dumb — you didn't tell it anything, so it filled in the blanks with the most common case.

Every line in my template exists because leaving it out produced something I had to fix:

  • Version. Windows PowerShell 5.1 and PowerShell 7 are not the same language. Ternary operators, ??, ForEach-Object -Parallel — all 7-only. Servers usually run 5.1 by default. Say which one.
  • SupportsShouldProcess. Gives you -WhatIf and -Confirm for free. Any script that deletes, moves, or changes something gets this. Non-negotiable.
  • Parameters. Hardcoded paths make scripts single-use. Parameters with sane defaults make them tools.
  • No aliases. gci | ? {...} | % {...} is fine in a shell, unreadable in a file you'll open again in six months.
  • Assumptions list. This is the most useful line. It makes the model write down what it guessed, and that's exactly where the bugs are.

A worked example

Here's the kind of task I'd hand over: delete log files older than N days under a folder, recursively, with a dry-run. Below is the shape of script I expect back — I wrote this one out by hand to show the target, but it's what a good answer to the template looks like:

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

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

    [string]$Filter = '*.log'
)

$ErrorActionPreference = 'Stop'
$cutoff = (Get-Date).AddDays(-$Days)
Write-Verbose "Removing $Filter older than $cutoff under $Path"

$files = Get-ChildItem -LiteralPath $Path -Filter $Filter -File -Recurse |
    Where-Object { $_.LastWriteTime -lt $cutoff }

$removed = 0
foreach ($file in $files) {
    if ($PSCmdlet.ShouldProcess($file.FullName, 'Remove file')) {
        Remove-Item -LiteralPath $file.FullName
        $removed++
    }
}

Write-Verbose "Removed $removed of $($files.Count) candidate files"

What I check when reading something like this:

  • -File on Get-ChildItem — without it, a folder named old.log gets matched too.
  • -LiteralPath instead of -Path — paths with [ or ] break -Path because it treats them as wildcards.
  • ValidateRange on $Days — stops someone passing 0 and nuking today's logs.
  • ShouldProcess wrapping the delete — so -WhatIf really does nothing.

If the generated version is missing one of those, I don't fix it by hand. I tell the model what's wrong and let it regenerate — that keeps the conversation as the record of why the script looks the way it does.

Where I run this

Mostly in Claude Code, the CLI, sitting in the folder where the script will live. It can read the neighbouring scripts, so "follow the style of the other scripts in this folder" actually means something. For one-off questions I use the non-interactive mode:

claude -p "PowerShell 5.1: list local accounts on this server that haven't logged in for 90 days. Read-only. Script only, then assumptions."

For scripts that touch AD or remote machines, I give it the real context: domain name, OU paths, which cmdlets are available on the box. An example of the context block I paste for AD work:

Context: Windows Server 2022, Windows PowerShell 5.1, RSAT ActiveDirectory
module installed. Domain lab.example.com. Servers live in
OU=Servers,OU=Computers,OU=Corp,DC=lab,DC=example,DC=com.
I am running this as a delegated admin, not Domain Admin.

That's the same OU layout from my AD series, and scripts written against it slot straight in — for example around domain joins.

The review loop

  1. Read it. Every line. If I can't explain a line, I ask the model to explain it, then I check that explanation against Get-Help.
  2. Verify every cmdlet and parameter exists. Models occasionally invent a parameter that sounds right. This catches it in seconds:
Get-Command Get-ADComputer -Syntax
(Get-Command Remove-Item).Parameters.Keys
  1. Lint. Invoke-ScriptAnalyzer flags aliases, unused variables, missing ShouldProcess on functions with state-changing verbs, plaintext password parameters. Feed the warnings back to the model if there are many.
  2. Dry run. -WhatIf -Verbose on the real target. Read the output. Count the "What if:" lines — does that number make sense?
  3. Run for real, on one machine first.
  4. Schedule it if it's recurring — that part is in Task Scheduler automation with PowerShell.

Step 4 is where most bad surprises show up. A "clean up old files" script that wants to remove ten thousand files when you expected fifty is a bug, not a cleanup.

Gotcha: PowerShell 7 syntax on a 5.1 box

The most common failure. The script parses fine in the model's head and dies on the server with a parse error on ?? or ? :. Or it uses ConvertFrom-Json -AsHashtable, which doesn't exist in 5.1. Check what you're on:

$PSVersionTable.PSVersion

and put that version in the prompt. If you can install PowerShell 7 on your servers, great — then say 7. Just say something.

Gotcha: encoding and umlauts

Windows PowerShell 5.1 reads a .ps1 without BOM as the system ANSI codepage. A script saved as UTF-8 without BOM that contains ä, ü or € in a string turns those into garbage at runtime. Save scripts as UTF-8 with BOM if they contain anything non-ASCII, or keep strings ASCII.

Same story on output: in 5.1, Out-File and > write UTF-16LE by default, which surprises whatever tool reads the file next. Ask for -Encoding UTF8 explicitly when the output goes to another program.

Gotcha: confident wrong answers

The script that looks cleanest can still be wrong — a filter that matches nothing, a property name that doesn't exist on that object (which PowerShell silently treats as $null, so Where-Object just returns nothing). Set Set-StrictMode -Version Latest at the top while testing; it turns those silent $nulls into errors.

The actual point

Claude doesn't replace knowing PowerShell. It replaces typing it. You still need to read the output, which is why I keep the PowerShell sysadmin cheatsheet around — it's the layer that lets you tell a good generated script from a plausible one. The prompts I use for other sysadmin work, Linux included, are in the sysadmin AI prompt cheat sheet.


Related posts