Dynamic Parameters for Context-Aware PowerShell Commands: Build Smarter CLIs
If your PowerShell tools ask users to type magic strings like environment names, regions, or feature flags, youre inviting typos and support tickets. Dynamic parameters let you adapt parameters at runtime, exposing only valid choices pulled from files, APIs, or current system state. Combine DynamicParam with ValidateSet and you get discoverable, predictable input and a cleaner CLI experience.
What Dynamic Parameters Are (and When to Use Them)
A dynamic parameter is created at runtime during command binding. This means you can compute valid values based on the filesystem, network calls, or session state, and then restrict inputs using [ValidateSet()]. Tab completion will surface the computed options, and invalid values are rejected before your command runs. Use dynamic parameters when:
- You need discoverable, current choices (e.g., environments read from
./envfolders). - You want to prevent typos and invalid inputs via
ValidateSet. - Choices depend on context (e.g., Git branch, current Kubernetes context, installed modules, API-provided project IDs).
Prefer traditional static parameters if the values dont change or if you want to accept arbitrary user input. If you need large result sets or looser validation, consider an ArgumentCompleter (dynamic completion) instead of dynamic parameters with ValidateSet.
Implementing Context-Aware Parameters
Baseline pattern
Heres a compact pattern that builds a dynamic -Environment parameter from a folder structure. If nothing is found, it falls back to Dev, Test, Prod.
function Get-ConfigValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Key
)
DynamicParam {
$dict = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
$root = Join-Path -Path (Get-Location) -ChildPath 'env'
$names = @(Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name)
if (-not $names -or $names.Count -eq 0) { $names = @('Dev','Test','Prod') }
$attrs = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
$p = New-Object System.Management.Automation.ParameterAttribute
$p.Mandatory = $true
$p.HelpMessage = 'Choose the target environment'
$attrs.Add($p) | Out-Null
$v = New-Object System.Management.Automation.ValidateSetAttribute($names)
$attrs.Add($v) | Out-Null
$rdp = New-Object System.Management.Automation.RuntimeDefinedParameter('Environment',[string],$attrs)
$dict.Add('Environment',$rdp)
$dict
}
Begin {
$envName = $PSBoundParameters['Environment']
}
Process {
# Replace with real lookup logic
Write-Host ("{0}:{1}" -f $envName, $Key)
}
}
# Usage: Get-ConfigValue -Key 'ApiBase' -Environment DevWhat you get: context-aware params, fewer typos, a cleaner UX, and predictable input. Users never see unavailable options.
Step-by-step breakdown
- Compute choices (
$names): read from disk, API, or state. - Define attributes: build
ParameterAttribute(mandatory, help message, parameter set names if needed) andValidateSetAttributewith the choices. - Create the runtime parameter:
RuntimeDefinedParameter('Environment', [string], $attrs). - Return the dictionary: add the parameter to
$dictand return it fromDynamicParam. - Use the bound value: read
$PSBoundParameters['Environment']inBegin/Process.
Expose choices from files
A practical pattern is to drive environments, regions, or tenants from a repo directory or a config file. You can combine both and maintain a simple fallback:
function Invoke-Deploy {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$Service
)
DynamicParam {
$dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$envs = @()
$dir = Join-Path $PSScriptRoot 'env'
if (Test-Path $dir) {
$envs = Get-ChildItem -Path $dir -Directory -ErrorAction SilentlyContinue | ForEach-Object Name
}
if (-not $envs) {
$cfgPath = Join-Path $PSScriptRoot 'environments.json'
if (Test-Path $cfgPath) {
try { $envs = (Get-Content $cfgPath -Raw | ConvertFrom-Json).environments } catch { }
}
}
if (-not $envs) { $envs = 'Dev','Test','Prod' }
$attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$paramAttr = [System.Management.Automation.ParameterAttribute]@{ Mandatory = $true; HelpMessage = 'Target environment' }
$attrs.Add($paramAttr) | Out-Null
$attrs.Add([System.Management.Automation.ValidateSetAttribute]::new($envs)) | Out-Null
$rdp = [System.Management.Automation.RuntimeDefinedParameter]::new('Environment',[string],$attrs)
$dict.Add('Environment', $rdp)
return $dict
}
Begin { $envName = $PSBoundParameters['Environment'] }
Process {
if ($PSCmdlet.ShouldProcess("$Service in $envName", 'Deploy')) {
Write-Host "Deploying $Service to $envName..."
# ... real deploy
}
}
}Expose choices from an API (with caching)
Network calls inside DynamicParam can slow down every tab completion. Cache results to keep the UX snappy and add timeouts for resilience:
$script:ProjectCache = @{ Items = @(); Expiry = Get-Date 0 }
function Get-Issue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Id
)
DynamicParam {
$now = Get-Date
if ($now -gt $script:ProjectCache.Expiry) {
try {
$response = Invoke-RestMethod -Uri 'https://api.example.com/projects' -TimeoutSec 5 -Method Get
$names = $response | ForEach-Object { $_.name } | Sort-Object -Unique
if ($names) {
$script:ProjectCache.Items = $names
$script:ProjectCache.Expiry = $now.AddMinutes(5)
}
} catch {
# Keep previous cache; if empty, fallback
if (-not $script:ProjectCache.Items) { $script:ProjectCache.Items = @('Default') }
$script:ProjectCache.Expiry = $now.AddMinutes(1)
}
}
$choices = if ($script:ProjectCache.Items) { $script:ProjectCache.Items } else { @('Default') }
$attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attrs.Add([System.Management.Automation.ParameterAttribute]@{ Mandatory = $true; HelpMessage = 'Select project' }) | Out-Null
$attrs.Add([System.Management.Automation.ValidateSetAttribute]::new($choices)) | Out-Null
$rdp = [System.Management.Automation.RuntimeDefinedParameter]::new('Project',[string],$attrs)
$dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$dict.Add('Project',$rdp)
$dict
}
Begin { $project = $PSBoundParameters['Project'] }
Process {
Write-Host "Fetching issue $Id from project $project"
# ... real API call
}
}Expose choices from current state
- Git branch: use
git rev-parse --abbrev-ref HEADto default a parameter or populate a set of branches viagit branch --format="%(refname:short)". - Docker/Kubernetes context: shell out to
docker context ls --format '{{.Name}}'orkubectl config get-contexts -o nameand feed intoValidateSet. - Windows Registry/Services: list keys under
HKLM:\Software\MyApp\Tenantsor read service names viaGet-Serviceand expose only supported ones.
Keep core logic simple
- Do minimal work in
DynamicParam; fetch/calc heavy data once and cache. - In
Begin, read the chosen value from$PSBoundParameters. - Keep execution paths focused; the validation should prevent bad inputs from ever reaching your core logic.
Production Tips: Performance, Security, Testing, Maintainability
Performance and UX
- Cache aggressively: memoize choices in
$script:scope with short expiries (e.g., 1 20 minutes) depending on freshness needs. - Fallbacks: when IO/API fails, keep a safe fallback set (e.g.,
Dev,Test,Prod) so tab completion still works. - Consider ArgumentCompleter: for large choice sets (hundreds+), use dynamic completion instead of
ValidateSetso you dont over-constrain. Example:
Register-ArgumentCompleter -CommandName Invoke-Report -ParameterName Tenant -ScriptBlock {
param($cmdName,$paramName,$wordToComplete,$commandAst,$fakeBoundParameters)
Get-Content tenants.txt | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}With an argument completer, you get helpful suggestions but can still accept values outside the list if needed.
Security best practices
- Sanitize inputs: when populating sets from files or APIs, trim and filter to expected characters (e.g.,
^[A-Za-z0-9_-]+$), ignore empty or suspicious values. - Constrain execution: run network calls with timeouts and explicit methods, e.g.,
-TimeoutSec,-Method Get. - Least privilege: dont require admin unless necessary for data sources like registry hives.
- Fail closed: prefer a known-safe fallback set rather than letting any value through.
Error handling patterns
- Wrap IO/API operations in
try/catchand log viaWrite-VerboseorWrite-Warning, notWrite-Host. - Use
-ErrorAction SilentlyContinueonly where expected; otherwise bubble up meaningful errors. - In unreliable environments, adjust cache expiry and surface a hint when using cached or fallback data (e.g.,
Write-Verbose "Using cached project list").
Testing with Pester
Dynamic parameters are testable. Assert the existence and values of the runtime-defined parameter under different conditions:
Describe 'Get-ConfigValue dynamic params' {
It 'exposes environments from folder' {
# Arrange: create temp env dirs
$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ([guid]::NewGuid()))
Push-Location $tmp.FullName
New-Item -ItemType Directory -Name 'env/Dev' -Force | Out-Null
New-Item -ItemType Directory -Name 'env/Prod' -Force | Out-Null
# Act: discover metadata via reflection
$cmd = Get-Command Get-ConfigValue
$params = $cmd.Parameters
# Assert: ValidateSet contains Dev and Prod (simulate binding by invoking tab completion if needed)
# (In practice, you can call the function and ensure values bind)
Get-ConfigValue -Key ApiBase -Environment Dev | Out-Null
Pop-Location
Remove-Item $tmp.FullName -Recurse -Force
}
}You can also inject test doubles for API calls (wrap HTTP in a helper function and use Mock to return fixtures).
Maintainability and help
- Comment-based help: document what sources feed your dynamic sets and how fallbacks work. Users should know why a value is or isnt available.
- Parameter sets: you can assign
ParameterSetNamedynamically to guide mutually exclusive modes. Example: an-Environmentparam only appears for theDeployset. - Separation of concerns: keep a helper function like
Get-EnvironmentChoicesso yourDynamicParamis declarative and thin.
Putting It Together: A Clean Pattern You Can Reuse
function New-Release {
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName='Standard')]
param(
[Parameter(Mandatory)] [string]$Service,
[Parameter(ParameterSetName='Standard')] [switch]$DryRun
)
DynamicParam {
$choices = Get-EnvironmentChoices -Fallback @('Dev','Test','Prod')
$attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$attrs.Add([System.Management.Automation.ParameterAttribute]@{
Mandatory = $true
HelpMessage = 'Target environment'
ParameterSetName = 'Standard'
}) | Out-Null
$attrs.Add([System.Management.Automation.ValidateSetAttribute]::new($choices)) | Out-Null
$rdp = [System.Management.Automation.RuntimeDefinedParameter]::new('Environment',[string],$attrs)
$dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
$dict.Add('Environment',$rdp)
$dict
}
Begin {
$envName = $PSBoundParameters['Environment']
}
Process {
if ($PSCmdlet.ShouldProcess("$Service@$envName", 'Create release')) {
# core logic is simple because inputs are already validated
Write-Host "Creating release for $Service in $envName" -ForegroundColor Cyan
# ... do work
}
}
}
function Get-EnvironmentChoices {
param([string[]]$Fallback)
$dir = Join-Path $PSScriptRoot 'env'
$envs = @()
if (Test-Path $dir) {
$envs = Get-ChildItem -Path $dir -Directory -ErrorAction SilentlyContinue | ForEach-Object Name
}
if (-not $envs) { $envs = $Fallback }
return $envs | Sort-Object -Unique
}With this approach, youll build PowerShell CLIs that adapt to their environment, offer clear and correct choices, and keep your core logic lean. Start small by surfacing environments from folders, then grow into API-backed sets with caching and guardrails. Your users will stop guessing, and your scripts will stop babysitting bad input.