powershellpowershell-3.0

Implement the subcommand pattern in PowerShell


Is it possible to implement the subcommand pattern in PowerShell? Something like:

command [subcommand] [options] [files]

Examples: Git, svn, Homebrew

What would be the general architecture? A single function that delegates the actual work to script blocks? Each subcommand isolated in its own PS1 file that is dot-sources by the primary script? Would PowerShell's various meta-data functions (e.g. Get-Command) be able to 'inspect' the subcommands?


Solution

  • I thought of this pattern and found two ways of doing this. I did not find real applications in my practice, so the research is rather academic. But the scripts below work fine.

    An existing tool which implements this pattern (in its own way) is scoop.


    The pattern subcommand implements the classic command line interface

    app <command> [parameters]
    

    This pattern introduces a single script app.ps1 which provides commands instead of providing multiple scripts or functions in a script library or module. Each command is a script in the special subdirectory, e.g. ./Command.

    Get available commands

    app
    

    Invoke a command

    app c1 [parameters of Command\c1.ps1]
    

    Get command help

    app c1 -?     # works with splatting approach
    app c1 -help  # works with dynamic parameters
    

    The script app.ps1 may contain common functions used by commands.


    splat.ps1 (as such app.ps1) - pattern with splatting

    Pros:

    Cons:


    dynamic.ps1 (as such app.ps1) - pattern with dynamic parameters

    Pros:

    Cons:


    Scripts

    splat.ps1

    UPDATE: Used $_Command instead of $Command to avoid conflicts with partial parameter names like -c, see comments.

    #requires -Version 3
    
    param(
        $_Command
    )
    
    if (!$_Command) {
        foreach($_ in Get-ChildItem $PSScriptRoot\Command -Name) {
            [System.IO.Path]::GetFileNameWithoutExtension($_)
        }
        return
    }
    
    & "$PSScriptRoot\Command\$_Command.ps1" @args
    

    dynamic.ps1

    UPDATE: Added more known common parameters.

    param(
        [Parameter()]$Command,
        [switch]$Help
    )
    dynamicparam {
        ${private:*pn} = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'ErrorVariable', 'WarningVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'InformationAction', 'InformationVariable', 'ProgressAction'
        $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Definition
    
        $Command = $PSBoundParameters['Command']
        if (!$Command) {return}
    
        $_ = Get-Command -Name "$PSScriptRoot\Command\$Command.ps1" -CommandType ExternalScript -ErrorAction 1
        if (!($_ = $_.Parameters) -or !$_.Count) {return}
    
        ${private:*r} = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
        (${private:*a} = New-Object System.Collections.ObjectModel.Collection[Attribute]).Add((New-Object System.Management.Automation.ParameterAttribute))
        foreach($_ in $_.Values) {
            if (${*pn} -notcontains $_.Name) {
                ${*r}.Add($_.Name, (New-Object System.Management.Automation.RuntimeDefinedParameter $_.Name, $_.ParameterType, ${*a}))
            }
        }
        ${*r}
    }
    end {
        if (!$Command) {
            foreach($_ in Get-ChildItem $PSScriptRoot\Command -Name) {
                [System.IO.Path]::GetFileNameWithoutExtension($_)
            }
            return
        }
    
        if ($Help) {
            Get-Help "$PSScriptRoot\Command\$Command.ps1" -Full
            return
        }
    
        $null = $PSBoundParameters.Remove('Command')
        & "$PSScriptRoot\Command\$Command.ps1" @PSBoundParameters
    }