Implement the subcommand pattern in PowerShell

5.4k views Asked by At

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?

1

There are 1 answers

3
Roman Kuzmin On BEST ANSWER

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:

  • Minimum code and overhead.
  • Positional parameters work.
  • -? works for help as it is (short help).

Cons:

  • PowerShell v3+, splatting works funny in v2.

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

Pros:

  • PowerShell v2+.
  • TabExpansion works for parameters.

Cons:

  • More code, more runtime work.
  • Only named parameters.
  • Help as -help.

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
}