public/functions/Invoke-PowerStubCommand.ps1

<#
.SYNOPSIS
  Executes a command from a registered PowerStub.
 
.DESCRIPTION
  Invokes a command that is registered within a PowerStub stub. This is the main entry point
  for executing stubbed commands. It resolves the command path, forwards parameters and arguments,
  and executes the target script or executable with proper error handling and logging.
 
  Supports virtual verbs (search, help, update) that perform special operations without
  mapping to actual command files.
 
.PARAMETER Stub
  The name of the stub containing the command. Required unless using virtual verbs.
  Supports tab completion for registered stubs.
 
.PARAMETER Command
  The name of the command to execute within the stub. Required unless showing overview.
  Supports tab completion for commands in the selected stub.
  Supports dynamic parameters from the target command.
 
.PARAMETER RemainingArgs
  Arguments to pass to the target command. Captured from the command line and passed through unchanged.
 
.INPUTS
  None. You cannot pipe objects to this function.
 
.OUTPUTS
  Output from the executed command. This varies depending on the target command.
 
.EXAMPLE
  pstb DevOps deploy -Environment Production
 
  Executes the deploy command in the DevOps stub with the Environment parameter.
 
.EXAMPLE
  pstb search "backup"
 
  Searches all registered stubs for commands matching "backup".
 
.EXAMPLE
  pstb help DevOps deploy
 
  Displays PowerShell help for the deploy command in the DevOps stub.
 
.EXAMPLE
  pstb update --check
 
  Checks the status of all stub Git repositories without pulling changes.
 
#>


function Invoke-PowerStubCommand {
    [CmdletBinding()]
    param(
        [parameter(Position = 0)] [string] $stub,
        [parameter(Position = 1)] [string] $command,
        # ValueFromRemainingArguments captures any positional arguments after stub and command
        [parameter(DontShow = $true, ValueFromRemainingArguments = $true)] [object[]] $RemainingArgs
    )

    DynamicParam {
        # Get stub and command from PSBoundParameters (not variables - they don't exist yet during DynamicParam)
        $stubValue = $PSBoundParameters['Stub']
        $commandValue = $PSBoundParameters['Command']

        # Only build dynamic params if both stub and command are provided
        if ($stubValue -and $commandValue) {
            $RuntimeParamDic = Get-PowerStubCommandDynamicParams $stubValue $commandValue
            return $RuntimeParamDic
        }

        # Return empty dictionary if we don't have both values yet
        return New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
    }

    begin {
        Write-Debug "Invoke-PowerStubCommand Begin"
    }

    process {
        Write-Debug "Invoke-PowerStubCommand Process"
    }

    end {
        Write-Debug "Invoke-PowerStubCommand Process"

        if (!$stub) {
            Show-PowerStubOverview
            return
        }

        # Virtual verb handling - these are reserved commands that don't map to script files
        $virtualVerbs = @('search', 'help', 'update')
        if ($virtualVerbs -contains $stub) {
            switch ($stub) {
                'search' {
                    if ($command) {
                        return Search-PowerStubCommands $command
                    }
                    else {
                        throw "Usage: pstb search <query>"
                    }
                }
                'help' {
                    if ($command -and $RemainingArgs -and $RemainingArgs.Count -gt 0) {
                        # pstb help <stub> <command>
                        return Get-PowerStubCommandHelp -Stub $command -Command $RemainingArgs[0]
                    }
                    elseif ($command) {
                        throw "Usage: pstb help <stub> <command>"
                    }
                    else {
                        throw "Usage: pstb help <stub> <command>"
                    }
                }
                'update' {
                    Invoke-PowerStubUpdate -Command $command -RemainingArgs $RemainingArgs
                    return
                }
            }
        }

        if (!$command) {
            Show-PowerStubCommands $stub
            return
        }

        # Check if stub is registered first
        $stubs = Get-PowerStubConfigurationKey 'Stubs'
        if (-not $stubs -or -not $stubs.ContainsKey($stub)) {
            $registeredStubs = if ($stubs) { ($stubs.Keys -join ', ') } else { '(none)' }
            Throw "Stub '$stub' is not registered. Registered stubs: $registeredStubs`n`nTo register: New-PowerStub -Name '$stub' -Path '<path-to-stub-folder>'"
        }

        $commandObj = Get-PowerStubCommand $stub $command
        if (!$commandObj) {
            $stubConfig = $stubs[$stub]
            $stubPath = Get-PowerStubPath -StubConfig $stubConfig
            Throw "Command '$command' not found in stub '$stub'.`n`nStub path: $stubPath`nExpected: $stubPath\Commands\$command.ps1 or $stubPath\Commands\$command\$command.ps1"
        }

        $cmd = $commandObj.Path

        # Collect dynamic parameters (bound params that aren't our static or common params)
        $forwardParams = @{}
        $skipParams = @('Stub', 'Command', 'RemainingArgs')
        $commonParamsList = @('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction',
            'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer',
            'PipelineVariable', 'ProgressAction', 'WhatIf', 'Confirm')
        foreach ($key in $PSBoundParameters.Keys) {
            if ($key -notin $skipParams -and $key -notin $commonParamsList) {
                $forwardParams[$key] = $PSBoundParameters[$key]
            }
        }

        # Parse RemainingArgs to extract named parameters that match the target command.
        # This handles the case where callers use array splatting (& pstb @args) which
        # passes all elements as positional, preventing DynamicParam from capturing named params.
        $effectivePositionalArgs = @()
        if ($RemainingArgs -and $RemainingArgs.Count -gt 0) {
            $cmdParams = $commandObj.Parameters
            $i = 0
            while ($i -lt $RemainingArgs.Count) {
                $arg = $RemainingArgs[$i]
                if ($arg -is [string] -and $arg.StartsWith('-') -and $arg.Length -gt 1) {
                    $paramName = $arg.Substring(1)
                    if ($cmdParams.ContainsKey($paramName) -and -not $forwardParams.ContainsKey($paramName)) {
                        $paramType = $cmdParams[$paramName].ParameterType
                        if ($paramType -eq [System.Management.Automation.SwitchParameter]) {
                            $forwardParams[$paramName] = $true
                        }
                        else {
                            $i++
                            if ($i -lt $RemainingArgs.Count) {
                                $forwardParams[$paramName] = $RemainingArgs[$i]
                            }
                        }
                    }
                    else {
                        $effectivePositionalArgs += $arg
                    }
                }
                else {
                    $effectivePositionalArgs += $arg
                }
                $i++
            }
        }

        Write-Debug "Command path: $cmd"
        Write-Debug "Dynamic params: $($forwardParams.Keys -join ', ')"
        Write-Debug "Remaining args: $($effectivePositionalArgs -join ', ')"

        Write-Host "Invoking $cmd"
        Invoke-CheckedCommandWithParams -command $cmd -namedParams $forwardParams -positionalArgs $effectivePositionalArgs
    }
}