/cli-skeleton

An opinionated framework for building golang cli tools on top of mitchellh/cli.

Primary LanguageGoMIT LicenseMIT

cli-skeleton

An opinionated framework for building golang cli tools on top of mitchellh/cli.

Why

While mitchellh/cli gives quite a bit of code to allow folks to build cli tools on top of it, it does not provide enough structure to allow folks to get started quickly. This project aims to fill that void by implementing a skeleton based upon those provided by the hashicorp suite of tools.

See Command Line Interface Guidelines for further reading on how to structure command line tools.

Getting started

The source for this example is stored in the examples/lollipop directory.

To create a new cli tool, the cli-tool will need to be initialized. This tool will be called lollipop:

mkdir lollipop
go mod init lollipop

Next, create a main.go with the following contents:

package main

import (
  "fmt"
  "os"

  "github.com/josegonzalez/cli-skeleton/command"
  "github.com/mitchellh/cli"
)

// The name of the cli tool
var AppName = "lollipop"

// Holds the version
var Version string

func main() {
  os.Exit(Run(os.Args[1:]))
}

// Executes the specified command
func Run(args []string) int {
  commandMeta, ui := command.SetupRun(AppName, Version, args)
  c := cli.NewCLI(AppName, Version)
  c.Args = os.Args[1:]
  c.Commands = command.Commands(commandMeta, ui, Commands)
  exitCode, err := c.Run()
  if err != nil {
    fmt.Fprintf(os.Stderr, "Error executing CLI: %s\n", err.Error())
    return 1
  }

  return exitCode
}

// Returns a list of implemented commands
func Commands(meta command.Meta) map[string]cli.CommandFactory {
  return map[string]cli.CommandFactory{
    "version": func() (cli.Command, error) {
      return &command.VersionCommand{Meta: meta}, nil
    },
  }
}

Run go build -ldflags "-X main.Version=0.1.0" to build the 0.1.0 version of the tool in the current directory. Running ./lollipop will now show the following output:

Usage: lollipop [--version] [--help] <command> [<args>]

Available commands are:
    version    Return the version of the binary

The cli-skeleton project includes a helpful version command that can be executed via ./lollipop version with the following output:

0.1.0

Adding additional commands

Adding a new subcommand is straightforward. For the example lollipop app, an eat command will be created. To start, create a commands directory that contains an eat.go file. This file should contain an EatCommand struct as follows:

import "github.com/josegonzalez/cli-skeleton/command"

type EatCommand struct {
  command.Meta
}

EatCommand should implement the following interface:

type Command interface {
  Arguments()                    []Argument
  AutocompleteArgs()             complete.Predictor
  AutocompleteFlags()            complete.Flags
  Examples()                     map[string]string
  FlagSet()                      *flag.FlagSet
  Help()                         string
  Name()                         string
  ParsedArguments(args []string) (map[string]Argument, error)
  Run(args []string)             int
  Synopsis()                     string
}

The following section will describe each interface function and how to implement them for the example eat command. Each section will include all required import statements. Please be sure to de-duplicate them when creating the eat.go file.

Naming the command

The Name() function must return the name of the command. This is used in parsing, help output, and other examples.

func (c *EatCommand) Name() string {
  return "eat"
}

Describing the command

A command description - or synopsis - is used in the help output for the function. This should ideally be 50 characters or less:

func (c *EatCommand) Synopsis() string {
  return "Eats one or more lollipops"
}

Help output

To start, the following boilerplate help command can be quickly added (note the import statement, which only needs to be included once per command file):

import "github.com/josegonzalez/cli-skeleton/command"

func (c *EatCommand) Help() string {
  return command.CommandHelp(c)
}

As long as all the other interface functions are implemented, the eat command will automatically support the --help and -h flags for help output.

Help examples

While examples are excellent, it is recommended to have 5 or fewer examples in the help output. Further examples should be sent to documentation or potentially result in splitting the command into multiple commands.

Users wishing to understand how to use cli tool will want a few examples. These can be easily specified like so:

import (
  "fmt"
  "os"
)

func (c *EatCommand) Examples() map[string]string {
  appName := os.Getenv("CLI_APP_NAME")
  return map[string]string{
    "Eats one lollipop quickly": fmt.Sprintf("%s %s quickly", appName, c.Name()),
    "Eats one lollipop slowly": fmt.Sprintf("%s %s slowly", appName, c.Name()),
    "Eats two lollipops quickly": fmt.Sprintf("%s %s --count 2 quickly", appName, c.Name()),
    "Eats three red lollipops": fmt.Sprintf("%s %s --count 3 --color red", appName, c.Name()),
  }
}

Examples are a great way to help users get started with the cli tool, allowing contributors to embed further examples for common tasks without having them rot in a place far away from the actual code.

Arguments

Arguments can be added by specifying an Arguments() function like so:

import "github.com/josegonzalez/cli-skeleton/command"

func (c *EatCommand) Arguments() []command.Argument {
  args := []command.Argument{}
  args = append(args, command.Argument{
    Name:     "speed",
    Optional: true,
    Type:     command.ArgumentString,
  })
  return args
}

The Arguments() function returns a slice of Argument structs. An Argument struct is defined as follows:

type Argument struct {
  Name     string       // The name of the argument
  Optional bool         // Whether the argument is optional or not
  Type     ArgumentType // The type of the Argument. Valid types are: ArgumentString, ArgumentInt, ArgumentBool, ArgumentList
  Value    interface{}  // The value of the interface
  HasValue bool         // A boolean that contains whether the Argument has a value. Populated during argument parsing
}

When specifying an argument in the Arguments() function, only the following attributes should be specified:

  • Name
  • Optional
  • Type

Argument autocompletion

By default, argument autocompletion isn't necessary, so the following function is more than enough:

import "github.com/posener/complete"

func (c *EatCommand) AutocompleteArgs() complete.Predictor {
  return complete.PredictNothing
}

Argument autocompletion is usually not useful except for when the argument is predictable from a list, which is usually only the case when only a single argument is specified or the arguments are a list.

Argument Parsing

Argument parsing involves a boilerplate function. It is not strictly necessary, but makes it easier to handle arguments within the main Run() function of the command.

import "github.com/josegonzalez/cli-skeleton/command"

func (c *EatCommand) ParsedArguments(args []string) (map[string]command.Argument, error) {
  return command.ParseArguments(args, c.Arguments())
}

Flags

Flag specification is fairly straightforward. Values should be stored on the Command struct, and in this case would be denoted in the EatCommand struct specified at the top of the file. While the flag module is supported by mitchellh/cli, cli-skeleton uses github.com/spf13/pflag for a richer flag parsing experience.

import (
  "github.com/josegonzalez/cli-skeleton/command"
  flag "github.com/spf13/pflag"
)

// EatCommand struct respecified for completeness of example
type EatCommand struct {
  Meta

  count int
  color string
}

func (c *EatCommand) FlagSet() *flag.FlagSet {
  f := c.Meta.FlagSet(c.Name(), command.FlagSetClient)
  f.IntVar(&c.count, "count", 1, "number of lollipops to eat")
  f.StringVar(&c.color, "color", "normal", "the color of the lollipops being eaten")
  return f
}

Flags should only be used for optional arguments on the command, or when specifying an argument without a name on the command line would make it less clear as to what is being specified

Flag autocompletion

Flag autocompletion can help in autocompleting both the flags and their potential values. While the github.com/posener/complete library supports a wide range of prediction capabilities, below are some simple examples.

import (
  "github.com/josegonzalez/cli-skeleton/command"
  "github.com/posener/complete"
)

func (c *EatCommand) AutocompleteFlags() complete.Flags {
  return command.MergeAutocompleteFlags(
    c.Meta.AutocompleteFlags(command.FlagSetClient),
    complete.Flags{
      "--count":           complete.PredictAnything,
      "--color":           complete.PredictSet("red", "orange", "yellow", "green", "blue", "purple"),
    },
  )
}

Defining the main Run() codeblock

Once a command has been filled out, the only thing left is defining the Run() command. This is used to parse arguments and flags before actually running the command code.

The following is the Run() command for our example EatCommand.

import (
  "fmt"

  "github.com/josegonzalez/cli-skeleton/command"
)

func (c *EatCommand) Run(args []string) int {
  flags := c.FlagSet()
  flags.Usage = func() { c.Ui.Output(c.Help()) }
  if err := flags.Parse(args); err != nil {
    c.Ui.Error(err.Error())
    c.Ui.Error(command.CommandErrorText(c))
    return 1
  }

  arguments, err := c.ParsedArguments(flags.Args())
  if err != nil {
    c.Ui.Error(err.Error())
    c.Ui.Error(command.CommandErrorText(c))
    return 1
  }

  name := arguments["speed"].StringValue()
  if name == "" {
    name = "normally"
  }

  c.Ui.Output(fmt.Sprintf("Eating %d %v lollipop(s) %v", c.count, c.color, name))

  return 0
}

Note that arguments and flags are not validated - this is an exercise left to the developer.

Errors are output via c.Ui.Error() - showing the CommandErrorText text as appropriate. This allows users to self-discover issues with their execution of the subcommand.

Additionally, the Run() command returns an integer, which represents the response code. 0 should be returned in case of success, with anything between 1 and 255 being an error state. It is recommended that users respect shell exit codes when using anything other than exit codes 0 and 1.

Adding the command to the cli

To add the new command, modify the Commands() function in the main.go to specify the new eat subcommand. The following is the full content of that function, including the necessary import statements:

import (
  "lollipop/commands"

  "github.com/josegonzalez/cli-skeleton/command"
  "github.com/mitchellh/cli"
)

// Returns a list of implemented commands
func Commands(meta command.Meta) map[string]cli.CommandFactory {
  return map[string]cli.CommandFactory{
    "eat": func() (cli.Command, error) {
      return &commands.EatCommand{Meta: meta}, nil
    },
    "version": func() (cli.Command, error) {
      return &command.VersionCommand{Meta: meta}, nil
    },
  }
}

Building

Once everything is put together, the go build -ldflags "-X main.Version=0.1.0" command - with the version modified as desired - can be executed to rebuild the binary. The following is the new help output:

Usage: lollipop [--version] [--help] <command> [<args>]

Available commands are:
    eat        Eats one or more lollipops
    version    Return the version of the binary

If there are any errors in compilation or output, please compare with the code in examples/lollipop.