SamWhited|blog

mellium.im/cli

The last few places I’ve worked one of my biggest annoyances is how complicated creating a new Go based service always was. Even something as simple as a small CLI required code generation, massive amounts of boilerplate, and complex configuration that didn’t feel very Go-like (eg. it had lots of global mutable state). It was so complicated that each of these companies used a CLI helper library that included codegen to write the boilerplate for you. So now you had to learn a new tool to deal with the codegen, still had to deal with a massive and confusing mess of global configuration, and had to deal with the library itself which had an unbelievably large API footprint covering every possible thing you could ever want to do with commands and subcommands and flags and environment variables.

Realistically, the only thing we ever needed was some concept of a “command”, and some flags or environment variables, but the fancy features were there so the developers would try to find a use them. When you have more features that you need, all that happens is that developers bike shed for 30 minutes about whether or not to use the libraries GNU-style flag mode or X-style flag mode (eg. -l, --long or -long). Flags are a thing that get set once in the service configuration and then are more or less never looked at again, and the Go standard library has a built in flag package, but people were insisting on a giant dependency just so they could use their favorite number of dashes or add aliases (just in case one person running the service wants to use one name and one another, I suppose?).

It was all hopeless.


For my own projects, I just wanted something simple and reusable like what the go command does internally, and something that felt consistent with the flags API. So, like a good little developer bad developer with the Go communities characteristic not-invented-here syndrome, I created it. The mellium.im/cli package is simple enough that we can run through its entire use in this post. The package’s API comprises some 8 exported identifiers: two sentinel error values, one type, a constructor function, and 4 methods on the type.

Let’s start with the Command struct, which is really the only necessary part of the package. This will create a cli command such as “help” or “upgrade”, can contain flags, subcommands, and can print its own help output. Commands have a Run function which provides behavior or which can be left off to make them “articles” that are only useful for adding extra help topics.

type Command struct {
    // Usage always starts with the name of the command, followed by a description
    // of its usage. For more information, see the Name method.
    Usage string

    // Description starts with a short, one line description. It can optionally be
    // followed by a blank line and then a longer description or help info.
    Description string

    // Flags is a flag set that provides options that are specific to this
    // subcommand.
    Flags *flag.FlagSet

    // Commands is a set of subcommands.
    Commands []*Command

    // The action to take when this command is executed. The args will be the
    // remaining command line args after all flags have been parsed.
    // Run is normally called by a CommandSet and shouldn't be called directly.
    Run func(c *Command, args ...string) error
}

For example, a simple “about” or “version” command that takes no options and has no subcommands might be as simple as a run function that prints some basic information closed over from a constructor:

func aboutCmd(w io.Writer, version, commit string) *cli.Command {
	if w == nil {
		w = os.Stdout
	}

	return &cli.Command{
		Usage:       "about",
		Description: "Show information about this application.",
		Run: func(c *cli.Command, _ ...string) error {
			_, err := fmt.Fprintf(w, `%s

version:     %s
git hash:    %s
go version:  %s
go compiler: %s
platform:    %s/%s
`,
				os.Args[0],
				version, commit,
				runtime.Version(), runtime.Compiler, runtime.GOOS, runtime.GOARCH)
			return err
		},
	}
}

In this example, version and commit are probably set in your Makefile using global variables and linker flags, something like:

GOFILES!=find . -name '*.go'
VERSION!=git describe --dirty
COMMIT!=git rev-parse --short HEAD 2>/dev/null
LDFLAGS =-X main.Commit=$(COMMIT)
LDFLAGS+=-X main.Version=$(VERSION)

myprogram: go.mod go.sum $(GOFILES)
	go build -o $@ -ldflags "-s -w $(LDFLAGS)"

Nesting commands is as simple as listing subcommands in the in the Commands field, and adding Flag’s is accomplished by setting the Flags field with a new flag.FlagSet and calling its Parse method in the Run function with the arguments that were passed to Run. For example, if we wanted to emulate the git commit command with a few options, we might setup our flags like so:

func commitCmd() *cli.Command {
    commitFlags := flag.NewFlagSet("commit", flag.ContinueOnError)
    help := commitFlags.Bool("h", false, "Print this commands help output…")
    interactive := commitFlags.Bool("interactive", false, "Run commit in interactive mode.")

    return &cli.Command{
        Usage: `commit [-h] [-interactive] …`,
        Description: `Records changes to the repository.

Stores the current contents of the index in a new commit…`,
        Flags: commitFlags,
        Run: func(c *cli.Command, args ...string) error {
            err := commitFlags.Parse()
            if err != nil {
              return err
            }

            if *interactive {
                println("Interactive mode enabled.")
            }
            if *help {
                c.Help(os.Stdout)
            }
            return nil
        },
    }
}

Note that this command also has a description in the appropriate format. The first line will be used wherever a short description is needed (eg. after the commands in a list of subcommands in help output) and the full description will be used when printing the commands help output using its Help method. The name of the command is still taken from the usage line, but we also show some arguments after it. This could also be automatically generated, or simply read “[options]” depending on how you want your help to look.

The last thing worth mentioning is the Help function. This built in constructor returns a command called “help” that parses its arguments and calls the Help method on any commands it finds that match the arguments. This can be used as the root command (or you can pass it a separate command set to use) to emulate the behavior of many commands such as the go command and print help output when writing something like go help mod or go help mod init. If the help command is run without any arguments, a list of subcommands (along with their short descriptions) and flags (if any) is printed.

Adding a help command and our commit command to a new root flag set lets us have multiple top-level commands with a robust help system built in:

func main() {
  cmds := &cli.Command{
    Usage: os.Args[0],
    Flags: flag.CommandLine,
  }
  cmds.Commands = []*cli.Command{
    cli.Help(cmds)
    commitCmd(),
    // Other subcommands could be added here.
  }

  err = cmds.Exec(flag.Args()...)
  if err != nil {
    // Replace with your preferred method of error handling.
    panic(err)
  }
}

As you can see, the cli module is a simple alternative to more complex configuration management and CLI libraries: it’s easy to use, has a minimal API surface, and has no dependencies outside the Go standard library. It is still possible that the API will change somewhat before 1.0, but I don’t plan on making any substantial changes other than adding a few more common commands similar to the Help function. I hope you’ll try it out in your own projects and get as much use out of it as I do on a day to day basis.

For more examples see the documentation.