cli

package module
v0.0.0-...-3732873 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 25, 2021 License: MIT Imports: 7 Imported by: 51

README

go-cli

Rite-of-passage-style Go command line parser.

CLIs are awesome. Most options libraries aren't. go-cli doesn't attempt to change that, it just tries to focus on doing one thing well.

Things you will not find in go-cli:

  • Magical bash/zsh Auto-completion support
  • Usage generation
  • Option help string
  • Option defaults

Things you will find in go-cli:

  • A dead-simple, tagged-struct approach to options
  • A rudimentary sub-command recognizer
  • A flexible argument processor

Usage

Should be pretty simple.

import (
  "fmt"
  "os"

  "github.com/jhunt/go-cli"
)

type Options struct {
  Help     bool   `cli:"-h, --help"`
  Version  bool   `cli:"-v, --version"`
  Insecure bool   `cli:"-k, --insecure, --no-insecure"`
  URL      string `cli:"-U, --url"`

  Gen struct {
    Length int     `cli:"-l, --length"`
    Policy string  `cli:"-p, --policy"`
  } `cli:"gen"`
}

func main() {
  var options Options
  options.Gen.Length = 48 // a default

  command, args, err := cli.Parse(&options)
  if err != nil {
    fmt.Fprintf(os.Stderr, "!!! %s\n", err)
    os.Exit(1)
  }

  if command == "gen" {
    fmt.Printf("generating a password %d characters long", options.Gen.Length)
    // ...
  }
}

Repeat Flags

If you assign a cli:"..." tag to a slice ([]thing) in your options structure, go-cli allows users to specify that flag multiple times, and will combine all of the given values, in order, into a list and assign that to the slice.

Here, I have to point out that you can supply a default value for a repeat flag by assigning to the slice before telling go-cli about it, but the semantics of override bear some thought.

The easiest case to implement is that command-line flags append to the default value. That works great for the no-defaults case, since appending to an empty list just allocates a new list. But that means that users of the program can never escape the default choices made by the programmer.

Instead, go-cli uses the default as-is, until the first time it sees an instance of that flag on the actual command line. At that point, it chucks the default out the window, allocates a fresh slice, and begins assembling values.

So, remember: defaults for repeat flags get thrown out upon override!

Reusing Flags

You can reuse option flags, both short and long, as long as it is provable unambiguous where and when callers can use the flag. Practically, this means:

  1. You cannot reuse flags defined "above" you
  2. You cannot reuse flags on the same level as you

This allows go-cli to recognize arguments for a single level (global, sub-command, sub-sub-command, ad infinitum) at any point after the "beginning" of that level.

Let's look at some examples, shall we?

type Options struct {
  Help   bool           `cli:"-h, --help"`

  List struct {
    LongForm   bool     `cli:"-l, --long"`
    All        bool     `cli:"-a, --all"`
  } `cli:"list, ls"`

  Create struct {
    Archive    string   `cli:"-a, "--archive"`
    Name       string   `cli:"-n, "--name"`
  } `cli:"new"`
}

Here, -h / --help is global option. It can appear anywhere in the command line invocation, and it has the same semantics everywhere (namely, to show the help or something).

On the contrary, -l / --long only makes sense after the list sub-command. If encountered before list, it's an unrecognized flag.

The up-shot of this is that a user of your CLI can do this:

$ ./foo -h
$ ./foo list -h
$ ./foo ls -h
$ ./foo list -h --all
$ ./foo -h list -h --all -h

This is why you can't override the -h / --help flag on a per-command basis -- it's just too confusing to end users (including the author of go-cli).

If you look closely, you'll notice that both list and new define a -a short option. What gives? Didn't this guy just get done saying that you can't override flags??

It's cool. It's going to be alright. There's not much chance of a user conflating the two -a use cases - list -a lists everything, but new -a name sets an archive name. And since -a doesn't exist at the global level ("above"), you can't do this:

$ ./foo -a list                 # this is bad

So, without any ambiguity, go-cli is perfectly happy to let you overload the meaning of -a. Whether you should, is entirely up to you.

Halting Argument Processing

If you end the command name, or one of its aliases, with an exclamation point, go-cli will stop parsing all options after the command name is seen. That means that if you have this structure:

type Opt struct {
  Verbose bool `cli:"-v, --verbose"`
  Debug   bool `cli:"-D, --debug"`

  List struct {
    All bool `cli:"-a, --all"`
  } `cli:"list"`

  Exec struct {
  } `cli:"exec!"`
}

And a command-line like this:

$ ./cli -v exec ls -lah

The argument processing stops as soon as the exec token is seen, and the arguments will be given back as [ls, -lah]. This can be helpful for cases where you want to examine the flags yourself, or pass them to another parser, or echo them as-is.

Chained Commands

A curiously powerful command-line paradigm involves abusing the -- signifier to chain commands. That is, within a single executed process, do a whole bunch of sub-commands, like this:

$ ./cli -t prod --format silent -k \
      set system.cores.available 4 \
   -- set system.cores.usable 2 --if-missing \
   -- build vm --name new-vm --ip 10.40.0.5/24 \
   -- list --format fancy --all

go-cli tries very hard to make this style of CLI interaction both easy to program, and simple and unsurprising to use. A few things to keep in mind:

Global options specified before any sub-commands will be treated as truly global; every single sub-command will inherit the values set globally. That's not to say each sub-command is stuck with what was specified at the global level. Nope. Sub-commands can provide their own values for global options. The list command in the above example undoes the global --format with it's own definition as 'fancy'.

Options set for a given sub-command only affect that instance of that sub-command. This mimics how normal shells operate. Running ls -r followed by an rm isn't going to magically cause your rm to become recursive. In the example above, the second set sub-command runs with the --if-missing option set to true, but that doesn't affect the first set (nor any future sets).

Similarly, overriding a global option for a sub-command only persists for the scope of that sub-command.

The idiom for supporting chained sub-command calls is short and sweet:

p, err := cli.NewParser(&opts, os.Args)
if err != nil {
  panic(err)
}

for p.Next() {
  // dispatch on the value of p.Command and p.Args
}

if err = p.Error(); err != nil {
  panic(err)
}

Error checking is very important here; we check errors in two places: when we create the parser via cli.NewParser(), and once we stop processing chained commands. The former region of code can error if global option parsing fails (unrecognized flag, missing value argument, etc). The latter can fail if sub-command option parsing fails (unrecognized sub-command, bad flag, missing value, etc). If you skip either case for error checking, you are doing your users a great disservice.

The loop in the middle is the workhorse of the idiom. p.Next() will return true as long as it finds the next sub-command to run. Once it runs out of chained sub-commands, or encounters an error, it returns false.

Inside the body of the loop, you can access p.Command to get the full, space-separated name of the sub-command to run. Aliases (i.e. 'ls' in cli:"list, ls") will be resolved to the first name in the tag list (here, "list"). p.Args will give you the list of positional arguments, in the order they were specified, with all of the -s and --style flags removed.

Note that any changes you make to the option structure between subsequent calls to Next() will be lost by virtue of the snapshotting / reset features that make this whole magic show work. The same goes for changes between calling NewParser() and the first Next() call.

Contributing

This code is licensed MIT. Enjoy.

If you find a bug, please raise a Github Issue first, before submitting a PR.

When you do work up a patch, keep in mind that we have a fairly extensive test suite in cli_test.go. I don't care all that much about code coverage, but we do have >90% C0 code coverage on the current tests suite, and I'd like to keep it that way.

(That's not to say we've caught 90% of the bugs, but it's better than nothin')

Happy Hacking!

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Parse

func Parse(thing interface{}) (string, []string, error)

Parse looks through os.Args, and returns the sub-command name

(or "" for none), the remaining positional arguments, and any
error that was encountered.

func ParseArgs

func ParseArgs(thing interface{}, args []string) (string, []string, error)

ParseArgs is like Parse(), except that it operates on an explicit

list of arguments, instead of implicitly using os.Args.

Types

type Parser

type Parser struct {
	Command string
	Args    []string
	// contains filtered or unexported fields
}

func NewParser

func NewParser(thing interface{}, args []string) (*Parser, error)

func (*Parser) Error

func (p *Parser) Error() error

func (*Parser) Next

func (p *Parser) Next() bool

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL