An opinionated framework for building golang cli tools on top of mitchellh/cli.
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.
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 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.
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"
}
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"
}
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.
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 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
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 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())
}
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 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"),
},
)
}
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
.
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
},
}
}
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
.