
Powershell Expert
- 71 installs
- 36 repo stars
- Updated July 14, 2026
- oimiragieo/agent-studio
Helps with ai & agent building tasks.
About
powershell-expert is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- powershell-expert
- AI & Agent Building
- AI-coding skill
Powershell Expert by the numbers
- 71 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,673 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oimiragieo/agent-studio --skill powershell-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 71 |
|---|---|
| repo stars | ★ 36 |
| Last updated | July 14, 2026 |
| Repository | oimiragieo/agent-studio ↗ |
What it does
Helps with ai & agent building tasks.
Files
PowerShell Expert Skill
<identity> Automation Architect and Windows Internals Specialist -- expert in high-scale scripting, system orchestration, and secure administrative patterns. Specialist in PowerShell 7 Core, Desired State Configuration (DSC), Just Enough Administration (JEA), and Pester testing. </identity>
<capabilities>
- Design and implement robust automation scripts using PowerShell 7+
- Audit scripts for security (injection, plaintext secrets, unsafe aliases)
- Optimize pipeline performance using parallelization and background jobs
- Manage complex system states across Windows, Linux, and cloud environments
- Design custom modules with structured help and unit tests (Pester)
- Orchestrate secure deployments using JEA (Just Enough Administration) patterns
- Configure Desired State Configuration (DSC) for infrastructure as code
- Build CI/CD pipelines with PowerShell-based build and deploy scripts
</capabilities>
Overview
This skill covers PowerShell 7+ (Core) for cross-platform automation, system administration, and DevOps scripting. The core philosophy is: treat PowerShell as a typed, object-oriented automation language -- not a bash replacement. Every script must handle errors explicitly, use structured objects instead of text parsing, and never expose credentials in plaintext.
When to Use
- When writing PowerShell automation scripts for Windows or cross-platform
- When auditing existing PowerShell scripts for security and reliability
- When setting up CI/CD pipelines with PowerShell-based tooling
- When managing Windows infrastructure with DSC or JEA
- When building PowerShell modules with proper structure and testing
- When migrating from Windows PowerShell 5.1 to PowerShell 7+
Iron Laws
1. ALWAYS set $ErrorActionPreference = 'Stop' at the top of scripts -- silent failures are the primary cause of automation bugs and data loss. 2. NEVER hardcode credentials or secrets in scripts -- use Microsoft.PowerShell.SecretManagement module to pull secrets from vaults. 3. ALWAYS use [PSCustomObject] or -OutputType JSON for structured output -- text parsing with regex is fragile and breaks on locale/format changes. 4. NEVER use Invoke-Expression (IEX) on untrusted input -- it is the PowerShell equivalent of eval() and enables arbitrary code execution. 5. ALWAYS write Pester tests for production scripts -- untested automation is a liability in enterprise environments.
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Parsing command output with regex instead of using objects | Breaks on locale changes, format updates, and different OS versions | Use cmdlet object output directly or convert to PSCustomObject |
Using Invoke-Expression to build dynamic commands | Enables code injection; any user input can execute arbitrary PowerShell | Use splatting (@params) for dynamic parameters; use Start-Process for external commands |
| Catching all exceptions with empty catch blocks | Silently swallows errors; automation appears to succeed when it failed | Use typed catch blocks; log and re-throw unexpected exceptions |
| Using Windows PowerShell 5.1 syntax without checking compatibility | Scripts fail on Linux/macOS where PS 7 is the only option | Use $PSVersionTable.PSVersion checks; prefer PS 7 cross-platform cmdlets |
| Storing credentials in script variables or config files | Plaintext secrets in source control; credential theft risk | Use Get-Secret from SecretManagement module; inject via environment variables in CI |
Workflow
Step 1: Script Structure
#Requires -Version 7.0
#Requires -Modules @{ ModuleName='Microsoft.PowerShell.SecretManagement'; ModuleVersion='1.1.0' }
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Invoke-DataBackup {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$TargetPath,
[Parameter()]
[switch]$Compress
)
begin {
Write-Verbose "Starting backup to $TargetPath"
}
process {
try {
# Business logic here
}
catch [System.IO.IOException] {
Write-Error "IO error during backup: $_"
throw
}
catch {
Write-Error "Unexpected error: $_"
throw
}
}
end {
Write-Verbose "Backup complete"
}
}Step 2: Secure Secret Retrieval
# Register a vault (one-time setup)
Register-SecretVault -Name 'AzureKeyVault' -ModuleName 'Az.KeyVault'
# Retrieve secret at runtime
$apiKey = Get-Secret -Name 'MyApiKey' -Vault 'AzureKeyVault' -AsPlainText
# Use in automation (never log the value)
$headers = @{ 'Authorization' = "Bearer $apiKey" }
Invoke-RestMethod -Uri $endpoint -Headers $headersStep 3: Object-Oriented Pipeline
# Process structured data through the pipeline
Get-ChildItem -Path $target -Filter *.json |
ForEach-Object {
$data = Get-Content -Path $_.FullName | ConvertFrom-Json
[PSCustomObject]@{
FileName = $_.Name
ItemCount = $data.items.Count
LastModified = $_.LastWriteTime
}
} |
Sort-Object -Property ItemCount -Descending |
Export-Csv -Path 'report.csv' -NoTypeInformationStep 4: Pester Testing
# Invoke-DataBackup.Tests.ps1
Describe 'Invoke-DataBackup' {
BeforeAll {
. $PSScriptRoot/Invoke-DataBackup.ps1
}
Context 'When target path exists' {
It 'Should create backup file' {
$result = Invoke-DataBackup -TargetPath $TestDrive
$result | Should -Not -BeNullOrEmpty
Test-Path "$TestDrive/backup.zip" | Should -BeTrue
}
}
Context 'When target path is invalid' {
It 'Should throw IO exception' {
{ Invoke-DataBackup -TargetPath '/nonexistent/path' } |
Should -Throw -ExceptionType ([System.IO.IOException])
}
}
}Step 5: Cross-Platform Compatibility
# Use Join-Path for all path operations
$configPath = Join-Path -Path $HOME -ChildPath '.config' -AdditionalChildPath 'myapp', 'settings.json'
# Check platform before using platform-specific features
if ($IsWindows) {
# Windows-specific: registry, WMI, COM
$os = Get-CimInstance -ClassName Win32_OperatingSystem
} elseif ($IsLinux -or $IsMacOS) {
# Unix-specific: /proc, systemctl
$os = uname -a
}Complementary Skills
| Skill | Relationship |
|---|---|
devops | CI/CD pipeline integration with PowerShell scripts |
docker-compose | Containerized PowerShell automation |
terraform-infra | Infrastructure provisioning alongside PS configuration |
tdd | Test-driven development methodology for Pester tests |
Memory Protocol (MANDATORY)
Before starting:
Read .claude/context/memory/learnings.md for prior PowerShell modules, Pester testing patterns, or OS-specific workarounds.
After completing: Record new PowerShell modules, Pester testing patterns, or OS-specific workarounds to .claude/context/memory/learnings.md.
ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.
Invoke the powershell-expert skill and follow it exactly as presented to you
'use strict';
/**
* Post-execute hook for powershell-expert
* Auto-generated by enterprise-bundle-scaffolder
*
* Records metrics after skill execution.
*/
function postExecute(_context) {
// Record execution metrics
return { ok: true, skill: 'powershell-expert' };
}
module.exports = { postExecute };
'use strict';
/**
* Pre-execute hook for powershell-expert
* Auto-generated by enterprise-bundle-scaffolder
*
* Validates inputs before skill execution.
*/
function preExecute(context) {
// Validate skill invocation context
if (!context || typeof context !== 'object') {
return { allow: true, message: 'powershell-expert: no context to validate' };
}
return { allow: true };
}
module.exports = { preExecute };
# Reference materials for this skill
PowerShell Best Practices
Naming Conventions
Cmdlet Naming (Verb-Noun)
- Use approved verbs:
Get-Verblists all approved verbs - Use singular nouns:
Get-ItemnotGet-Items - Prefix module nouns to avoid conflicts:
Get-MrVersion(module prefix "Mr") - PascalCase for both verb and noun:
Invoke-WebRequest
Variables and Parameters
- PascalCase for parameter names:
$ComputerName,$OutputPath - camelCase acceptable for local variables:
$currentIndex - Descriptive names:
$serverListnot$sl - Avoid single-letter variables except loop iterators
Files and Modules
- Module file:
ModuleName.psm1 - Manifest file:
ModuleName.psd1 - Script files:
Verb-Noun.ps1 - Test files:
Verb-Noun.Tests.ps1
Function Design
Always Use CmdletBinding
[CmdletBinding(SupportsShouldProcess)]
param(...)This gives you: -Verbose, -Debug, -ErrorAction, -WhatIf, -Confirm for free.
Parameter Validation Attributes
[ValidateNotNullOrEmpty()] # Not null or empty string
[ValidateNotNull()] # Not null (allows empty string)
[ValidateRange(1, 100)] # Numeric range
[ValidateSet('A', 'B', 'C')] # Enumerated values
[ValidateLength(1, 255)] # String length
[ValidatePattern('^[a-z]+$')] # Regex pattern
[ValidateScript({ $_ -gt 0 })] # Custom script validationPipeline Support
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string[]]$Name
)
process {
foreach ($Item in $Name) {
# Process one at a time for streaming
}
}ShouldProcess for Destructive Operations
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(...)
process {
if ($PSCmdlet.ShouldProcess($target, 'Delete')) {
Remove-Item $target
}
}OutputType Attribute
[OutputType([PSCustomObject])]
[OutputType([System.IO.FileInfo])]Comment-Based Help
Always include comment-based help for public functions:
<#
.SYNOPSIS
One-line description.
.DESCRIPTION
Detailed description. Can be multi-line.
.PARAMETER ComputerName
Description of ComputerName parameter.
.PARAMETER Credential
Description of Credential parameter.
.EXAMPLE
Get-Something -ComputerName 'server1'
Example description.
.EXAMPLE
'server1', 'server2' | Get-Something
Pipeline example.
.INPUTS
System.String
.OUTPUTS
PSCustomObject with Name, Status, Data properties.
.NOTES
Author: Your Name
Version: 1.0
.LINK
https://docs.example.com/Get-Something
#>Code Style
No Aliases in Scripts
# BAD (aliases - break on other systems/profiles)
ls | ? { $_.Name -match '*.ps1' } | % { $_.FullName }
# GOOD (full cmdlet names)
Get-ChildItem | Where-Object { $_.Name -match '*.ps1' } | ForEach-Object { $_.FullName }Splatting for Long Commands
# BAD (long line, hard to read)
Invoke-Command -ComputerName $server -Credential $cred -ScriptBlock $sb -ErrorAction Stop
# GOOD (splatting)
$invokeParams = @{
ComputerName = $server
Credential = $cred
ScriptBlock = $sb
ErrorAction = 'Stop'
}
Invoke-Command @invokeParamsOutput Objects, Not Text
# BAD: Returns text
"Server: $name, Status: $status"
# GOOD: Returns structured objects
[PSCustomObject]@{
Server = $name
Status = $status
}Write-Output vs Write-Host
Write-Output— sends to pipeline (use for data)Write-Host— directly to console, bypasses pipeline (use only for display)Write-Verbose— informational messages with -VerboseWrite-Warning— warnings (non-terminating issues)Write-Error— non-terminating errorsthrow— terminating errors
Avoid Format-\* in Functions
# BAD: Format-Table breaks pipeline
function Get-Data {
Get-Process | Format-Table # Can't pipe this output
}
# GOOD: Return objects, let caller format
function Get-Data {
Get-Process # Caller can pipe to Format-Table, Select-Object, etc.
}Error Handling Best Practices
1. Use specific exception types in catch blocks 2. Use -ErrorAction Stop on individual cmdlets rather than $ErrorActionPreference 3. Always re-throw if you can't handle the error: throw 4. Log errors with context: Write-Error -ErrorRecord $_ 5. Use finally for cleanup that must always run
Compatibility Notes (5.1 vs 7+)
| Feature | PS 5.1 | PS 7+ |
|---|---|---|
?? null-coalescing | No | Yes |
?. null-conditional | No | Yes |
Ternary ? : | No | Yes |
&& / `\ | \ | ` pipeline chains |
ForEach-Object -Parallel | No | Yes |
Start-ThreadJob | Module required | Built-in |
| Cross-platform | Windows only | Windows/Linux/macOS |
| UTF-8 default | No | Yes |
Add #Requires -Version 7.0 at top of scripts that use PS7+ features.
PowerShell DevOps Integration
GitHub Actions with PowerShell
Basic Workflow
name: PowerShell CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Dependencies
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-PSResource -Name Pester -Scope CurrentUser
Install-PSResource -Name PSScriptAnalyzer -Scope CurrentUser
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
$results = Invoke-ScriptAnalyzer -Path ./src -Recurse -Severity Error
if ($results.Count -gt 0) {
$results | Format-Table -AutoSize
throw "PSScriptAnalyzer found $($results.Count) error(s)"
}
Write-Host "PSScriptAnalyzer: No errors found"
- name: Run Pester Tests
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = 'TestResults.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
$config.Run.Exit = $true
Invoke-Pester -Configuration $config
- name: Publish Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: TestResults.xmlCross-Platform Matrix
jobs:
test:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Run Tests
shell: pwsh # 'pwsh' works on all platforms; 'powershell' is Windows-only
run: Invoke-Pester -Path ./tests -CIPublish to PSGallery from CI
- name: Publish Module
if: github.ref == 'refs/heads/main'
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
run: |
Publish-PSResource -Path ./MyModule -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEYAzure DevOps Pipeline
trigger:
branches:
include:
- main
pool:
vmImage: 'windows-latest'
steps:
- task: PowerShell@2
displayName: 'Run Pester Tests'
inputs:
targetType: inline
pwsh: true # Use pwsh (cross-platform) not powershell
script: |
Install-PSResource -Name Pester -Scope CurrentUser
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = '$(System.DefaultWorkingDirectory)/TestResults.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
$config.Run.Exit = $true
Invoke-Pester -Configuration $config
- task: PublishTestResults@2
displayName: 'Publish Test Results'
condition: always()
inputs:
testResultsFormat: NUnit
testResultsFiles: '**/TestResults.xml'Azure PowerShell Module
# Install Az module (modular)
Install-PSResource -Name Az -Scope CurrentUser
# Or install only needed submodules
Install-PSResource -Name Az.Compute -Scope CurrentUser
Install-PSResource -Name Az.Storage -Scope CurrentUser
# Authenticate
Connect-AzAccount # Interactive
Connect-AzAccount -ServicePrincipal -Credential $spCred -Tenant $tenantId # SP
# Managed Identity (in Azure resources)
Connect-AzAccount -Identity
# Common operations
$vms = Get-AzVM -ResourceGroupName 'MyRG'
New-AzResourceGroup -Name 'MyRG' -Location 'eastus'
Get-AzStorageAccount | Where-Object { $_.Kind -eq 'StorageV2' }AWS Tools for PowerShell
# Install modular AWS.Tools
Install-PSResource -Name AWS.Tools.Common -Scope CurrentUser
Install-PSResource -Name AWS.Tools.S3 -Scope CurrentUser
Install-PSResource -Name AWS.Tools.EC2 -Scope CurrentUser
# Configure credentials
Set-AWSCredential -AccessKey $accessKey -SecretKey $secretKey -StoreAs 'default'
# Use profiles
Set-AWSCredential -ProfileName 'production'
# Common operations
Get-S3Bucket
Get-EC2Instance | Select-Object -ExpandProperty Instances
Write-S3Object -BucketName 'my-bucket' -Key 'folder/file.txt' -File 'C:\file.txt'PowerShell in Docker
# Windows container
FROM mcr.microsoft.com/powershell:7.4-windowsservercore-ltsc2022
WORKDIR /app
COPY MyModule/ ./MyModule/
COPY tests/ ./tests/
RUN pwsh -Command "Install-PSResource -Name Pester -Scope AllUsers"
CMD ["pwsh", "-Command", "Invoke-Pester -Path ./tests -CI"]# Linux container (cross-platform PS)
FROM mcr.microsoft.com/powershell:7.4-ubuntu-22.04
WORKDIR /app
COPY . .
RUN pwsh -Command "Install-PSResource -Name Pester -Scope AllUsers && Install-PSResource -Name PSScriptAnalyzer -Scope AllUsers"
CMD ["pwsh", "-Command", "Invoke-Pester -CI"]Environment-Specific Configuration
# Best practice: Use environment-specific config files
$env = $env:DEPLOYMENT_ENV ?? 'development'
$configPath = Join-Path $PSScriptRoot "config.$env.json"
$config = Get-Content $configPath | ConvertFrom-Json
# Or use #Requires for environment validation
#Requires -Modules Az.Accounts
#Requires -Version 7.2
#Requires -RunAsAdministrator
# Environment detection in CI
$isCI = [bool]($env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS)
if ($isCI) {
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # Speeds up downloads in CI
}CI Best Practices
1. Use pwsh shell target (not powershell) for cross-platform compatibility 2. Run Pester with -CI flag for machine-readable output 3. Publish NUnit test results for CI dashboards 4. Run PSScriptAnalyzer as a gate before tests 5. Store API keys in CI secrets, not code 6. Use $ProgressPreference = 'SilentlyContinue' to speed up downloads 7. Set $ErrorActionPreference = 'Stop' in CI scripts to fail fast 8. Use Install-PSResource (PSResourceGet) not Install-Module (deprecated)
PowerShell Module Authoring
Module Structure
MyModule/
MyModule.psd1 (module manifest - REQUIRED)
MyModule.psm1 (root module file)
Public/ (exported functions)
Get-Something.ps1
Set-Something.ps1
New-Something.ps1
Remove-Something.ps1
Private/ (internal helper functions, NOT exported)
Invoke-Helper.ps1
ConvertTo-Internal.ps1
Tests/ (Pester tests)
Get-Something.Tests.ps1
en-US/
MyModule-help.xml (MAML help - generated by platyPS)
MyModule.Format.ps1xml (optional formatting rules)
MyModule.Types.ps1xml (optional type extensions)
LICENSE
README.mdCreating the Manifest
# Create initial manifest
New-ModuleManifest -Path .\MyModule\MyModule.psd1 `
-RootModule 'MyModule.psm1' `
-ModuleVersion '1.0.0' `
-Guid (New-Guid) `
-Author 'Your Name' `
-CompanyName 'Your Company' `
-Copyright "(c) $(Get-Date -Format yyyy) Your Name. All rights reserved." `
-Description 'Brief module description' `
-PowerShellVersion '7.0' `
-FunctionsToExport @('Get-Something', 'Set-Something', 'New-Something', 'Remove-Something') `
-CmdletsToExport @() `
-AliasesToExport @() `
-VariablesToExport @() `
-RequiredModules @(
@{ ModuleName = 'Az.Accounts'; ModuleVersion = '2.0.0' }
) `
-Tags @('tag1', 'tag2', 'automation') `
-ProjectUri 'https://github.com/owner/MyModule' `
-LicenseUri 'https://github.com/owner/MyModule/blob/main/LICENSE' `
-IconUri 'https://example.com/icon.png' `
-ReleaseNotes 'Initial release'IMPORTANT: Never recreate the manifest — the GUID must stay constant. Use Update-ModuleManifest for subsequent changes:
Update-ModuleManifest -Path .\MyModule\MyModule.psd1 `
-ModuleVersion '1.1.0' `
-FunctionsToExport @('Get-Something', 'Set-Something', 'New-Something', 'Remove-Something', 'Get-NewFeature')Root Module (MyModule.psm1)
# Load private functions
$Private = Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue
foreach ($file in $Private) {
. $file.FullName
}
# Load and export public functions
$Public = Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue
foreach ($file in $Public) {
. $file.FullName
}
# Export using manifest (preferred over Export-ModuleMember)
# FunctionsToExport in .psd1 handles thisSemantic Versioning
Follow SemVer:
MAJOR.MINOR.PATCH(e.g.,2.1.3)- MAJOR: Breaking changes
- MINOR: New backward-compatible features
- PATCH: Bug fixes
PSGallery pre-release: append -beta1, -rc1 suffix to version string.
PSGallery Publishing
Prerequisites
# Install PSResourceGet (modern replacement for PowerShellGet)
Install-PSResource -Name Microsoft.PowerShell.PSResourceGet -Scope CurrentUser
# Get API key from https://www.powershellgallery.com/account/apikeys
$apiKey = Get-Secret -Name 'PSGalleryApiKey' -AsPlainTextPre-publish Checklist
# 1. Run Pester tests
Invoke-Pester -Path .\Tests\ -CI
# 2. Run PSScriptAnalyzer
$results = Invoke-ScriptAnalyzer -Path .\MyModule\ -Recurse -Severity Error
if ($results.Count -gt 0) { throw 'PSScriptAnalyzer errors found' }
# 3. Validate manifest
Test-ModuleManifest -Path .\MyModule\MyModule.psd1
# 4. Check module imports cleanly
Import-Module .\MyModule\MyModule.psd1 -Force -ErrorAction Stop
# 5. Verify exports
Get-Command -Module MyModule | Select-Object Name, CommandTypePublish
# Publish to PSGallery
Publish-PSResource -Path .\MyModule -Repository PSGallery -ApiKey $apiKey
# Publish pre-release
Publish-PSResource -Path .\MyModule -Repository PSGallery -ApiKey $apiKey -PrereleaseModule Versioning in CI/CD
# Bump version in manifest from CI pipeline
$version = '1.2.0'
Update-ModuleManifest -Path .\MyModule\MyModule.psd1 -ModuleVersion $versionComment-Based Help for Module
Each public function must have comment-based help. Use platyPS to generate external MAML help:
Install-PSResource -Name platyPS
# Generate markdown help docs
New-MarkdownHelp -Module MyModule -OutputFolder .\docs\
# Update markdown after changes
Update-MarkdownHelp -Path .\docs\
# Generate MAML XML from markdown
New-ExternalHelp -Path .\docs\ -OutputPath .\en-US\ -ForceModule Types (Choosing the Right Type)
| Type | Extension | Use Case |
|---|---|---|
| Script module | .psm1 | General purpose, most common |
| Binary module | .dll | High performance, compiled C# |
| Manifest module | .psd1 only | Grouping other modules |
| Dynamic module | In-memory | Temporary, programmatically created |
Best Practices
1. Always use a manifest — gives you metadata, versioning, dependency management 2. Separate public/private — prevents accidental exposure of internal helpers 3. Never use `Export-ModuleMember` AND `FunctionsToExport` — pick one (manifest preferred) 4. *Use specific exports, never `''** — performance and security 5. **Include Pester tests in Tests/` 6. Run PSScriptAnalyzer before each publish 7. Keep GUID constant — required for PSGallery identity 8. Use semantic versioning — clear communication of breaking changes 9. Generate help from comment-based help** — use platyPS for MAML
PowerShell Performance Patterns
Measuring Performance
# Time a block of code
$elapsed = Measure-Command {
Get-ChildItem -Recurse C:\Windows\System32
}
Write-Host "Elapsed: $($elapsed.TotalSeconds)s"
# Compare two approaches
$approach1 = Measure-Command {
$result = @()
1..10000 | ForEach-Object { $result += $_ }
}
$approach2 = Measure-Command {
$result = [System.Collections.Generic.List[int]]::new()
1..10000 | ForEach-Object { $result.Add($_) }
}
"Array +=: $($approach1.TotalMilliseconds)ms"
"List.Add: $($approach2.TotalMilliseconds)ms"Array Building (Critical)
# BAD: O(n²) — creates new array on each iteration
$array = @()
foreach ($item in $source) { $array += $item }
# GOOD: O(n) — amortized constant append
$list = [System.Collections.Generic.List[object]]::new()
foreach ($item in $source) { $list.Add($item) }
# Convert back to array if needed
$array = $list.ToArray()
# Also good: ArrayList (non-generic)
$list = [System.Collections.ArrayList]::new()
$list.Add($item) | Out-Null # Suppress return value
# Best for known size: pre-allocated array
$array = [object[]]::new(10000)
for ($i = 0; $i -lt 10000; $i++) { $array[$i] = $i }Pipeline vs Loop Performance
# ForEach-Object pipeline — functional but slower (overhead per object)
1..10000 | ForEach-Object { $_ * 2 }
# foreach statement — fastest for simple loops
$results = [System.Collections.Generic.List[int]]::new()
foreach ($n in 1..10000) { $results.Add($n * 2) }
# .NET LINQ (fastest for filtering/projecting)
$data = 1..10000
$results = [System.Linq.Enumerable]::Where($data, [Func[int,bool]]{ param($x) $x % 2 -eq 0 })Parallel Processing (PS7+)
# ForEach-Object -Parallel for CPU-bound work
$servers = 'server1', 'server2', 'server3', 'server4', 'server5'
$results = $servers | ForEach-Object -Parallel {
$pingResult = Test-Connection $_ -Count 1 -Quiet
[PSCustomObject]@{
Server = $_
Online = $pingResult
}
} -ThrottleLimit 10
# Pass variables into parallel scope with $using:
$threshold = 80
Get-Process | ForEach-Object -Parallel {
if ($_.CPU -gt $using:threshold) {
[PSCustomObject]@{ Name = $_.Name; CPU = $_.CPU }
}
} -ThrottleLimit 4ThreadJob for Background I/O
# Start-ThreadJob (lightweight, lower overhead than Start-Job)
$jobs = 'server1', 'server2', 'server3' | ForEach-Object {
$server = $_
Start-ThreadJob -ScriptBlock {
param($s)
Invoke-RestMethod "https://$s/api/health" -TimeoutSec 5
} -ArgumentList $server -ThrottleLimit 5
}
# Wait and collect results
$results = $jobs | Receive-Job -Wait -AutoRemoveJob.NET Methods for Hot Paths
# File I/O — .NET is faster than cmdlets for large files
# BAD (Get-Content loads entire file into PS objects)
$lines = Get-Content -Path .\large.log
# GOOD (.NET ReadAllLines is faster for one-shot reads)
$lines = [System.IO.File]::ReadAllLines('C:\large.log')
# BEST (streaming for large files)
$reader = [System.IO.StreamReader]::new('C:\large.log')
while (-not $reader.EndOfStream) {
$line = $reader.ReadLine()
if ($line -match 'ERROR') { $line }
}
$reader.Dispose()
# String operations — .NET StringBuilder for concatenation
$sb = [System.Text.StringBuilder]::new()
foreach ($line in $lines) { [void]$sb.AppendLine($line) }
$result = $sb.ToString()Hashtable for O(1) Lookups
# BAD: O(n) search — array contains check
$validValues = @('red', 'green', 'blue')
if ($color -in $validValues) { ... } # Scans entire array
# GOOD: O(1) lookup — hashtable/hashset
$validValues = @{ red = $true; green = $true; blue = $true }
if ($validValues.ContainsKey($color)) { ... }
# Or HashSet
$validSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@('red', 'green', 'blue'))
if ($validSet.Contains($color)) { ... }Provider Filtering vs Client Filtering
# BAD: Client-side filtering (downloads all, filters locally)
Get-ChildItem C:\Windows -Recurse | Where-Object { $_.Name -like '*.dll' }
# GOOD: Provider-side filtering (server/filesystem filters before returning)
Get-ChildItem C:\Windows -Recurse -Filter '*.dll'
# BAD: Download all log entries then filter
Get-WinEvent -LogName System | Where-Object { $_.Level -eq 2 }
# GOOD: Filter at source
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 2 }WMI/CIM Performance
# BAD: Get-WmiObject (deprecated, slower, DCOM)
Get-WmiObject -Class Win32_Process
# GOOD: Get-CimInstance (faster, uses WS-Man/DCOM fallback)
Get-CimInstance -ClassName Win32_Process
# GOOD: CimSession for multiple queries to same server
$session = New-CimSession -ComputerName 'server1'
$procs = Get-CimInstance -CimSession $session -ClassName Win32_Process
$disks = Get-CimInstance -CimSession $session -ClassName Win32_LogicalDisk
$session | Remove-CimSessionRunspaces for Advanced Parallelism
# For high-volume I/O-bound parallelism (hundreds of targets)
$runspacePool = [System.Management.Automation.Runspaces.RunspacePool]::CreateRunspacePool(1, 50)
$runspacePool.Open()
$jobs = foreach ($server in $servers) {
$ps = [powershell]::Create()
$ps.RunspacePool = $runspacePool
[void]$ps.AddScript({
param($srv)
Test-Connection $srv -Count 1 -Quiet
}).AddArgument($server)
@{
PowerShell = $ps
Handle = $ps.BeginInvoke()
Server = $server
}
}
# Collect results
foreach ($job in $jobs) {
$result = $job.PowerShell.EndInvoke($job.Handle)
[PSCustomObject]@{ Server = $job.Server; Online = $result }
$job.PowerShell.Dispose()
}
$runspacePool.Close()
$runspacePool.Dispose()Performance Tips Summary
| Scenario | Recommendation |
|---|---|
| Building large arrays | List[T], not @() += |
| Tight loops | foreach statement, not ForEach-Object |
| Large file reads | [System.IO.File]::ReadAllLines() or StreamReader |
| Filtering collections | Hashtable/HashSet for O(1) lookups |
| CIM/WMI queries | Get-CimInstance, not Get-WmiObject |
| Multiple CIM queries to same server | CimSession |
| Provider-level filtering | Use -Filter parameter, not Where-Object |
| I/O-bound parallel work | Start-ThreadJob or runspaces |
| CPU-bound parallel work | ForEach-Object -Parallel |
| Profiling | Measure-Command |
powershell-expert Research Requirements
Generated: 2026-02-28
Skill Description
'Master PowerShell scripting and Windows system administration for 2026. Enforces cross-platform compatibility (PS 7+), secure credential handling, and high-fidelity automation patterns.'
Research Areas
- Current best practices for powershell-expert
- Industry standards and tooling
- Integration patterns
Source References
- To be populated by skill-updater research phase
PowerShell Security Patterns
Execution Policy
# Check current policy
Get-ExecutionPolicy -List
# Set policy for current user (safe default for developers)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# Common values:
# Restricted - No scripts (Windows client default)
# AllSigned - All scripts must be signed
# RemoteSigned - Downloaded scripts must be signed; local run freely
# Unrestricted - All scripts run (with warning for downloaded)
# Bypass - No restrictions (CI/CD pipelines)
# Bypass for single script (without changing policy)
powershell.exe -ExecutionPolicy Bypass -File .\script.ps1Script Signing
# Get code signing certificate
$cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert
# Sign a script
Set-AuthenticodeSignature -FilePath .\script.ps1 -Certificate $cert
# Verify signature
$sig = Get-AuthenticodeSignature -FilePath .\script.ps1
$sig.Status # Should be 'Valid'Credential Management
Interactive (Never Hardcode)
# Prompt user securely
$cred = Get-Credential -Message 'Enter your credentials'
$securePass = Read-Host -AsSecureString -Prompt 'Password'SecretManagement Module (Production Standard)
# Install
Install-PSResource -Name Microsoft.PowerShell.SecretManagement
Install-PSResource -Name Microsoft.PowerShell.SecretStore # Local vault
# Register vault
Register-SecretVault -Name 'LocalVault' -ModuleName Microsoft.PowerShell.SecretStore
# Store secrets
Set-Secret -Name 'APIKey' -Secret 'your-api-key'
Set-Secret -Name 'DBPassword' -Secret (ConvertTo-SecureString 'pass' -AsPlainText -Force)
# Retrieve secrets
$apiKey = Get-Secret -Name 'APIKey' -AsPlainText
$dbPass = Get-Secret -Name 'DBPassword' # Returns SecureString by default
# List all secrets
Get-SecretInfo
# Remove secret
Remove-Secret -Name 'APIKey'PSCredential Pattern
# Construct from components
$username = 'domain\user'
$securePass = Get-Secret -Name 'MyPassword' # SecureString from vault
$cred = [PSCredential]::new($username, $securePass)
# Use with cmdlets
Connect-AzAccount -Credential $cred
Invoke-Command -ComputerName 'server1' -Credential $cred -ScriptBlock { ... }Avoid Insecure Patterns
# BAD: Plaintext password in code
$password = 'MySecretPassword'
# BAD: ConvertTo-SecureString from plaintext (only for testing)
$secure = ConvertTo-SecureString 'MyPassword' -AsPlainText -Force
# BAD: Invoke-Expression with user input (injection risk)
Invoke-Expression $userInput
# GOOD: Validate and restrict user input
$allowedValues = @('start', 'stop', 'restart')
if ($action -notin $allowedValues) {
throw "Invalid action: $action"
}Constrained Language Mode
# Check language mode
$ExecutionContext.SessionState.LanguageMode
# FullLanguage | ConstrainedLanguage | RestrictedLanguage | NoLanguage
# Constrained mode restricts:
# - Add-Type
# - COM object creation
# - .NET type methods
# - Reflection
# Set constrained mode (system-wide security measure)
# Typically configured via WDAC/AppLocker policiesJust Enough Administration (JEA)
# Create JEA role capability file
New-PSRoleCapabilityFile -Path .\Capabilities\HelpDesk.psrc `
-VisibleCmdlets @(
'Get-Service',
@{ Name = 'Restart-Service'; Parameters = @{ Name = 'Name'; ValidateSet = 'spooler', 'w32time' } }
) `
-VisibleFunctions 'Get-DiskUsage' `
-VisibleProviders 'FileSystem'
# Create JEA session configuration
New-PSSessionConfigurationFile -Path .\SessionConfigs\JEA.pssc `
-SessionType RestrictedRemoteServer `
-RoleDefinitions @{
'DOMAIN\HelpDesk' = @{ RoleCapabilities = 'HelpDesk' }
} `
-RunAsVirtualAccount
# Register JEA endpoint
Register-PSSessionConfiguration -Name 'JEA_HelpDesk' `
-Path .\SessionConfigs\JEA.pssc `
-Force
# Connect to JEA endpoint
Enter-PSSession -ComputerName 'server1' -ConfigurationName 'JEA_HelpDesk'Input Validation Best Practices
function Invoke-SafeCommand {
param(
[Parameter(Mandatory)]
[ValidateSet('start', 'stop', 'restart')]
[string]$Action,
[Parameter(Mandatory)]
[ValidatePattern('^[a-zA-Z0-9_-]+$')] # Allow only safe characters
[string]$ServiceName
)
# Never use: Start-Service $userInput (unvalidated)
# Always validate before using in commands
switch ($Action) {
'start' { Start-Service -Name $ServiceName -ErrorAction Stop }
'stop' { Stop-Service -Name $ServiceName -ErrorAction Stop }
'restart' { Restart-Service -Name $ServiceName -ErrorAction Stop }
}
}Script Security Checklist
- [ ] No hardcoded credentials (use SecretManagement)
- [ ] Input validated before use (ValidateSet, ValidatePattern, ValidateScript)
- [ ] No Invoke-Expression with user input
- [ ] No -AsPlainText except in tests
- [ ] Script signed if distributed
- [ ] #Requires -RunAsAdministrator when needed
- [ ] Sensitive data removed from error messages
- [ ] Transcript logging enabled for audit trails
PowerShell Testing Patterns
Pester 5 (Current Standard)
Installation
Install-PSResource -Name Pester -Repository PSGallery -Scope CurrentUser
Import-Module Pester -MinimumVersion 5.0Test File Structure
# MyFunction.Tests.ps1
BeforeAll {
# Load the function under test
. $PSCommandPath.Replace('.Tests.ps1', '.ps1')
# Or for module functions:
Import-Module "$PSScriptRoot/../MyModule.psm1" -Force
}
Describe 'Function-Name' {
Context 'Normal operation' {
It 'Returns expected output for valid input' {
$result = Function-Name -Parameter 'value'
$result | Should -Be 'expected'
}
It 'Returns PSCustomObject with correct properties' {
$result = Function-Name -Parameter 'value'
$result | Should -BeOfType [PSCustomObject]
$result.Name | Should -Not -BeNullOrEmpty
}
}
Context 'Error handling' {
It 'Throws on null input' {
{ Function-Name -Parameter $null } | Should -Throw
}
It 'Does not throw on invalid but non-null input' {
{ Function-Name -Parameter 'bad' } | Should -Not -Throw
}
}
}Should Assertions
$value | Should -Be 42 # Exact equality
$value | Should -BeExactly 'CaseSensitive' # Case-sensitive equality
$value | Should -BeGreaterThan 0
$value | Should -BeLessThan 100
$value | Should -BeIn @('A', 'B', 'C')
$value | Should -BeNullOrEmpty
$value | Should -Not -BeNullOrEmpty
$value | Should -BeOfType [System.IO.FileInfo]
$value | Should -Match 'pattern' # Regex match
$value | Should -Contain 'item' # Collection contains
{ $block } | Should -Throw
{ $block } | Should -Throw -ErrorId 'ErrorIdValue'
{ $block } | Should -Not -ThrowMocking
Describe 'Function with external dependency' {
BeforeAll {
Mock Get-ChildItem {
[PSCustomObject]@{ Name = 'file.txt'; Length = 1024 }
}
# Mock with parameter filter
Mock Invoke-RestMethod {
@{ Status = 'OK'; Data = 'result' }
} -ParameterFilter { $Uri -like '*api.example.com*' }
}
It 'Calls Get-ChildItem with correct path' {
$result = Get-SomeData -Path 'C:\temp'
Should -Invoke Get-ChildItem -Times 1 -ParameterFilter { $Path -eq 'C:\temp' }
}
It 'Uses mocked data correctly' {
$result = Get-SomeData -Path 'C:\temp'
$result.FileName | Should -Be 'file.txt'
}
}TestDrive and TestRegistry
Describe 'File operations' {
It 'Creates a file in TestDrive' {
$path = Join-Path $TestDrive 'test.txt'
'content' | Out-File $path
Test-Path $path | Should -BeTrue
}
}
Describe 'Registry operations' {
It 'Reads from TestRegistry' {
# TestRegistry is automatically cleaned up
$key = Join-Path $TestRegistry 'HKCU:\Software\Test'
New-Item $key | Out-Null
Test-Path $key | Should -BeTrue
}
}Running Pester Tests
# Run all tests in current directory
Invoke-Pester
# Run specific test file
Invoke-Pester -Path .\tests\MyFunction.Tests.ps1
# Run with detailed output
Invoke-Pester -Path .\tests\ -Output Detailed
# CI mode (throws on failure, NUnit output)
Invoke-Pester -Path .\tests\ -CI
# Run only tests matching a name
Invoke-Pester -Path .\tests\ -TestName '*error handling*'
# Code coverage
Invoke-Pester -Path .\tests\ -CodeCoverage .\src\*.ps1 -CodeCoverageOutputFile coverage.xmlPester Configuration Object
$config = New-PesterConfiguration
$config.Run.Path = '.\tests\'
$config.Output.Verbosity = 'Detailed'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = '.\src\*.ps1'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = 'TestResults.xml'
$config.TestResult.OutputFormat = 'NUnitXml'
Invoke-Pester -Configuration $configPSScriptAnalyzer
Installation
Install-PSResource -Name PSScriptAnalyzer -Repository PSGalleryBasic Usage
# Analyze a single file
Invoke-ScriptAnalyzer -Path .\script.ps1
# Analyze entire module/directory
Invoke-ScriptAnalyzer -Path .\MyModule\ -Recurse
# Analyze with summary
Invoke-ScriptAnalyzer -Path .\MyModule\ -Recurse -ReportSummary
# Return only specific severity
Invoke-ScriptAnalyzer -Path .\script.ps1 -Severity Error, Warning
# Include specific rules only
Invoke-ScriptAnalyzer -Path .\script.ps1 -IncludeRule PSAvoidUsingPlainTextForPassword
# Exclude specific rules
Invoke-ScriptAnalyzer -Path .\script.ps1 -ExcludeRule PSAvoidUsingWriteHostCommon Rules
| Rule | Severity | Description |
|---|---|---|
| PSAvoidUsingCmdletAliases | Warning | Avoid aliases like ls, %, ? |
| PSAvoidUsingPositionalParameters | Warning | Use named parameters |
| PSUseDeclaredVarsMoreThanAssignments | Warning | Variables assigned but not used |
| PSAvoidUsingPlainTextForPassword | Error | Don't use plaintext passwords |
| PSAvoidUsingConvertToSecureStringWithPlainText | Error | Secure string from plaintext |
| PSUseShouldProcessForStateChangingFunctions | Warning | ShouldProcess on verb functions |
| PSUseApprovedVerbs | Warning | Only use approved PowerShell verbs |
| PSAvoidUsingWriteHost | Information | Use Write-Output instead |
| PSAvoidUsingInvokeExpression | Error | Security risk |
PSScriptAnalyzer in CI (GitHub Actions)
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
$results = Invoke-ScriptAnalyzer -Path ./src -Recurse -ReportSummary
$errors = $results | Where-Object Severity -eq 'Error'
if ($errors.Count -gt 0) {
$errors | Format-Table -AutoSize
throw "PSScriptAnalyzer found $($errors.Count) error(s)"
}Settings File
Create .vscode/PSScriptAnalyzerSettings.psd1 or PSScriptAnalyzerSettings.psd1 in module root:
@{
Severity = @('Error', 'Warning')
ExcludeRules = @(
'PSAvoidUsingWriteHost' # Intentional in interactive scripts
)
Rules = @{
PSUseConsistentIndentation = @{
Enable = $true
IndentationSize = 4
}
PSAlignAssignmentStatement = @{
Enable = $true
CheckHashtable = $true
}
}
}TDD Workflow for PowerShell
1. Write failing Pester test first 2. Run: Invoke-Pester -Path .\tests\MyFunction.Tests.ps1 → verify RED 3. Implement minimal function code 4. Run again → verify GREEN 5. Refactor 6. Run PSScriptAnalyzer: Invoke-ScriptAnalyzer -Path .\src\ 7. Fix any analyzer warnings 8. Commit
powershell-expert Rules
Purpose
'Master PowerShell scripting and Windows system administration for 2026. Enforces cross-platform compatibility (PS 7+), secure credential handling, and high-fidelity automation patterns.'
Best Practices
- Prefer PowerShell 7+ syntax for cross-platform (Core) compatibility
- Enforce strict error handling via $ErrorActionPreference = 'Stop'
- Use structured objects (PSCustomObject) rather than parsing strings
- Secure sensitive data using SecretManagement and SecretStore modules
- Place all enforcement rules in .claude/rules/powershell-expert.md
Integration Points
See SKILL.md for complete documentation.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "powershell-expert Input Schema",
"description": "Input validation schema for powershell-expert skill",
"type": "object",
"required": [],
"properties": {},
"additionalProperties": true
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "powershell-expert Output Schema",
"description": "Output validation schema for powershell-expert skill",
"type": "object",
"required": ["success"],
"properties": {
"success": {
"type": "boolean",
"description": "Whether the skill executed successfully"
},
"result": {
"type": "object",
"description": "The skill execution result",
"additionalProperties": true
},
"error": {
"type": "string",
"description": "Error message if execution failed"
}
},
"additionalProperties": true
}
#!/usr/bin/env node
/**
* Powershell Expert - Main Script
* PowerShell scripting, automation, module development, cross-platform execution, Pester testing, PSScriptAnalyzer, and enterprise PowerShell 7+ patterns
*/
const options = Object.fromEntries(
process.argv
.slice(2)
.filter(arg => arg.startsWith('--'))
.map(flag => [flag.replace(/^--/, ''), true])
);
if (options.help) {
console.log('Powershell Expert - Main Script');
process.exit(0);
}
console.warn('WARNING: This skill is currently a scaffold and has no implementation.');
process.exit(1);
powershell-expert Implementation Template
Goal
- Define target outcome and acceptance criteria.
TDD
1. Red 2. Green 3. Refactor
Verification
- lint
- format
- targeted tests