Building helpful CLI tools with Go and Kingpin

March 2nd 2016 Nicholas Whyte in Golang, Scripting, Tools

Building a well documented command line interface is hard. Allowing users to discover functionality and get help without typing --help or looking at the docs is difficult to do. How many times have you found yourself reaching for --help because you couldn't quite remember the command name you needed?

Here at Atlassian, my team maintains a CLI tool to interface with our internal service. It's written in Go and takes advantage of all the functionality that the CLI library Kingpin has to offer.

We wanted to improve the experience for the users of our service. We decided providing Bash completion for the tool would allow faster usage of the tool. Unfortunately Kingpin did not support shell completion, however we implemented the functionality, and had our pull request approved and merged upstream.

In this blog post I'll cover how to make a friendly CLI that gives shell completion hints. I'll also assume you're familiar with creating Go projects. If you're just getting started with Go, check out the Golang Getting Started

Getting started

Create a new go package for your CLI. Let's start by populating your main.go with the following:

package main

import (
    "os"

    "gopkg.in/alecthomas/kingpin.v2"
)

func main() {
    app := kingpin.New("my-app", "My Example CLI Application With Bash Completion")
    kingpin.MustParse(app.Parse(os.Args[1:]))
}

This sets up a new kingpin CLI app which parses the command line arguments passed at runtime. Try it out:

$> go build && ./my-app --help
usage: my-app [<flags>]

My Example CLI Application With Bash Completion

Flags:
  --help  Show context-sensitive help (also try --help-long and --help-man).

Adding functionality

Let's add some sub commands. We extend our main() and add a helper factory addSubCommand:

package main

import (
    "os"
    "fmt"

    "gopkg.in/alecthomas/kingpin.v2"
)

func addSubCommand(app *kingpin.Application, name string, description string) {
    app.Command(name, description).Action(func(c *kingpin.ParseContext) error {
        fmt.Printf("Would have run command %s.\n", name)
        return nil
    })
}

func main() {
    app := kingpin.New("my-app", "My Sample Kingpin App!")
    app.Flag("flag-1", "").String()
    app.Flag("flag-2", "").HintOptions("opt1", "opt2").String()

    // Add some additional top level commands
    addSubCommand(app, "ls", "Additional top level command to show command completion")
    addSubCommand(app, "ping", "Additional top level command to show command completion")
    addSubCommand(app, "nmap", "Additional top level command to show command completion")

    kingpin.MustParse(app.Parse(os.Args[1:]))
}

Bash completion

Whilst we don't have a full example yet, it would be nice to try out Bash completion right now. In order to enable Bash completion in our shell, we need to generate the completion script. You can do this with ./my-app --completion-script-bash and ./my-app --completion-script-zsh. It's worth keeping in mind that the name of your binary must be the same as the name passed to kingpin.New().

Ideally, when packaging your binary tool, you will also include this script and install it in the appropriate location. For now we'll just locally source it in our current session:

# If you're using Bash
eval "$(./my-app --completion-script-bash)"
# If you're using Zsh
eval "$(./my-app --completion-script-zsh)"

Now try it out:

$> ./my-app <TAB>
help     ls    nmap    ping

If everything went well, you should be able to see the available subcommands.

More functionality

Whilst being able to hint subcommands is very useful, it would be even better if we could hint possible options for flags. We'll add a new command nc which will use command line flags.

We will update the main function, add a new struct NetcatCommand, and a factory for it configureNetcatCommand:

func main() {
    app := kingpin.New("my-app", "My Sample Kingpin App!")

    configureNetcatCommand(app)

    // Add some additional top level commands
    addSubCommand(app, "ls", "Additional top level command to show command completion")
    addSubCommand(app, "ping", "Additional top level command to show command completion")
    addSubCommand(app, "nmap", "Additional top level command to show command completion")

    kingpin.MustParse(app.Parse(os.Args[1:]))
}

type NetcatCommand struct {
    hostName string
    port     int
    format   string
}

func (n *NetcatCommand) run(c *kingpin.ParseContext) error {
    fmt.Printf("Would have run netcat to hostname %v, port %d, and output format %v\n", n.hostName, n.port, n.format)
    return nil
}

func configureNetcatCommand(app *kingpin.Application) {
    c := &NetcatCommand{}
    nc := app.Command("nc", "Connect to a Host").Action(c.run)
    nc.Flag("nop-flag", "Example of a flag with no options").Bool()
}

If we build and run now we should be able to see hints for flags when in the netcat context. To show flag suggestions, we must first type "--", then press tab.

$> ./my-app nc --<TAB>
--help         --nop-flag

Finally, we'll add some more flags to NetcatCommand. We make these modifications to configureNetcatCommand and also add a helper function listHosts():

func configureNetcatCommand(app *kingpin.Application) {
    c := &NetcatCommand{}
    nc := app.Command("nc", "Connect to a Host").Action(c.run)
    nc.Flag("nop-flag", "Example of a flag with no options").Bool()

    // You can provide hint options statically
    nc.Flag("port", "Provide a port to connect to").
        Required().
        HintOptions("80", "443", "8080").
        IntVar(&c.port)

    // Enum/EnumVar options will be turned into completion options automatically
    nc.Flag("format", "Define the output format").
        Default("raw").
        EnumVar(&c.format, "raw", "json")

    // You can provide hint options using a function to generate them
    nc.Flag("host", "Provide a hostname to nc").
        Required().
        HintAction(listHosts).
        StringVar(&c.hostName)

}

func listHosts() []string {
    // Provide a dynamic list of hosts from a hosts file or otherwise
    // for Bash completion. In this example we simply return static slice.

    // You could use this functionality to reach into a hosts file to provide
    // completion for a list of known hosts.
    return []string{"sshhost.example", "webhost.example", "ftphost.example"}
}

Try it out:

$> ./my-app nc --<TAB>
--format     --help     --host     --nop-flag     --port

$> ./my-app nc --format <TAB>
json     raw

$> ./my-app nc --host <TAB>
ftphost.example     sshhost.example     webhost.example

$> ./my-app nc --port <TAB>
443         80        8080

Wrapping up

So now you've successfully made a user friendly to use CLI tool. You can check out the full friendly Go CLI code example. It was closely based on the provided Kingpin completion example that we included in our pull request. You can find the actual example here.