How To Implement Subcommands Without using a CLI Command Package in 2 Steps

CLI is king!

Your IT infrastructure wants to be managed, and while UIs are fancy and convenient, the CLI rules when quick action is required. For ultimate control over your infrastructure, you envision a tool named srv that provides numerous subcommands, like srv status, srv start, srv stop, and so forth. Just like Git does it!

Of course, you want to write all these subcommands in Go. You know that there are several CLI command packages available that support subcommands, like Cobra, urfave/cli, Kong, and many more.

But which one to choose?

“None” is an option

If you need to decide quickly, opt for none of them. Postpone this decision until you find some time to evaluate available CLI packages. You can still bundle all your tools as subcommands under a main command.

The trick? A simple shell function.

A simple Bash/Zsh/Powershell function can deliver a simple command/subcommand hierarchy in 2 steps.

Step 1: Create stand-alone commands

Start by writing all your sub-commands as stand-alone binaries. If we stick with the above example, you would name these binaries srv-status, srv-start, srv-stop, and so on.

Step 2: Add a function to your shell config file

Here is a rather simple function that can be called as srv <command> and invokes srv-command. Sounds trivial? Maybe, but you get a few advantages from this approach:

  1. All commands have a common entrypoint. All users know that all server management commands are called as srv <command>. No need to keep a list of commands in one's head or on stickies.
  2. All available subcommands are discoverable. If called as srv or as srv help, the script prints out all available commands.
  3. Everyone can easily contribute new commands. Adding a new binary with a srv- prefix to a directory listed in $PATH is sufficient to extend the list of subcommands for srv. The script automatically picks up the new command, no manual changes required.

So here is the script for several shell flavors.

For Bash, add this script to .bashrc and restart the shell or start a new one:

srv() {
  print_usage() {
    echo "Usage: srv <subcommand>"
    echo "Available subcommands:"
    printf '  %s\n' $(compgen -c srv- | sed 's/^srv-//' | sort | uniq)
    echo "  help"
  }

  if [ $# -eq 0 ] || [ "$1" = "help" ]; then
    print_usage
    return 0
  fi

  command srv-"$@"
}

Zsh users can re-use this script after swapping out the printf line for:

printf '  %s\n' ${(f)"$(print -l ${(ok)commands[(I)srv-*]} | sed 's/^srv-//')"} 

The reason for this change is that compgen is a Bash-internal function. The Zsh script uses Zsh's associative commands array instead for finding all srv subcommands.

Fish syntax is different. Create ~/.config/fish/functions/srv.fish and insert this script:

function srv
    function print_usage
        echo "Usage: srv <subcommand>"
        echo "Available subcommands:"
        for cmd in (string replace -r '^.*/srv-' '' (command -s srv-*))
            echo "  $cmd"
        end
        echo "  help"
    end

    if test (count $argv) -eq 0; or test "$argv[1]" = "help"
        print_usage
        return 0
    end

    command srv-$argv[1] $argv[2..-1]
end

PowerShell users can put the following script into $PROFILE:

function srv {
    function Print-Usage {
        Write-Host "Usage: srv <subcommand>"
        Write-Host "Available subcommands:"
        Get-Command srv-* | ForEach-Object { $_.Name -replace '^srv-' } | Sort-Object -Unique | ForEach-Object { Write-Host "  $_" }
        Write-Host "  help"
    }

    if ($args.Count -eq 0 -or $args[0] -eq "help") {
        Print-Usage
        return
    }

    $command = "srv-$($args -join ' ')"
    Invoke-Expression $command
}

Finito

With almost no effort, you now can create and use subcommands. Calling srv start --now server1 server2 translates directly to srv-start --now server1 server2, while srv and srv help list all available commands.

Feel free to play around and extend the script. For example, if srv is called without a subcommand, make it invoke a default command instead of listing subcommands. Or improve the help command to turn the call to srv help <subcommand> into srv-<subcommand> --help.