Smarter Tab Completion in PowerShell: ArgumentCompleter + CompletionResult for Dynamic, Discoverable CLIs
Hard-to-remember parameters slow you down and cause typos. PowerShell's ArgumentCompleter gives you smarter tab completion: you can dynamically suggest values from files, APIs, or current state; return precise labels and tooltips via CompletionResult; and keep it scoped to a single command for predictable behavior across sessions. In this guide, you will build friendly, context-aware completions that make your CLI faster and easier to use.
Why ArgumentCompleter Makes Your CLI Friendlier
Great tab completion does more than fill text. It teaches usage by showing you the right choices at the right time.
- Discoverability: Turn obscure inputs into readable, searchable menus.
- Fewer typos: Suggest only valid values and filter as you type.
- Speed: Jump straight to real values pulled from files, APIs, or runtime state.
- Predictability: Keep completion local to a command so it behaves the same way every session and does not leak globally.
- Clarity: Use
CompletionResultlabels and tooltips to display IDs and human-readable titles together.
Implementing Smarter Tab Completion
Basic file-backed completion (local to a command)
This example completes the -Name parameter from subdirectories under a local projects folder. It returns CompletionResult objects with a clear tooltip.
function Get-Project {
param([string]$Name)
Write-Host ('Selected project: {0}' -f $Name)
}
Register-ArgumentCompleter -CommandName Get-Project -ParameterName Name -ScriptBlock {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
$root = Join-Path -Path (Get-Location) -ChildPath 'projects'
$names = @(Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name)
$names |
Where-Object { $_ -like "$wordToComplete*" } |
ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_, # CompletionText (what gets inserted)
$_, # ListItemText (what you see in the menu)
'ParameterValue', # ResultType
('Project {0}' -f $_) # ToolTip
)
}
}
# Usage
# Get-Project -Name <Tab>Because the completer is registered for a specific command and parameter, you avoid cross-command interference and keep behavior consistent.
Context-aware completion using other parameter values
Use $fakeBoundParameters to vary suggestions based on what is already bound. Below, -Name suggestions depend on the chosen -Vault:
function Get-Secret {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Vault,
[ArgumentCompleter({
param($c, $p, $w, $ast, $fbp)
$v = $fbp['Vault']
if (-not $v) { return }
$names = switch ($v) {
'dev' { 'alpha','beta','gamma' }
'prod' { 'payments','billing','identity' }
default { @() }
}
$names |
Where-Object { $_ -like "$w*" } |
ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
('Secret in {0}' -f $v)
)
}
})]
[string]$Name
)
}
# Get-Secret -Vault prod -Name <Tab> # suggests payments, billing, identityUsing the [ArgumentCompleter()] attribute keeps the completer tightly scoped to a single parameter on a single command, making behavior predictable across machines and sessions when you distribute the function (for example, as part of a module).
Dynamic API-backed completion with caching
Fetching data from a remote API on every keystroke is slow. Cache results briefly and provide fast suggestions, with labels for readability and tooltips for more detail. This example completes issue IDs for a hypothetical Get-Issue -Id command, labeling each entry with its issue number and showing the title in the tooltip.
# simple cache in script scope
$script:IssueCache = [ordered]@{ Data = @(); Fetched = Get-Date '2000-01-01' }
function Get-Issue {
param([int]$Id)
Write-Host ('Selected issue: #{0}' -f $Id)
}
Register-ArgumentCompleter -CommandName Get-Issue -ParameterName Id -ScriptBlock {
param($c, $p, $w, $ast, $fbp)
$stale = (New-TimeSpan -Start $script:IssueCache.Fetched -End (Get-Date)).TotalSeconds -gt 60
if ($stale) {
try {
$repo = 'owner/name'
$uri = ('https://api.github.com/repos/{0}/issues?state=open' -f $repo)
$resp = Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'pwsh-completer' } -ErrorAction Stop
$script:IssueCache.Data = $resp |
Select-Object -First 50 |
ForEach-Object {
[pscustomobject]@{ Id = $_.number; Title = $_.title }
}
$script:IssueCache.Fetched = Get-Date
} catch {
# swallow errors to avoid blocking completion
}
}
$items = $script:IssueCache.Data
$items |
Where-Object { $_.Id.ToString() -like "$w*" -or $_.Title -like "*$w*" } |
ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Id.ToString(), # insert the numeric ID
('#{0}' -f $_.Id), # show a friendly list label
'ParameterValue',
$_.Title # tooltip shows the issue title
)
}
}
# Get-Issue -Id <Tab> # shows #123, #124 ... with tooltipsTest your completers without smashing Tab
You can programmatically inspect what your completer returns using TabExpansion2:
TabExpansion2 -inputScript 'Get-Project -Name pr' -cursorColumn 22 |
Select-Object -ExpandProperty CompletionMatches |
Select-Object -First 5 |
Format-Table ListItemText, CompletionText, ToolTipDesigning High-Quality Suggestions (labels, tooltips, predictability)
[System.Management.Automation.CompletionResult] has four key fields:
- CompletionText: The exact text inserted into the command line.
- ListItemText: The label shown in the menu.
- ResultType: Usually
ParameterValuefor parameter values; you can also useTextfor freeform text. - ToolTip: Supplementary info (descriptions, paths, IDs) shown in the suggestion UI.
Use this to separate IDs from human-friendly labels. For example, insert a project key, but show a labeled item with a readable name and a descriptive tooltip:
$projects = @(
[pscustomobject]@{ Key = 'proj-a'; Name = 'Project Apollo'; Desc = 'Critical path' },
[pscustomobject]@{ Key = 'proj-b'; Name = 'Project Borealis'; Desc = 'Backend services' }
)
$projects | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, # CompletionText to insert
('{0} ({1})' -f $_.Key, $_.Name), # ListItemText to display
'ParameterValue',
$_.Desc # ToolTip
)
}Best practices
- Keep it fast: Aim for under 100 ms. Cache API results for several seconds. Bail out early if
$wordToCompleteis too short (for example, require at least 2 characters before hitting an API). - Filter smartly: Use
-likepatterns for prefix matches and combine fields for discoverability (search ID or title). - Be resilient: Wrap I/O in
try/catch, use-ErrorAction SilentlyContinue, and return an empty sequence on failure so tab completion never hangs. - Scope locally: Register per command and parameter. Package registration with your function or module so behavior is consistent across environments.
- Do not leak secrets: Never place secrets or internal URLs in tooltips or labels.
- Cross-platform paths: Use
Join-Pathand avoid hardcoded separators. - Test programmatically: Use
TabExpansion2to validateListItemText,CompletionText, andToolTip.
Quick pattern: lightweight prefix gating
Register-ArgumentCompleter -CommandName Find-Item -ParameterName Query -ScriptBlock {
param($c,$p,$w,$ast,$fbp)
if (($w ?? '').Length -lt 2) { return } # require 2 chars before heavy work
# fetch or search here...
}With these patterns, you transform hard-to-remember inputs into discoverable choices backed by your real data sources. The result is fewer typos, faster input, and a friendlier CLI that teaches itself through great tab completion.