completers/apm_completer/apm_completer.ps1

<#
.SYNOPSIS
Registers native PowerShell completion for apm and apm.exe.

.DESCRIPTION
The completer is static-first and is derived from the official APM CLI
reference plus the upstream microsoft/apm click command definitions for
documented enum values and aliases. When apm is installed, each command's
live '--help' page is parsed once per session and merged over the static
table, so subcommands and options added by newer releases still complete.
#>


Set-StrictMode -Version Latest

if (-not (Get-Variable -Name ApmLiveHelpCache -Scope Script -ErrorAction Ignore)) {
    $script:ApmLiveHelpCache = @{
        ExecutableProbed = $false
        Executable       = $null
        Stamp            = $null
        Entries          = @{}
    }
}

function New-ApmCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ToolTip,
        [System.Management.Automation.CompletionResultType]$ResultType = [System.Management.Automation.CompletionResultType]::ParameterValue
    )

    [System.Management.Automation.CompletionResult]::new(
        $CompletionText,
        $CompletionText,
        $ResultType,
        $ToolTip
    )
}

function Test-ApmStartsWith {
    param(
        [string]$Candidate,
        [string]$Prefix
    )

    if ([string]::IsNullOrEmpty($Prefix)) {
        return $true
    }

    $Candidate.StartsWith($Prefix, [System.StringComparison]::OrdinalIgnoreCase)
}

function Test-ApmPathLikeToken {
    param([string]$Token)

    if ([string]::IsNullOrWhiteSpace($Token)) {
        return $false
    }

    if ($Token -match '^[a-z][a-z0-9+\-.]*://') {
        return $false
    }

    $Token -match '^(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\|[\\/])'
}

function Get-ApmPathCompletions {
    param(
        [string]$PathPrefix,
        [string]$AttachedPrefix = '',
        [string]$Placeholder = '<path>'
    )

    $results = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new()

    foreach ($item in [System.Management.Automation.CompletionCompleters]::CompleteFilename($PathPrefix)) {
        $completionText = if ([string]::IsNullOrEmpty($AttachedPrefix)) {
            $item.CompletionText
        }
        else {
            "$AttachedPrefix$($item.CompletionText)"
        }

        $results.Add(
            [System.Management.Automation.CompletionResult]::new(
                $completionText,
                $completionText,
                $item.ResultType,
                $completionText
            )
        )
    }

    if ($results.Count -eq 0 -and -not [string]::IsNullOrEmpty($Placeholder)) {
        $placeholderText = if ([string]::IsNullOrEmpty($AttachedPrefix)) {
            $Placeholder
        }
        else {
            "$AttachedPrefix$Placeholder"
        }

        $results.Add((New-ApmCompletionResult -CompletionText $placeholderText -ToolTip $placeholderText))
    }

    $results
}

function Get-ApmClosedValueCompletions {
    param(
        [string[]]$Values,
        [string]$WordToComplete,
        [string]$AttachedPrefix = '',
        [string]$ToolTipPrefix = ''
    )

    foreach ($value in $Values) {
        $completionText = if ([string]::IsNullOrEmpty($AttachedPrefix)) { $value } else { "$AttachedPrefix$value" }
        if (-not (Test-ApmStartsWith -Candidate $completionText -Prefix $WordToComplete)) {
            continue
        }

        $toolTip = if ([string]::IsNullOrEmpty($ToolTipPrefix)) { $value } else { "$ToolTipPrefix$value" }
        New-ApmCompletionResult -CompletionText $completionText -ToolTip $toolTip
    }
}

function Get-ApmFreeformValueCompletions {
    param(
        [string[]]$SuggestedValues,
        [string]$WordToComplete,
        [string]$AttachedPrefix = '',
        [string]$ToolTipPrefix = ''
    )

    $matched = @(
        Get-ApmClosedValueCompletions -Values $SuggestedValues -WordToComplete $WordToComplete -AttachedPrefix $AttachedPrefix -ToolTipPrefix $ToolTipPrefix
    )

    if ($matched.Count -gt 0 -or [string]::IsNullOrEmpty($WordToComplete)) {
        return $matched
    }

    $typedValue = if ([string]::IsNullOrEmpty($AttachedPrefix)) {
        $WordToComplete
    }
    else {
        "$AttachedPrefix$WordToComplete"
    }

    New-ApmCompletionResult -CompletionText $typedValue -ToolTip $typedValue
}

function Get-ApmListValueCompletion {
    # Completes the segment after the last comma of a comma-separated value.
    # PowerShell parses an unquoted a,b,c as an array literal: $WordToComplete
    # is then only the last segment and only that segment is replaced, while
    # the native command still receives 'a,b,c' as one argument. A quoted word
    # arrives whole with its opening quote, so the earlier segments and the
    # quote are kept as the prefix.
    param(
        [string[]]$Values,
        [string]$WordToComplete,
        [string]$AttachedPrefix = '',
        [string]$ElementText = ''
    )

    $quote = ''
    $word = $WordToComplete
    if ($word.Length -gt 0 -and ($word[0] -eq "'" -or $word[0] -eq '"')) {
        # PowerShell closes an unterminated quote in $WordToComplete itself.
        $quote = [string]$word[0]
        $word = $word.Substring(1)
        if ($word.EndsWith($quote)) {
            $word = $word.Substring(0, $word.Length - 1)
        }
    }

    $emitPrefix = ''
    if (-not [string]::IsNullOrEmpty($AttachedPrefix) -and $word.StartsWith($AttachedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
        $emitPrefix = $word.Substring(0, $AttachedPrefix.Length)
        $word = $word.Substring($AttachedPrefix.Length)
    }

    $lastComma = $word.LastIndexOf(',')
    $committed = $word.Substring(0, $lastComma + 1)
    $fragment = $word.Substring($lastComma + 1)

    $source = if ([string]::IsNullOrEmpty($ElementText)) { $WordToComplete } else { $ElementText }
    $source = $source -replace '^[''"]', '' -replace '^-{1,2}[^=,]+=', ''
    $chosen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($segment in @($source -split ',' | Select-Object -SkipLast 1)) {
        [void]$chosen.Add($segment.Trim())
    }

    foreach ($value in $Values) {
        if ($chosen.Contains($value) -or -not (Test-ApmStartsWith -Candidate $value -Prefix $fragment)) {
            continue
        }

        New-ApmCompletionResult -CompletionText "$quote$emitPrefix$committed$value$quote" -ToolTip $value
    }
}

function Get-ApmCompletionCatalog {
    $existingCatalog = Get-Variable -Name ApmCompletionCatalog -Scope Script -ErrorAction Ignore
    if ($null -ne $existingCatalog) {
        return $existingCatalog.Value
    }

    $newCommand = {
        param([string]$Name, [string]$Description)
        [pscustomobject]@{
            Name        = $Name
            Description = $Description
        }
    }

    $newOption = {
        param(
            [string[]]$Tokens,
            [string]$Description,
            [string]$ValueKind = 'flag',
            [string[]]$Values = @(),
            [switch]$OptionalValue
        )

        foreach ($token in $Tokens) {
            [pscustomobject]@{
                Token         = $token
                Description   = $Description
                ValueKind     = $ValueKind
                Values        = @($Values)
                OptionalValue = [bool]$OptionalValue
            }
        }
    }

    # apm 0.33.0: --target and --runtime share one 21-entry harness list.
    $targetValues = @(
        'agent-skills', 'agents', 'agy', 'all', 'antigravity', 'claude', 'codex', 'copilot',
        'copilot-app', 'copilot-cowork', 'cursor', 'gemini', 'grok-build', 'grok-cloud',
        'hermes', 'intellij', 'kiro', 'openclaw', 'opencode', 'vscode', 'windsurf'
    )
    $installRuntimeValues = $targetValues
    $auditFormatValues = @('text', 'json', 'sarif', 'markdown')
    $configKeyValues = @('auto-integrate', 'temp-dir')
    $booleanValues = @('true', 'false', 'yes', 'no', '1', '0')
    $runtimeValues = @('copilot', 'codex', 'gemini', 'llm')
    $countValues = @('0', '1', '4', '8', '10', '20', '50')
    $transportValues = @('stdio', 'http', 'sse', 'streamable-http')
    $packFormatValues = @('plugin', 'agent-plugin', 'claude', 'claude-plugin', 'apm')
    $packMarketplaceValues = @('all', 'none', 'claude', 'codex')
    $lifecycleEventValues = @('pre-install', 'post-install', 'pre-update', 'post-update', 'pre-uninstall', 'post-uninstall')

    $catalog = [ordered]@{}

    $catalog[''] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'init' 'Initialize new APM project'
            & $newCommand 'install' 'Install APM, MCP, and LSP dependencies'
            & $newCommand 'uninstall' 'Remove packages using manifest entries or direct locked refs'
            & $newCommand 'prune' 'Remove APM packages absent from the resolved dependency set'
            & $newCommand 'audit' 'Scan installed primitives for hidden Unicode, drift, and lockfile issues'
            & $newCommand 'auth' 'Get a working credential for a git host'
            & $newCommand 'pack' 'Pack distributable artifacts from your APM project'
            & $newCommand 'unpack' '[Deprecated] Extract an APM bundle into the current project'
            & $newCommand 'update' 'Refresh APM dependencies to the latest matching refs'
            & $newCommand 'self-update' 'Update the APM CLI binary itself to the latest version'
            & $newCommand 'view' 'View package metadata or list remote versions'
            & $newCommand 'outdated' 'Show outdated locked dependencies'
            & $newCommand 'deps' 'Manage APM package dependencies'
            & $newCommand 'mcp' 'Discover, inspect, and install MCP servers'
            & $newCommand 'marketplace' 'Manage marketplaces for discovery and governance'
            & $newCommand 'search' 'Search plugins in a marketplace (QUERY@MARKETPLACE)'
            & $newCommand 'run' 'Run a script with parameters (experimental)'
            & $newCommand 'preview' 'Preview a script''s compiled prompt files'
            & $newCommand 'list' 'List available scripts in the current project'
            & $newCommand 'compile' 'Compile APM context into distributed AGENTS.md files'
            & $newCommand 'config' 'Configure APM CLI'
            & $newCommand 'runtime' 'Manage AI runtimes (experimental)'
            & $newCommand 'approve' 'Approve package executables'
            & $newCommand 'deny' 'Deny package executables'
            & $newCommand 'cache' 'Manage the local package cache'
            & $newCommand 'doctor' 'Run environment diagnostics (git, network, auth, marketplace config)'
            & $newCommand 'experimental' 'Manage experimental feature flags'
            & $newCommand 'find' 'Find which package owns a file path'
            & $newCommand 'lifecycle' 'Inspect, test, and scaffold lifecycle scripts'
            & $newCommand 'lock' 'Resolve dependencies and write apm.lock.yaml without deploying'
            & $newCommand 'plugin' 'Scaffold and manage plugins (plugin-author workflows)'
            & $newCommand 'policy' 'Inspect and diagnose APM policy'
            & $newCommand 'publish' 'Publish a package to a registry'
            & $newCommand 'targets' 'Show resolved targets for the current project'
            & $newCommand 'info' 'Hidden alias for view'
        )
        Options = @(
            & $newOption @('--version') 'Show version and exit'
            & $newOption @('--verbose', '-v') 'Enable debug-level logging'
            & $newOption @('--help') 'Show help message and exit'
        )
    }

    $catalog['approve'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--pending') 'List packages with unapproved executables'
            & $newOption @('--all') 'Approve every package with executables'
            & $newOption @('--recommended') 'Approve the org-recommended executable set'
            & $newOption @('--list') 'List effective trust decisions'
            & $newOption @('--user') 'Persist to your personal ~/.apm/config.json instead of apm.yml'
        )
    }

    $catalog['deny'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--user') 'Record the deny in your personal ~/.apm/config.json instead of apm.yml'
        )
    }

    $catalog['auth'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--check') 'Validate the token against the host''s REST API (one network call)'
            & $newOption @('--export') 'Print "export VAR=token" on stdout for eval'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['cache'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'clean' 'Remove all cached content'
            & $newCommand 'info' 'Show cache location and size statistics'
            & $newCommand 'prune' 'Remove Git checkout SHA groups older than N days'
        )
        Options = @()
    }

    $catalog['cache clean'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--force', '-f') 'Skip confirmation prompt'
            & $newOption @('--yes', '-y') 'Skip confirmation prompt'
        )
    }

    $catalog['cache info'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['cache prune'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--days') 'Remove SHA groups not accessed within this many days' 'freeform' @('7', '30', '90')
        )
    }

    $catalog['doctor'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['experimental'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'disable' 'Disable an experimental feature'
            & $newCommand 'enable' 'Enable an experimental feature'
            & $newCommand 'list' 'List all experimental features'
            & $newCommand 'reset' 'Reset experimental features to defaults'
        )
        Options = @(
            & $newOption @('--verbose', '-v') 'Show verbose output'
        )
    }

    $catalog['experimental disable'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['experimental enable'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['experimental list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--enabled') 'Show only enabled features'
            & $newOption @('--disabled') 'Show only disabled features'
            & $newOption @('--verbose', '-v') 'Show detailed output'
            & $newOption @('--json') 'Output as JSON array'
        )
    }

    $catalog['experimental reset'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Skip confirmation prompt'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['find'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--source') 'Append resolved origin (oci/git/local) to each package name'
            & $newOption @('--path') 'Print full root-to-target dependency chain (like apm deps why)'
        )
    }

    $catalog['lifecycle'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'init' 'Inject a starter lifecycle: block into apm.yml'
            & $newCommand 'test' 'Dry-run a synthetic lifecycle event through discovered scripts'
            & $newCommand 'trust' 'Trust the project apm.yml lifecycle: block so its scripts run'
            & $newCommand 'untrust' 'Revoke trust for the project apm.yml lifecycle: block'
            & $newCommand 'validate' 'Validate all discovered script files for errors'
        )
        Options = @()
    }

    $catalog['lifecycle init'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--force') 'Overwrite existing lifecycle: block if present'
        )
    }

    $catalog['lifecycle test'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
            & $newOption @('--execute') 'Actually run the scripts (default is a non-executing dry-run)'
        )
    }

    $catalog['lifecycle trust'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['lifecycle untrust'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['lifecycle validate'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['lock'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'export' 'Export an SBOM/inventory from the existing lockfile'
        )
        Options = @(
            & $newOption @('--verbose', '-v') 'Show per-dependency resolution details'
            & $newOption @('--global', '-g') 'Operate on ~/.apm/apm.yml instead of the current project'
            & $newOption @('--update') 'Re-resolve refs to their latest SHAs before writing the lockfile'
            & $newOption @('--no-policy') 'Skip policy enforcement during resolution'
            & $newOption @('--target', '-t') 'Agent target(s) to scope policy enforcement during resolution' 'list' $targetValues
            & $newOption @('--parallel-downloads') 'Max concurrent package downloads (0 to disable parallelism)' 'freeform' $countValues
        )
    }

    $catalog['lock export'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--format', '-f') 'SBOM output format' 'enum' @('cyclonedx', 'spdx')
            & $newOption @('--output', '-o') 'Write the SBOM to a file instead of stdout' 'path'
            & $newOption @('--global', '-g') 'Read the user-scope (~/.apm/) lockfile'
            & $newOption @('--timestamp') 'Pin the SBOM timestamp (ISO 8601)' 'freeform' @('<iso-8601>')
        )
    }

    $catalog['plugin'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'init' 'Scaffold a plugin project (creates plugin.json + apm.yml)'
        )
        Options = @()
    }

    $catalog['plugin init'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Skip interactive prompts and use auto-detected defaults'
            & $newOption @('--target') 'Comma-separated target list (skip prompt)' 'list' $targetValues
            & $newOption @('--format') 'Plugin layout' 'enum' @('plugin', 'agent-plugin', 'claude', 'claude-plugin')
            & $newOption @('--claude-plugin') 'Scaffold the legacy Claude-compatible layout'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['policy'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'explain' 'Explain the effective executable-trust decision for a package'
            & $newCommand 'status' 'Show the current policy posture (discovery, cache, rules)'
        )
        Options = @()
    }

    $catalog['policy explain'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['policy status'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--policy-source') 'Override discovery (org, URL, or path)' 'policy'
            & $newOption @('--no-cache') 'Force a fresh fetch (skip the policy cache)'
            & $newOption @('--json') 'Emit the report as JSON (alias of -o json)'
            & $newOption @('--output', '-o') 'Output format' 'enum' @('table', 'json')
            & $newOption @('--check') 'Exit non-zero (1) when no usable policy is found'
        )
    }

    $catalog['publish'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--registry') 'Registry name (from apm.yml registries: block)' 'freeform' @('<registry>')
            & $newOption @('--package') 'Package identity to publish as (owner/repo)' 'freeform' @('<owner/repo>')
            & $newOption @('--zip') 'Path to a pre-built .zip archive (skips the pack step)' 'path'
            & $newOption @('--dry-run') 'Preview without uploading'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['targets'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--json') 'Output as JSON instead of a table'
            & $newOption @('--all') 'Include the agent-skills meta-target in JSON output'
        )
    }

    $catalog['init'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--discover') 'Inventory existing local packages without writes'
            & $newOption @('--apply', '--write') 'Add existing local package references to consumer apm.yml; never install'
            & $newOption @('--format') 'Discovery report format' 'enum' @('text', 'json', 'yaml')
            & $newOption @('--global', '-g') 'Discover under home and declare packages in ~/.apm/apm.yml'
            & $newOption @('--yes', '-y') 'Skip interactive prompts and use auto-detected defaults'
            & $newOption @('--plugin') 'Initialize as a plugin authoring project'
            & $newOption @('--marketplace') '(deprecated) Use apm marketplace init instead'
            & $newOption @('--target') 'Comma-separated target list (skip prompt, write directly)' 'list' $targetValues
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['install'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--runtime') 'Target specific runtime only' 'enum' $installRuntimeValues
            & $newOption @('--exclude') 'Exclude specific runtime from installation' 'enum' $installRuntimeValues
            & $newOption @('--only') 'Install only specific dependency type' 'enum' @('apm', 'mcp')
            & $newOption @('--target', '-t') 'Target harness(es) to deploy to (comma-separated)' 'list' $targetValues
            & $newOption @('--update') 'Update dependencies to latest Git references'
            & $newOption @('--force') 'Overwrite locally-authored files on collision'
            & $newOption @('--dry-run') 'Show what would be installed without installing'
            & $newOption @('--frozen') 'Refuse to install when apm.lock.yaml is missing or out of sync (CI-safe)'
            & $newOption @('--parallel-downloads') 'Max concurrent package downloads' 'freeform' $countValues
            & $newOption @('--verbose', '-v') 'Show detailed installation information'
            & $newOption @('--trust-transitive-mcp') 'Trust self-defined MCP servers from transitive packages'
            & $newOption @('--dev') 'Install as development dependency'
            & $newOption @('--allow-insecure') 'Allow HTTP (insecure) dependencies'
            & $newOption @('--allow-insecure-host') 'Allow transitive HTTP dependencies from this hostname (repeatable)' 'freeform' @('<hostname>')
            & $newOption @('--global', '-g') 'Install to user scope instead of the current project'
            & $newOption @('--ssh') 'Prefer SSH transport for shorthand (owner/repo) dependencies'
            & $newOption @('--https') 'Prefer HTTPS transport for shorthand (owner/repo) dependencies'
            & $newOption @('--allow-protocol-fallback') 'Restore the legacy permissive cross-protocol fallback chain'
            & $newOption @('--mcp') 'Add an MCP server entry to apm.yml' 'freeform' @('<name>')
            & $newOption @('--transport') 'MCP transport for --mcp entries' 'enum' $transportValues
            & $newOption @('--url') 'MCP server URL for remote transports (requires --mcp)' 'freeform' @('https://<host>/mcp')
            & $newOption @('--env') 'Environment variable for stdio MCP, repeatable (requires --mcp)' 'freeform' @('<KEY=VALUE>')
            & $newOption @('--header') 'HTTP header for remote MCP, repeatable (requires --mcp and --url)' 'freeform' @('<KEY=VALUE>')
            & $newOption @('--mcp-version') 'Pin MCP registry entry to a specific version (requires --mcp)' 'freeform' @('<version>')
            & $newOption @('--registry') 'MCP registry URL for resolving --mcp NAME' 'freeform' @('https://<registry>')
            & $newOption @('--skill') 'Install only named skill(s) from a SKILL_BUNDLE (repeatable)' 'freeform' @('<name>')
            & $newOption @('--no-policy') 'Skip org policy enforcement for this invocation'
            & $newOption @('--audit') 'Run apm audit over deployed files during install' 'enum' @('off', 'warn', 'block')
            & $newOption @('--no-audit') 'Disable the install-time audit for this invocation'
            & $newOption @('--refresh') 'Re-fetch all dependencies from upstream and re-resolve all ref pins'
            & $newOption @('--legacy-skill-paths') 'Deploy skill files to per-client paths instead of .agents/skills/'
            & $newOption @('--as') 'Override the display label when installing a local bundle' 'freeform' @('<alias>')
            & $newOption @('--trust-bin') 'Deploy bin/ executables for this invocation'
            & $newOption @('--no-trust-bin') 'Skip bin/ executable deployment for this invocation'
            & $newOption @('--root') 'Install into DIR instead of the current directory' 'path'
        )
    }

    $catalog['uninstall'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--dry-run') 'Show what would be removed without removing'
            & $newOption @('--verbose', '-v') 'Show detailed removal information'
            & $newOption @('--global', '-g') 'Remove from user scope instead of the current project'
        )
    }

    $catalog['prune'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--dry-run') 'Show what would be removed without removing'
        )
    }

    $catalog['audit'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--file') 'Scan an arbitrary file instead of installed packages' 'path'
            & $newOption @('--strip') 'Remove dangerous characters while preserving info-level content'
            & $newOption @('--dry-run') 'Preview what would be removed without modifying files'
            & $newOption @('--verbose', '-v') 'Show info-level findings and file details'
            & $newOption @('--format', '-f') 'Output format' 'enum' $auditFormatValues
            & $newOption @('--output', '-o') 'Write report to file' 'path'
            & $newOption @('--ci') 'Run lockfile consistency checks for CI/CD gates'
            & $newOption @('--policy') 'Policy source for CI checks' 'policy'
            & $newOption @('--no-cache') 'Force fresh policy fetch'
            & $newOption @('--no-policy') 'Skip org policy discovery and enforcement'
            & $newOption @('--no-fail-fast') 'Run all checks even after a failure'
            & $newOption @('--no-drift') 'Skip the install-replay drift check'
            & $newOption @('--external') 'Ingest findings from an external SARIF-native scanner (repeatable)' 'enum' @('skillspector', 'sarif')
            & $newOption @('--external-sarif') 'SARIF file to ingest for --external sarif' 'path'
            & $newOption @('--external-llm') 'Force LLM-powered analysis on for external scanners'
            & $newOption @('--no-external-llm') 'Force LLM-powered analysis off for external scanners'
            & $newOption @('--external-args') 'Extra argv tokens for external scanners this run' 'freeform' @('<args>')
        )
    }

    $catalog['pack'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--output', '-o') 'Output directory' 'path'
            & $newOption @('--target', '-t') 'Filter files by target' 'enum' $targetValues
            & $newOption @('--archive') 'Produce an archive (.zip by default) instead of a directory'
            & $newOption @('--archive-format') 'Archive format when --archive is set' 'enum' @('zip', 'tar.gz')
            & $newOption @('--dry-run') 'List files that would be packed without writing anything'
            & $newOption @('--format') 'Bundle format' 'enum' $packFormatValues
            & $newOption @('--claude-plugin') 'Select the legacy Claude plugin bundle output'
            & $newOption @('--force') 'On collision, last writer wins instead of first'
            & $newOption @('--verbose', '-v') 'Show detailed packing information'
            & $newOption @('--offline') 'Marketplace: use cached refs, skip network'
            & $newOption @('--include-prerelease') 'Marketplace: include pre-release version tags'
            & $newOption @('--check-versions') 'Release gate: verify per-package versions agree with the versioning strategy'
            & $newOption @('--check-clean') 'Release gate: diff regenerated marketplace outputs against disk'
            & $newOption @('--strict-metadata') 'Marketplace: fail when remote metadata cannot be fetched'
            & $newOption @('--marketplace', '-m') 'Comma-separated marketplace outputs to build' 'list' $packMarketplaceValues
            & $newOption @('--marketplace-path') 'Override output path for a format (repeatable)' 'freeform' @('<FORMAT=PATH>')
            & $newOption @('--json') 'Emit machine-readable JSON to stdout'
            & $newOption @('--legacy-skill-paths') 'Deploy skill files to per-client paths instead of .agents/skills/'
        )
    }

    $catalog['unpack'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--output', '-o') 'Target project directory' 'path'
            & $newOption @('--skip-verify') 'Skip completeness verification against the bundle lockfile'
            & $newOption @('--force') 'Deploy despite critical hidden-character findings'
            & $newOption @('--dry-run') 'Show what would be extracted without writing anything'
            & $newOption @('--verbose', '-v') 'Show detailed unpacking information'
        )
    }

    $catalog['update'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Skip the confirmation prompt (for CI / automation)'
            & $newOption @('--dry-run') 'Render the update plan and exit without changing anything'
            & $newOption @('--verbose', '-v') 'Show unchanged deps and detailed pipeline diagnostics'
            & $newOption @('--global', '-g') 'Refresh user-scope dependencies (~/.apm/) instead of the current project'
            & $newOption @('--force') 'Overwrite locally-authored files and deploy despite critical security findings'
            & $newOption @('--parallel-downloads') 'Max concurrent package downloads (0 to disable parallelism)' 'freeform' $countValues
            & $newOption @('--target', '-t') 'Agent target(s) to update for (comma-separated for multiple)' 'list' $targetValues
        )
    }

    $catalog['self-update'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--check') 'Only check for updates without installing'
        )
    }

    $catalog['view'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'Inspect package from user scope'
            & $newOption @('--registry') 'List versions from a registry (name one, or a full git URL)' 'freeform' @('<registry>') -OptionalValue
        )
    }

    $catalog['info'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'Inspect package from user scope'
            & $newOption @('--registry') 'List versions from a registry (name one, or a full git URL)' 'freeform' @('<registry>') -OptionalValue
        )
    }

    $catalog['outdated'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'Check user-scope dependencies'
            & $newOption @('--verbose', '-v') 'Show extra detail for outdated packages'
            & $newOption @('--parallel-checks', '-j') 'Max concurrent remote checks' 'freeform' $countValues
        )
    }

    $catalog['deps'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'list' 'List installed APM dependencies'
            & $newCommand 'tree' 'Show dependency tree structure'
            & $newCommand 'info' 'Alias for apm view'
            & $newCommand 'clean' 'Remove all APM dependencies'
            & $newCommand 'update' 'DEPRECATED: use apm update instead (strict superset)'
            & $newCommand 'why' 'Explain why a package is installed by walking the lockfile back to its roots'
        )
        Options = @()
    }

    $catalog['deps why'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'Resolve against the user-scope lockfile'
            & $newOption @('--json') 'Emit machine-readable JSON to stdout'
        )
    }

    $catalog['deps list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'List user-scope packages instead of the current project'
            & $newOption @('--all') 'List packages from both project and user scope'
            & $newOption @('--insecure') 'Show only installed dependencies locked to http:// sources'
        )
    }

    $catalog['deps tree'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--global', '-g') 'Show user-scope dependency tree (~/.apm/)'
        )
    }

    $catalog['deps info'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['deps clean'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--dry-run') 'Show what would be removed without removing'
            & $newOption @('--yes', '-y') 'Skip confirmation prompt'
        )
    }

    $catalog['deps update'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed update information'
            & $newOption @('--force') 'Overwrite locally-authored files on collision'
            & $newOption @('--global', '-g') 'Update user-scope dependencies'
            & $newOption @('--target', '-t') 'Force deployment to a specific target (comma-separated)' 'list' $targetValues
            & $newOption @('--parallel-downloads') 'Max concurrent downloads' 'freeform' $countValues
            & $newOption @('--legacy-skill-paths') 'Deploy skill files to per-client paths instead of .agents/skills/'
        )
    }

    $catalog['mcp'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'install' 'Add an MCP server to apm.yml'
            & $newCommand 'list' 'List MCP servers'
            & $newCommand 'search' 'Search MCP servers'
            & $newCommand 'show' 'Show MCP server details'
        )
        Options = @()
    }

    $catalog['mcp install'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--transport') 'MCP transport' 'enum' $transportValues
            & $newOption @('--url') 'Server URL for remote transports' 'freeform' @('https://<host>/mcp')
            & $newOption @('--env') 'Environment variable (repeatable)' 'freeform' @('<KEY=VALUE>')
            & $newOption @('--header') 'HTTP header (repeatable)' 'freeform' @('<KEY=VALUE>')
            & $newOption @('--target', '-t') 'Agent target(s) to deploy to' 'list' $targetValues
            & $newOption @('--registry') 'Custom registry URL' 'freeform' @('https://<registry>')
            & $newOption @('--mcp-version') 'Pin registry entry to a specific version' 'freeform' @('<version>')
            & $newOption @('--global', '-g') 'Install to user scope (~/.apm/)'
            & $newOption @('--trust-transitive-mcp') 'Trust MCP servers from transitive dependencies'
            & $newOption @('--dev') 'Install as development dependency'
            & $newOption @('--dry-run') 'Show what would change without writing files'
            & $newOption @('--force') 'Overwrite locally-authored files on collision'
            & $newOption @('--verbose') 'Show detailed output'
            & $newOption @('--no-policy') 'Skip org policy enforcement'
        )
    }

    $catalog['mcp list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--limit') 'Number of results to show' 'freeform' @('20', '50')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['mcp search'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--limit') 'Number of results to show' 'freeform' @('5', '10', '20', '50')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['mcp show'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'add' 'Register a marketplace'
            & $newCommand 'list' 'List registered marketplaces'
            & $newCommand 'browse' 'Browse marketplace plugins'
            & $newCommand 'update' 'Refresh marketplace cache'
            & $newCommand 'remove' 'Remove a registered marketplace'
            & $newCommand 'validate' 'Validate marketplace structure and plugin schema'
            & $newCommand 'init' 'Add a marketplace: block to apm.yml'
            & $newCommand 'check' 'Validate marketplace entries are resolvable'
            & $newCommand 'outdated' 'Show packages with available upgrades'
            & $newCommand 'audit' 'Check that plugin dependencies resolve through the marketplace'
            & $newCommand 'package' 'Manage packages in marketplace authoring config'
            & $newCommand 'migrate' 'Fold marketplace.yml into apm.yml''s marketplace: block'
        )
        Options = @()
    }

    $catalog['marketplace add'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--name', '-n') 'Display name (defaults to repo name)' 'freeform' @('<marketplace-name>')
            & $newOption @('--ref', '-r') 'Git ref (branch, tag, or commit). Default: main' 'freeform' @('<ref>')
            & $newOption @('--host') 'Git host FQDN for OWNER/REPO shorthand' 'freeform' @('<host>')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace validate'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace init'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--force') 'Overwrite an existing marketplace: block in apm.yml'
            & $newOption @('--no-gitignore-check') 'Skip the .gitignore staleness check'
            & $newOption @('--name') 'Marketplace/package name (default: my-marketplace)' 'freeform' @('<name>')
            & $newOption @('--owner') 'Owner name for the marketplace' 'freeform' @('<owner>')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace check'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--offline') 'Schema + cached-ref checks only (no network)'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace outdated'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--offline') 'Use cached refs only (no network)'
            & $newOption @('--include-prerelease') 'Include prerelease versions'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace audit'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--strict') 'Exit non-zero on bypasses, fetch errors, or no verified packages'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace package'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'add' 'Add a package to marketplace authoring config'
            & $newCommand 'remove' 'Remove a package from marketplace authoring config'
            & $newCommand 'set' 'Update a package entry in marketplace authoring config'
        )
        Options = @()
    }

    $catalog['marketplace package add'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--name') 'Package name (default: repo name)' 'freeform' @('<name>')
            & $newOption @('--version') 'Semver range (e.g. >=1.0.0)' 'freeform' @('<range>')
            & $newOption @('--ref') 'Pin to a git ref (SHA, tag, or HEAD)' 'freeform' @('<ref>')
            & $newOption @('--subdir', '-s') 'Subdirectory inside source repo' 'freeform' @('<subdir>')
            & $newOption @('--tag-pattern') 'Tag pattern (e.g. v{version})' 'freeform' @('v{version}')
            & $newOption @('--tags') 'Comma-separated tags' 'freeform' @('<tags>')
            & $newOption @('--include-prerelease') 'Include prerelease versions'
            & $newOption @('--no-verify') 'Skip remote reachability check'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace package remove'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Skip confirmation prompt'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace package set'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--version') 'Semver range (e.g. >=1.0.0)' 'freeform' @('<range>')
            & $newOption @('--ref') 'Pin to a git ref (SHA, tag, or HEAD)' 'freeform' @('<ref>')
            & $newOption @('--subdir') 'Subdirectory inside source repo' 'freeform' @('<subdir>')
            & $newOption @('--tag-pattern') 'Tag pattern (e.g. v{version})' 'freeform' @('v{version}')
            & $newOption @('--tags') 'Comma-separated tags' 'freeform' @('<tags>')
            & $newOption @('--include-prerelease') 'Include prerelease versions'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace migrate'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '--force', '-y') 'Overwrite an existing marketplace: block in apm.yml'
            & $newOption @('--dry-run') 'Show the proposed apm.yml changes without writing them'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace browse'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace update'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['marketplace remove'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Skip confirmation prompt'
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['search'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--limit') 'Maximum results to return' 'freeform' @('5', '10', '20', '50')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['run'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--param', '-p') 'Parameter in format name=value' 'freeform' @('<name=value>')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['preview'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--param', '-p') 'Parameter in format name=value' 'freeform' @('<name=value>')
            & $newOption @('--verbose', '-v') 'Show detailed output'
        )
    }

    $catalog['list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['compile'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--output', '-o') 'Output file path' 'path'
            & $newOption @('--target', '-t') 'Target agent format (comma-separated)' 'list' $targetValues
            & $newOption @('--chatmode') 'Chatmode to prepend to the AGENTS.md file' 'freeform' @('<chatmode>')
            & $newOption @('--dry-run') 'Preview compilation without writing files'
            & $newOption @('--no-links') 'Skip markdown link resolution'
            & $newOption @('--with-constitution') 'Include Spec Kit constitution content'
            & $newOption @('--no-constitution') 'Do not regenerate the constitution block'
            & $newOption @('--watch') 'Auto-regenerate on changes'
            & $newOption @('--validate') 'Validate primitives without compiling'
            & $newOption @('--single-agents') 'Force single-file compilation'
            & $newOption @('--verbose', '-v') 'Show detailed source attribution and optimizer analysis'
            & $newOption @('--local-only') 'Ignore dependencies and compile only local primitives'
            & $newOption @('--clean') 'Remove orphaned generated files'
            & $newOption @('--legacy-skill-paths') 'Deploy skill files to per-client paths instead of .agents/skills/'
            & $newOption @('--all') 'Compile for all canonical targets'
            & $newOption @('--force-instructions') 'Include the instructions section even when rules are already populated'
            & $newOption @('--no-force-instructions') 'Keep the default instructions deduplication'
            & $newOption @('--root') 'Write AGENTS.md / CLAUDE.md outputs under DIR' 'path'
            & $newOption @('--global', '-g') 'Compile user-scope root context files from ~/.apm/apm_modules'
        )
    }

    $catalog['config'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'get' 'Get a configuration value'
            & $newCommand 'list' 'List all configuration values'
            & $newCommand 'set' 'Set a configuration value'
            & $newCommand 'unset' 'Unset a configuration value'
        )
        Options = @()
    }

    $catalog['config get'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['config list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['config set'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['config unset'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['runtime'] = [pscustomobject]@{
        Subcommands = @(
            & $newCommand 'setup' 'Install AI runtime'
            & $newCommand 'list' 'Show installed runtimes'
            & $newCommand 'remove' 'Uninstall runtime'
            & $newCommand 'status' 'Show active runtime and preference order'
        )
        Options = @()
    }

    $catalog['runtime setup'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--version') 'Specific version to install' 'freeform' @('<version>')
            & $newOption @('--vanilla') 'Install runtime without APM configuration'
        )
    }

    $catalog['runtime list'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $catalog['runtime remove'] = [pscustomobject]@{
        Subcommands = @()
        Options = @(
            & $newOption @('--yes', '-y') 'Confirm the action without prompting'
        )
    }

    $catalog['runtime status'] = [pscustomobject]@{
        Subcommands = @()
        Options = @()
    }

    $script:ApmCompletionCatalog = $catalog
    $script:ApmConfigKeyValues = $configKeyValues
    $script:ApmBooleanValues = $booleanValues
    $script:ApmRuntimeValues = $runtimeValues
    $script:ApmInstallRuntimeValues = $installRuntimeValues
    $script:ApmTargetValues = $targetValues
    $script:ApmLifecycleEventValues = $lifecycleEventValues

    $script:ApmCompletionCatalog
}

function Get-ApmLiveHelpStamp {
    # Identifies the installed binary; a changed stamp (upgrade, reinstall,
    # removal) drops every parsed help page. The WinGet shim is a symlink, so
    # the stamp follows it to the real executable.
    $cache = $script:ApmLiveHelpCache
    if (-not $cache.ExecutableProbed) {
        $cache.ExecutableProbed = $true
        $command = Get-Command -Name 'apm.exe' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
        if ($null -ne $command) {
            $cache.Executable = $command.Source
        }
    }

    if ([string]::IsNullOrEmpty($cache.Executable)) {
        return $null
    }

    $file = [System.IO.FileInfo]::new($cache.Executable)
    $target = $file.ResolveLinkTarget($true)
    if ($null -ne $target) {
        $file = $target
    }

    if (-not $file.Exists) {
        return $null
    }

    "$($file.FullName)|$($file.LastWriteTimeUtc.Ticks)"
}

function Invoke-ApmHelpCapture {
    param([string[]]$CommandPath)

    # APM_E2E_TESTS=1 skips the update check (a GitHub API call plus a cache
    # file write) that apm's root callback runs before a subcommand's --help.
    $process = [System.Diagnostics.Process]::new()
    try {
        $process.StartInfo = [System.Diagnostics.ProcessStartInfo]::new($script:ApmLiveHelpCache.Executable)
        $process.StartInfo.UseShellExecute = $false
        $process.StartInfo.CreateNoWindow = $true
        $process.StartInfo.RedirectStandardInput = $true
        $process.StartInfo.RedirectStandardOutput = $true
        $process.StartInfo.RedirectStandardError = $true
        $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8
        $process.StartInfo.Environment['APM_E2E_TESTS'] = '1'
        $process.StartInfo.Environment['PYTHONIOENCODING'] = 'utf-8'
        foreach ($segment in @($CommandPath)) {
            $process.StartInfo.ArgumentList.Add($segment)
        }
        $process.StartInfo.ArgumentList.Add('--help')

        [void]$process.Start()
        $process.StandardInput.Close()
        $stdout = $process.StandardOutput.ReadToEndAsync()
        $null = $process.StandardError.ReadToEndAsync()

        if (-not $process.WaitForExit(5000)) {
            try { $process.Kill($true) } catch { Write-Verbose "apm --help did not exit in 5 s and could not be killed: $_" }
            return $null
        }

        if ($process.ExitCode -ne 0) {
            return $null
        }

        $stdout.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', ''
    }
    catch {
        $null
    }
    finally {
        $process.Dispose()
    }
}

function ConvertTo-ApmLiveOption {
    param(
        [string[]]$Tokens,
        [string]$Metavar,
        [string]$Description
    )

    $valueKind = 'freeform'
    $values = @()
    $listedValues = @(
        if ($Description -match '(?:Values|Names):\s*(?<values>[a-z0-9][a-z0-9-]*(?:,\s*[a-z0-9][a-z0-9-]*)*)') {
            $Matches['values'] -split ',\s*'
        }
    )

    if ([string]::IsNullOrEmpty($Metavar)) {
        $valueKind = 'flag'
    }
    elseif ($Metavar -match '^\[(?<choices>[^\]]+\|[^\]]+)\]$') {
        $valueKind = 'enum'
        $values = @($Matches['choices'] -split '\|')
    }
    elseif ($Metavar -ceq 'TARGET' -or $Description -match '(?i)\bcomma(?:s|-separated)\b') {
        $valueKind = 'list'
        $values = @(if ($listedValues.Count -gt 0) { $listedValues } elseif ($Metavar -ceq 'TARGET') { $script:ApmTargetValues })
    }
    elseif ($listedValues.Count -gt 0) {
        $valueKind = 'enum'
        $values = $listedValues
    }
    elseif ($Metavar -cin @('PATH', 'DIR', 'FILE', 'DIRECTORY')) {
        $valueKind = 'path'
    }
    else {
        $values = @('<' + $Metavar.Trim([char[]]'[]').ToLowerInvariant() + '>')
    }

    $toolTip = ($Description -replace '\s*\[default: [^\]]*\]', '').Trim()
    foreach ($token in $Tokens) {
        [pscustomobject]@{
            Token         = $token
            Description   = $toolTip
            ValueKind     = $valueKind
            Values        = $values
            OptionalValue = $Metavar -match '^\[[^\]|]+\]$'
        }
    }
}

function ConvertFrom-ApmHelpText {
    # Parses Click help: the 'Options:' block and every '... commands:' block.
    # A blank line ends a block, so epilog examples are never read as entries.
    param([string]$Text)

    $subcommands = [System.Collections.Generic.List[object]]::new()
    $options = [System.Collections.Generic.List[object]]::new()
    $section = ''
    $pending = $null

    foreach ($line in ($Text -split '\r?\n')) {
        $isOptionLine = $section -eq 'options' -and $line -match '^ (?<spec>-.+?)(?:\s{2,}(?<desc>\S.*))?$'
        $spec = if ($isOptionLine) { $Matches['spec'] } else { '' }
        $description = if ($isOptionLine) { [string]$Matches['desc'] } else { '' }
        if ($isOptionLine -or [string]::IsNullOrWhiteSpace($line) -or $line -match '^[A-Za-z][A-Za-z ]*:\s*$') {
            if ($null -ne $pending) {
                foreach ($option in ConvertTo-ApmLiveOption -Tokens $pending.Tokens -Metavar $pending.Metavar -Description $pending.Description) {
                    $options.Add($option)
                }
                $pending = $null
            }
        }

        if ($isOptionLine) {
            if ($spec -match'^(?<tokens>-[^\s,/]+(?:(?:,\s*|\s+/\s+)-[^\s,/]+)*)(?:\s+(?<metavar>\S.*))?$') {
                $pending = [pscustomobject]@{
                    Tokens      = @($Matches['tokens'] -split ',\s*|\s+/\s+')
                    Metavar     = [string]$Matches['metavar']
                    Description = $description
                }
            }
            continue
        }

        if ([string]::IsNullOrWhiteSpace($line)) {
            $section = ''
            continue
        }

        if ($line -match '^(?<name>[A-Za-z][A-Za-z ]*):\s*$') {
            $section = if ($Matches['name'] -ceq 'Options') { 'options' } elseif ($Matches['name'] -match '(?i)commands$') { 'commands' } else { '' }
            continue
        }

        if ($section -eq 'commands' -and $line -match '^ (?<name>[a-z][a-z0-9-]*)(?:\s{2,}(?<desc>\S.*))?$') {
            $subcommands.Add([pscustomobject]@{ Name = $Matches['name']; Description = [string]$Matches['desc'] })
            continue
        }

        if ($null -ne $pending -and $line -match '^\s{3,}(?<more>\S.*)$') {
            # Click wraps hyphenated words at the hyphen ('grok-' / 'cloud').
            $more = $Matches['more']
            $pending.Description = if ([string]::IsNullOrEmpty($pending.Description)) {
                $more
            }
            elseif ($pending.Description -match '[a-z]-$') {
                "$($pending.Description)$more"
            }
            else {
                "$($pending.Description) $more"
            }
        }
    }

    [pscustomobject]@{
        Subcommands = @($subcommands)
        Options     = @($options)
    }
}

function Get-ApmCatalogEntry {
    # The static entry for a command path with the live '--help' page merged
    # over it: static definitions win (they carry richer value kinds), live
    # subcommands and options the table lacks are appended.
    param([string]$Key)

    $catalog = Get-ApmCompletionCatalog
    $staticEntry = if ($catalog.Contains($Key)) {
        $catalog[$Key]
    }
    else {
        [pscustomobject]@{ Subcommands = @(); Options = @() }
    }

    $cache = $script:ApmLiveHelpCache
    $stamp = Get-ApmLiveHelpStamp
    if ($null -eq $stamp) {
        return $staticEntry
    }

    if ($cache.Stamp -ne $stamp) {
        $cache.Stamp = $stamp
        $cache.Entries = @{}
    }

    if ($cache.Entries.ContainsKey($Key)) {
        return $cache.Entries[$Key]
    }

    $helpText = Invoke-ApmHelpCapture -CommandPath @($Key -split ' ' | Where-Object { $_ })
    if ([string]::IsNullOrEmpty($helpText)) {
        $cache.Entries[$Key] = $staticEntry
        return $staticEntry
    }

    $live = ConvertFrom-ApmHelpText -Text $helpText
    $subcommandNames = [System.Collections.Generic.HashSet[string]]::new([string[]]@($staticEntry.Subcommands | ForEach-Object { $_.Name }), [System.StringComparer]::Ordinal)
    $optionTokens = [System.Collections.Generic.HashSet[string]]::new([string[]]@($staticEntry.Options | ForEach-Object { $_.Token }), [System.StringComparer]::Ordinal)

    $merged = [pscustomobject]@{
        Subcommands = @(
            $staticEntry.Subcommands
            $live.Subcommands | Where-Object { $subcommandNames.Add($_.Name) }
        )
        Options     = @(
            $staticEntry.Options
            $live.Options | Where-Object { $optionTokens.Add($_.Token) }
        )
    }

    $cache.Entries[$Key] = $merged
    $merged
}

function Get-ApmOptionLookup {
    param($Entry)

    $lookup = @{}
    foreach ($option in $Entry.Options) {
        $lookup[$option.Token] = $option
    }

    $lookup
}

function Get-ApmArgsBeforeCursor {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [string]$WordToComplete,
        [int]$CursorPosition
    )

    $elements = @($CommandAst.CommandElements | ForEach-Object { $_.Extent.Text })
    if ($elements.Count -le 1) {
        return @()
    }

    $arguments = @($elements[1..($elements.Count - 1)])
    $line = $CommandAst.ToString()
    $hasTrailingSpace = ($line -match '\s$') -or ($CursorPosition -gt $line.Length)
    if ($hasTrailingSpace) {
        return $arguments
    }

    # The last element is the word being completed when it holds the cursor. An
    # unquoted a,b list is one array-literal element while $WordToComplete is
    # only its last segment, so text equality alone would miss it.
    $lastExtent = $CommandAst.CommandElements[-1].Extent
    $cursorInLast = $lastExtent.StartOffset -lt $CursorPosition -and $lastExtent.EndOffset -ge $CursorPosition
    if ($arguments.Count -gt 0 -and ($arguments[-1] -eq $WordToComplete -or $cursorInLast)) {
        if ($arguments.Count -eq 1) {
            return @()
        }

        return @($arguments[0..($arguments.Count - 2)])
    }

    $arguments
}

function Get-ApmCommandContext {
    param([string[]]$ArgsBeforeCursor)

    $catalog = Get-ApmCompletionCatalog
    $currentKey = ''
    $currentEntry = $catalog['']
    $commandPath = [System.Collections.Generic.List[string]]::new()
    $lastPathIndex = -1

    for ($i = 0; $i -lt $ArgsBeforeCursor.Count; $i++) {
        $arg = $ArgsBeforeCursor[$i]
        if ($arg -eq '--') {
            break
        }

        if ($arg.StartsWith('-')) {
            continue
        }

        $match = $currentEntry.Subcommands | Where-Object { $_.Name -eq $arg } | Select-Object -First 1
        if ($null -eq $match) {
            # Only a word the static table does not know reads the live help
            # page, and only the current command's page, which the returned
            # Entry needs anyway. Click takes a group's first plain word as its
            # subcommand, so an unknown word that is not the value of the
            # option before it ends the path and no deeper page is read.
            $liveEntry = Get-ApmCatalogEntry -Key $currentKey
            $match = $liveEntry.Subcommands | Where-Object { $_.Name -eq $arg } | Select-Object -First 1
            if ($null -eq $match) {
                $prior = if ($i -gt 0) { $ArgsBeforeCursor[$i - 1] } else { '' }
                $priorOption = $liveEntry.Options | Where-Object { $_.Token -eq $prior } | Select-Object -First 1
                if ($null -ne $priorOption -and $priorOption.ValueKind -ne 'flag') {
                    continue
                }

                break
            }
        }

        $commandPath.Add($match.Name)
        $currentKey = if ([string]::IsNullOrEmpty($currentKey)) {
            $match.Name
        }
        else {
            "$currentKey $($match.Name)"
        }

        $currentEntry = if ($catalog.Contains($currentKey)) {
            $catalog[$currentKey]
        }
        else {
            [pscustomobject]@{ Subcommands = @(); Options = @() }
        }
        $lastPathIndex = $i
    }

    [pscustomobject]@{
        Key           = $currentKey
        Entry         = Get-ApmCatalogEntry -Key $currentKey
        CommandPath   = @($commandPath)
        LastPathIndex = $lastPathIndex
    }
}

function Get-ApmArgsAfterPath {
    param(
        [string[]]$ArgsBeforeCursor,
        [int]$LastPathIndex
    )

    if ($ArgsBeforeCursor.Count -eq 0) {
        return @()
    }

    if ($LastPathIndex -lt 0) {
        return $ArgsBeforeCursor
    }

    if ($LastPathIndex -ge ($ArgsBeforeCursor.Count - 1)) {
        return @()
    }

    @($ArgsBeforeCursor[($LastPathIndex + 1)..($ArgsBeforeCursor.Count - 1)])
}

function Get-ApmArgumentAnalysis {
    param(
        [string[]]$ArgsAfterPath,
        $Entry
    )

    $optionLookup = Get-ApmOptionLookup -Entry $Entry
    $positionals = [System.Collections.Generic.List[string]]::new()
    $pendingOption = $null
    $afterDoubleDash = $false

    foreach ($arg in $ArgsAfterPath) {
        if ($afterDoubleDash) {
            $positionals.Add($arg)
            continue
        }

        if ($arg -eq '--') {
            $afterDoubleDash = $true
            continue
        }

        if ($null -ne $pendingOption) {
            # Click gives an optional value ('--registry [NAME]') the next word
            # unless that word is an option.
            $leavesValueOut = $optionLookup[$pendingOption].OptionalValue -and $arg -match '^-.'
            $pendingOption = $null
            if (-not $leavesValueOut) {
                continue
            }
        }

        if ($arg -match '^(?<option>-{1,2}[^=]+)=') {
            $optionName = $Matches.option
            if ($optionLookup.ContainsKey($optionName)) {
                continue
            }
        }

        if ($arg.StartsWith('-')) {
            if ($optionLookup.ContainsKey($arg) -and $optionLookup[$arg].ValueKind -ne 'flag') {
                $pendingOption = $arg
            }

            continue
        }

        $positionals.Add($arg)
    }

    [pscustomobject]@{
        Positionals     = @($positionals)
        PendingOption   = $pendingOption
        AfterDoubleDash = $afterDoubleDash
    }
}

function Get-ApmOptionValueResults {
    param(
        [string]$CommandKey,
        $Option,
        [string]$WordToComplete,
        [string]$AttachedPrefix = '',
        [string]$ElementText = ''
    )

    switch ($Option.ValueKind) {
        'enum' {
            return @(Get-ApmClosedValueCompletions -Values $Option.Values -WordToComplete $WordToComplete -AttachedPrefix $AttachedPrefix)
        }
        'list' {
            return @(Get-ApmListValueCompletion -Values $Option.Values -WordToComplete $WordToComplete -AttachedPrefix $AttachedPrefix -ElementText $ElementText)
        }
        'path' {
            $typedPath = if ([string]::IsNullOrEmpty($AttachedPrefix)) { $WordToComplete } else { $WordToComplete.Substring($AttachedPrefix.Length) }
            return @(Get-ApmPathCompletions -PathPrefix $typedPath -AttachedPrefix $AttachedPrefix)
        }
        'policy' {
            if ([string]::IsNullOrEmpty($AttachedPrefix) -and (Test-ApmPathLikeToken -Token $WordToComplete)) {
                return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<path>')
            }

            if (-not [string]::IsNullOrEmpty($AttachedPrefix)) {
                $typedPolicy = $WordToComplete.Substring($AttachedPrefix.Length)
                if (Test-ApmPathLikeToken -Token $typedPolicy) {
                    return @(Get-ApmPathCompletions -PathPrefix $typedPolicy -AttachedPrefix $AttachedPrefix -Placeholder '<path>')
                }
            }

            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('org', 'https://<url>', '<path>') -WordToComplete $WordToComplete -AttachedPrefix $AttachedPrefix)
        }
        'freeform' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues $Option.Values -WordToComplete $WordToComplete -AttachedPrefix $AttachedPrefix)
        }
        default {
            return @()
        }
    }
}

function Get-ApmPositionalResults {
    param(
        [string]$CommandKey,
        $Analysis,
        [string]$WordToComplete
    )

    $configKeys = (Get-Variable -Name ApmConfigKeyValues -Scope Script -ErrorAction Ignore).Value
    $booleanValues = (Get-Variable -Name ApmBooleanValues -Scope Script -ErrorAction Ignore).Value
    $runtimeValues = (Get-Variable -Name ApmRuntimeValues -Scope Script -ErrorAction Ignore).Value
    $lifecycleEvents = (Get-Variable -Name ApmLifecycleEventValues -Scope Script -ErrorAction Ignore).Value

    switch ($CommandKey) {
        'auth' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('github.com', 'gitlab.com') -WordToComplete $WordToComplete)
            }
        }
        'update' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
        }
        'approve' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
        }
        'deny' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
        }
        'find' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<file-path>')
            }
        }
        'deps why' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
            }
        }
        'policy explain' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
            }
        }
        'experimental enable' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<feature>') -WordToComplete $WordToComplete)
            }
        }
        'experimental disable' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<feature>') -WordToComplete $WordToComplete)
            }
        }
        'experimental reset' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<feature>') -WordToComplete $WordToComplete)
            }
        }
        'lifecycle test' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $lifecycleEvents -WordToComplete $WordToComplete)
            }
        }
        'plugin init' {
            if ($Analysis.Positionals.Count -eq 0) {
                if (Test-ApmPathLikeToken -Token $WordToComplete) {
                    return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<project-name>')
                }

                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('.', '<project-name>') -WordToComplete $WordToComplete)
            }
        }
        'mcp install' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<server-name>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace validate' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace audit' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace package add' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<owner/repo>', '<host/owner/repo>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace package remove' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package-name>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace package set' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package-name>') -WordToComplete $WordToComplete)
            }
        }
        'config unset' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $configKeys -WordToComplete $WordToComplete)
            }
        }
        'init' {
            if ($Analysis.Positionals.Count -eq 0) {
                if (Test-ApmPathLikeToken -Token $WordToComplete) {
                    return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<project-name>')
                }

                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('.', '<project-name>') -WordToComplete $WordToComplete)
            }
        }
        'install' {
            if (Test-ApmPathLikeToken -Token $WordToComplete) {
                return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<local-path>')
            }

            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<owner/repo>', 'https://<host>/<owner>/<repo>.git', './<local-path>', '<plugin@marketplace>') -WordToComplete $WordToComplete)
        }
        'uninstall' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<owner/repo>', 'https://<host>/<owner>/<repo>.git', '<package>') -WordToComplete $WordToComplete)
        }
        'audit' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
            }
        }
        'unpack' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<bundle-path>')
            }
        }
        'view' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
            }

            if ($Analysis.Positionals.Count -eq 1) {
                return @(Get-ApmClosedValueCompletions -Values @('versions') -WordToComplete $WordToComplete)
            }
        }
        'info' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
            }

            if ($Analysis.Positionals.Count -eq 1) {
                return @(Get-ApmClosedValueCompletions -Values @('versions') -WordToComplete $WordToComplete)
            }
        }
        'deps info' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package-name>') -WordToComplete $WordToComplete)
            }
        }
        'deps update' {
            return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<package>') -WordToComplete $WordToComplete)
        }
        'mcp search' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<query>') -WordToComplete $WordToComplete)
            }
        }
        'mcp show' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<server-name>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace add' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<owner/repo>', '<host/owner/repo>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace browse' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace update' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'marketplace remove' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'search' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<query@marketplace>') -WordToComplete $WordToComplete)
            }
        }
        'run' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<script-name>') -WordToComplete $WordToComplete)
            }
        }
        'preview' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmFreeformValueCompletions -SuggestedValues @('<script-name>') -WordToComplete $WordToComplete)
            }
        }
        'config get' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $configKeys -WordToComplete $WordToComplete)
            }
        }
        'config set' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $configKeys -WordToComplete $WordToComplete)
            }

            if ($Analysis.Positionals.Count -eq 1) {
                switch ($Analysis.Positionals[0]) {
                    'auto-integrate' {
                        return @(Get-ApmClosedValueCompletions -Values $booleanValues -WordToComplete $WordToComplete)
                    }
                    'temp-dir' {
                        return @(Get-ApmPathCompletions -PathPrefix $WordToComplete -Placeholder '<path>')
                    }
                }
            }
        }
        'runtime setup' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $runtimeValues -WordToComplete $WordToComplete)
            }
        }
        'runtime remove' {
            if ($Analysis.Positionals.Count -eq 0) {
                return @(Get-ApmClosedValueCompletions -Values $runtimeValues -WordToComplete $WordToComplete)
            }
        }
    }

    @()
}

function Get-ApmUniqueResults {
    # [object[]] so a single CompletionResult (PowerShell unrolls one-element
    # arrays on function return) binds as a one-element array instead of failing
    # IEnumerable parameter transformation.
    param([object[]]$Results)

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($result in @($Results)) {
        if ($null -eq $result) {
            continue
        }

        if ($seen.Add($result.CompletionText)) {
            $result
        }
    }
}

function Complete-Apm {
    param(
        [string]$WordToComplete,
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    $null = Get-ApmCompletionCatalog

    $argsBeforeCursor = @(Get-ApmArgsBeforeCursor -CommandAst $CommandAst -WordToComplete $WordToComplete -CursorPosition $CursorPosition)
    $context = Get-ApmCommandContext -ArgsBeforeCursor $argsBeforeCursor
    $argsAfterPath = @(Get-ApmArgsAfterPath -ArgsBeforeCursor $argsBeforeCursor -LastPathIndex $context.LastPathIndex)
    $analysis = Get-ApmArgumentAnalysis -ArgsAfterPath $argsAfterPath -Entry $context.Entry
    $optionLookup = Get-ApmOptionLookup -Entry $context.Entry

    # The parser's element under the cursor, up to the cursor: for an unquoted
    # comma list it holds the earlier segments $WordToComplete lacks.
    $elementText = ''
    $cursorElement = $CommandAst.CommandElements | Select-Object -Skip 1 | Where-Object {
        $_.Extent.StartOffset -lt $CursorPosition -and $_.Extent.EndOffset -ge $CursorPosition
    } | Select-Object -First 1
    if ($null -ne $cursorElement) {
        $elementText = $cursorElement.Extent.Text.Substring(0, $CursorPosition - $cursorElement.Extent.StartOffset)
    }

    if ($WordToComplete -match '^(?<option>-{1,2}[^=]+)=(?<value>.*)$') {
        $optionName = $Matches.option
        if ($optionLookup.ContainsKey($optionName)) {
            $attachedPrefix = "$optionName="
            return Get-ApmUniqueResults -Results @(Get-ApmOptionValueResults -CommandKey $context.Key -Option $optionLookup[$optionName] -WordToComplete $WordToComplete -AttachedPrefix $attachedPrefix -ElementText $elementText)
        }
    }
    elseif ($elementText -match '^(?<option>-{1,2}[^=]+)=.*,') {
        $optionName = $Matches.option
        if ($optionLookup.ContainsKey($optionName) -and $optionLookup[$optionName].ValueKind -eq 'list') {
            return Get-ApmUniqueResults -Results @(Get-ApmOptionValueResults -CommandKey $context.Key -Option $optionLookup[$optionName] -WordToComplete $WordToComplete -AttachedPrefix "$optionName=" -ElementText $elementText)
        }
    }

    $results = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new()

    if ($null -ne $analysis.PendingOption -and $optionLookup.ContainsKey($analysis.PendingOption)) {
        $pendingOption = $optionLookup[$analysis.PendingOption]
        $valueResults = @(Get-ApmOptionValueResults -CommandKey $context.Key -Option $pendingOption -WordToComplete $WordToComplete -ElementText $elementText)
        if (-not $pendingOption.OptionalValue) {
            return Get-ApmUniqueResults -Results $valueResults
        }

        # A bare optional-value option is complete when an option follows it,
        # so the slot offers the value placeholder and the command's options.
        # A plain word would become the value, so positionals stay out.
        if (-not $WordToComplete.StartsWith('-')) {
            foreach ($valueResult in $valueResults) {
                $results.Add($valueResult)
            }
        }

        if ([string]::IsNullOrEmpty($WordToComplete) -or $WordToComplete.StartsWith('-')) {
            foreach ($option in $context.Entry.Options) {
                if (Test-ApmStartsWith -Candidate $option.Token -Prefix $WordToComplete) {
                    $results.Add((New-ApmCompletionResult -CompletionText $option.Token -ToolTip $option.Description -ResultType ([System.Management.Automation.CompletionResultType]::ParameterName)))
                }
            }
        }

        return Get-ApmUniqueResults $results
    }

    if ($WordToComplete.StartsWith('-')) {
        foreach ($option in $context.Entry.Options) {
            if (-not (Test-ApmStartsWith -Candidate $option.Token -Prefix $WordToComplete)) {
                continue
            }

            $results.Add((New-ApmCompletionResult -CompletionText $option.Token -ToolTip $option.Description -ResultType ([System.Management.Automation.CompletionResultType]::ParameterName)))
        }

        return Get-ApmUniqueResults $results
    }

    if ($context.Entry.Subcommands.Count -gt 0 -and $analysis.Positionals.Count -eq 0 -and -not $analysis.AfterDoubleDash) {
        foreach ($subcommand in $context.Entry.Subcommands) {
            if (-not (Test-ApmStartsWith -Candidate $subcommand.Name -Prefix $WordToComplete)) {
                continue
            }

            $results.Add((New-ApmCompletionResult -CompletionText $subcommand.Name -ToolTip $subcommand.Description))
        }

        if (-not [string]::IsNullOrEmpty($WordToComplete)) {
            return Get-ApmUniqueResults $results
        }
    }

    if ([string]::IsNullOrEmpty($WordToComplete)) {
        foreach ($option in $context.Entry.Options) {
            $results.Add((New-ApmCompletionResult -CompletionText $option.Token -ToolTip $option.Description -ResultType ([System.Management.Automation.CompletionResultType]::ParameterName)))
        }
    }

    foreach ($positionalResult in Get-ApmPositionalResults -CommandKey $context.Key -Analysis $analysis -WordToComplete $WordToComplete) {
        $results.Add($positionalResult)
    }

    Get-ApmUniqueResults $results
}

Register-ArgumentCompleter -Native -CommandName @('apm', 'apm.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Complete-Apm -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}