cli

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 16, 2020 License: MIT Imports: 11 Imported by: 3

README

cli

PkgGoDev

github.com/ucarion/cli is a Golang package for writing delightful, Unix-style command-line tools in a type-safe way. With cli, you can define:

  • Commands and sub-commands (git commit, git remote, git remote set-url)
  • Short-style flags (-f, -o json, -ojson, -abc)
  • Long-style flags (--force, --output json, --output=json)
  • "Positional" arguments (mv <from> <to>, cat <files...>)

You will automatically get:

  • -h and --help usage messages
  • Man page generation (e.g. an automatically-generated man my-cool-tool)
  • Bash and Zsh tab autocompletion (e.g. mytool --f<TAB> expands into mytool --force)

Best of all, github.com/ucarion/cli gives you all of this while keeping a dirt-simple interface. Here's an unabridged, working tool built with cli:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	FirstName string `cli:"--first-name"`
	LastName  string `cli:"--last-name"`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Println("hello", args.FirstName, args.LastName)
		return nil
	})
}

This is examples/basic in this repo, which you can run as:

$ go run ./examples/basic/... --first-name=john --last-name doe
hello john doe

Installation

To use cli in your project, run:

go get github.com/ucarion/cli

Demo

As an end-to-end demonstration of how you can use cli to build a tool with subcommands, flags, arguments, --help text, man pages, and Bash/Zsh completions, all with automated releases with GitHub actions and an easy-to-install brew formula for macOS users, check out:

https://github.com/ucarion/fakegit

You can use fakegit to see what the most complex cli applications look like, and you use it as a starting point in your own applications.

Usage

For detailed, specific documentation on exactly what you can pass to cli.Run, see the godocs for github.com/ucarion/cli. This section will work more as a cookbook, showing you working programs that you can work off of.

At a high level, you use cli by passing cli.Run a context and a set of functions. cli requires that every function you pass to cli.Run looks like:

func (context.Context, T) error

Where T has to be a struct. cli will use reflection to determine the options and arguments your command or sub-command expects. The rest of this section will show examples of how you can use all of cli's features.

Accepting Options ("Flags")

To accept options (also called "flags"), mark a field in your struct with a tag called cli. You can give your option a "short" name (e.g. -f), a "long" name (e.g. --force), or both.

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Force   bool   `cli:"-f,--force"`
	Output  string `cli:"-o,--output"`
	N       int    `cli:"-n"`
	RFC3339 bool   `cli:"--rfc3339"`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/options in this repo, which you can run as:

$ go run ./examples/options/...
main.args{Force:false, Output:"", N:0, RFC3339:false}

$ go run ./examples/options/... --force --output json --rfc3339 -n 5
main.args{Force:true, Output:"json", N:5, RFC3339:true}

cli supports the full set of "standard" Unix command-line conventions, so this also works, like it would with most tools in modern Linux distributions:

$ go run ./examples/options/... -fn5 --rfc3339 --output=json
main.args{Force:true, Output:"json", N:5, RFC3339:true}
Accepting "Positional" Arguments

To accept arguments that aren't options, like the pattern and trailing list of files in grep pattern files..., then tag your fields with cli, but don't include a leading - or --. If your tag's value ends with ..., then all "leftover" / "trailing" arguments will go into that field.

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Foo string   `cli:"foo"`
	Bar string   `cli:"bar"`
	Baz []string `cli:"baz..."`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/posargs in this repo, which you can run as:

$ go run ./examples/posargs/... a b
main.args{Foo:"a", Bar:"b", Baz:[]string(nil)}

$ go run ./examples/posargs/... a b c d e
main.args{Foo:"a", Bar:"b", Baz:[]string{"c", "d", "e"}}
Mixing Options and Arguments

As a relatively straightforward extension of the previous two examples, you can accept both options ("flags") and "positional" arguments at once:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Force   bool     `cli:"-f,--force"`
	Output  string   `cli:"-o,--output"`
	N       int      `cli:"-n"`
	RFC3339 bool     `cli:"--rfc3339"`
	Foo     string   `cli:"foo"`
	Bar     string   `cli:"bar"`
	Baz     []string `cli:"baz..."`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/argsandopts in this repo, which you can run as:

$ go run ./examples/argsandopts/... --force --output=json a b c d e --rfc3339
main.args{Force:true, Output:"json", N:0, RFC3339:true, Foo:"a", Bar:"b", Baz:[]string{"c", "d", "e"}}

As is standard with Unix tools, cli will treat -- in the input args as a "end of flags" indicator. So, for instance, if you wanted --rfc3339 above to be treated as an argument instead of a flag, you could do:

$ go run ./examples/argsandopts/... --force --output=json a b c d e -- --rfc3339
main.args{Force:true, Output:"json", N:0, RFC3339:false, Foo:"a", Bar:"b", Baz:[]string{"c", "d", "e", "--rfc3339"}}
Defining Commands and Sub-Commands

If you mark one of the fields of your struct like this:

type bazArgs struct {
    ParentArgs parentArgs `cli:"baz,subcmd"`
}

That means that you're defining a sub-command called baz, and it's a sub-command of the parentArgs type. When you pass a set of functions to cli.Run, cli will use these cli:"xxx,subcmd" tags to discover your "tree" of commands.

So, for instance, if you want to have a CLI tool that has a get and set subcommands, you can do that like this:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type rootArgs struct {
	Username string `cli:"--username"`
	Password string `cli:"--password"`
}

func main() {
	cli.Run(context.Background(), get, set)
}

type getArgs struct {
	RootArgs rootArgs `cli:"get,subcmd"`
	Key      string   `cli:"key"`
}

func get(ctx context.Context, args getArgs) error {
	fmt.Printf("get %#v\n", args)
	return nil
}

type setArgs struct {
	RootArgs rootArgs `cli:"set,subcmd"`
	Key      string   `cli:"key"`
	Value    string   `cli:"value"`
}

func set(ctx context.Context, args setArgs) error {
	fmt.Printf("set %#v\n", args)
	return nil
}

This is examples/subcmds in this repo, which you can run as:

$ go run ./examples/subcmds/... --username foo --password bar get xxx
get main.getArgs{RootArgs:main.rootArgs{Username:"foo", Password:"bar"}, Key:"xxx"}

$ go run ./examples/subcmds/... --username foo --password bar set xxx yyy
set main.setArgs{RootArgs:main.rootArgs{Username:"foo", Password:"bar"}, Key:"xxx", Value:"yyy"}

The pattern above of pointing to your parent config type via cli:"xxx,subcmd" tags can work recursively. For instance, if you wanted to add config get and config set subcommands to the above example, you could do:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type rootArgs struct {
	Username string `cli:"--username"`
	Password string `cli:"--password"`
}

func main() {
	cli.Run(context.Background(), get, set, getConfig, setConfig)
}

type getArgs struct {
	RootArgs rootArgs `cli:"get,subcmd"`
	Key      string   `cli:"key"`
}

func get(ctx context.Context, args getArgs) error {
	fmt.Printf("get %#v\n", args)
	return nil
}

type setArgs struct {
	RootArgs rootArgs `cli:"set,subcmd"`
	Key      string   `cli:"key"`
	Value    string   `cli:"value"`
}

func set(ctx context.Context, args setArgs) error {
	fmt.Printf("set %#v\n", args)
	return nil
}

type configArgs struct {
	RootArgs   rootArgs `cli:"config,subcmd"`
	ConfigFile string   `cli:"--config-file"`
}

type getConfigArgs struct {
	ConfigArgs configArgs `cli:"get,subcmd"`
	Key        string     `cli:"key"`
}

func getConfig(ctx context.Context, args getConfigArgs) error {
	fmt.Printf("get config %#v\n", args)
	return nil
}

type setConfigArgs struct {
	ConfigArgs configArgs `cli:"set,subcmd"`
	Key        string     `cli:"key"`
	Value      string     `cli:"value"`
}

func setConfig(ctx context.Context, args setConfigArgs) error {
	fmt.Printf("set config %#v\n", args)
	return nil
}

This is examples/nestedsubcmds in this repo, which you can run as:

$ go run ./examples/nestedsubcmds/... --username foo --password bar get xxx
get main.getArgs{RootArgs:main.rootArgs{Username:"foo", Password:"bar"}, Key:"xxx"}

$ go run ./examples/nestedsubcmds/... --username foo --password bar set xxx yyy
set main.setArgs{RootArgs:main.rootArgs{Username:"foo", Password:"bar"}, Key:"xxx", Value:"yyy"}

$ go run ./examples/nestedsubcmds/... config --config-file=config.txt get xxx
get config main.getConfigArgs{ConfigArgs:main.configArgs{RootArgs:main.rootArgs{Username:"", Password:""}, ConfigFile:"config.txt"}, Key:"xxx"}

$ go run ./examples/nestedsubcmds/... config --config-file=config.txt set xxx yyy
set config main.setConfigArgs{ConfigArgs:main.configArgs{RootArgs:main.rootArgs{Username:"", Password:""}, ConfigFile:"config.txt"}, Key:"xxx", Value:"yyy"}

You may notice that in the above example, configArgs is used as the parent type to both getConfigArgs and putConfigArgs, but is never directly used by any function you pass to cli.Run. When you do that, that indicates to cli that you don't want the config subcommand to really "run". So this:

$ go run ./examples/nestedsubcmds/... config

Just outputs help text, showing users that config takes a --config-file, and that its subcommands are get and set:

usage: /var/folders/.../exe/nestedsubcmds config [<options>] get|set

        --config-file <string>
    -h, --help                    display this help and exit
Customizing Help Text

By default, cli will generate a help text for you, and it will be displayed if the user passes -h or --help. By default, the help text looks like (see examples/argsandopts in this repo for where these flags and args are from):

$ go run ./examples/argsandopts/... --help
usage: /var/folders/.../exe/argsandopts [<options>] foo bar baz...

    -f, --force
    -o, --output <string>
    -n <int>
        --rfc3339
    -h, --help               display this help and exit

The long /var/folders/... stuff is an artifact of how go run works, where it first compiles the program into a temp directory with an esoteric name. cli's auto-generated help will figure out your program's name from os.Args[0], as is convention in Unix tools.

To get a less weird-looking name after usage: in the help text, try compiling the program yourself first:

$ go build ./examples/argsandopts/...
$ ./argsandopts --help
usage: ./argsandopts [<options>] foo bar baz...

    -f, --force
    -o, --output <string>
    -n <int>
        --rfc3339
    -h, --help               display this help and exit

There are a couple of things you can do to customize the help text:

  • If you set a ExtendedDescription() string method on your args struct, then cli will call it, and use it as a description for your command.
  • If you set a usage tag on a field, that will be shown next to the flag.
  • If you set a value tag on a field, that will be shown instead of the <string> or <int> in the default output above.

Furthermore, if you define either -h or --help flag yourself, then cli will leave it be. If you define both -h and --help, then cli will not show help for you at all. This is useful mostly if you're writing a tool like ls or du, where -h means "human-readable".

Putting all of that together, you can do:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Human   bool     `cli:"-h" usage:"show human-readable output"`
	Force   bool     `cli:"-f,--force" usage:"do the thing no matter what"`
	Output  string   `cli:"-o,--output" value:"format" usage:"the format to output in"`
	N       int      `cli:"-n" value:"times" usage:"how many times to do the thing"`
	RFC3339 bool     `cli:"--rfc3339" usage:"use rfc3339 timestamps"`
	Foo     string   `cli:"foo"`
	Bar     string   `cli:"bar"`
	Baz     []string `cli:"baz..."`
}

func (_ args) ExtendedDescription() string {
	return "This is just a program that shows you how to customize help text."
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/customhelptext in this repo, which you can run as:

$ go run ./examples/customhelptext/... --help
usage: /var/folders/.../customhelptext [<options>] foo bar baz...

This is just a program that shows you how to customize help text.

    -h                       show human-readable output
    -f, --force              do the thing no matter what
    -o, --output <format>    the format to output in
    -n <times>               how many times to do the thing
        --rfc3339            use rfc3339 timestamps
        --help               display this help and exit
Generating and Customizing Man Pages

If you set an environment variable called UCARION_CLI_GENERATE_MAN, then cli.Run will generate man pages instead of running your program as usual. The value of UCARION_CLI_GENERATE_MAN is the directory where the man pages will be generated; each sub-command will get its own man page.

Aside: it's called UCARION_CLI_GENERATE_MAN to make it more obvious what is reading the environment variable. The goal was to use a name that made it obvious that something called "ucarion cli" is generating a man page, which if you put in Google will hopefully lead you to the docs you are currently reading.

By default, the man pages look like (see examples/argsandopts in this repo for where these flags and args are from):

$ UCARION_CLI_GENERATE_MAN="." go run ./examples/argsandopts/...
$ man ./argsandopts.1
ARGSANDOPTS(1)                                                  ARGSANDOPTS(1)



NAME
       argsandopts

SYNOPSIS
       argsandopts [<options>] foo bar baz...

DESCRIPTION
OPTIONS
       -f, --force


       -o, --output <string>


       -n <int>


       --rfc3339


       -h, --help
              Display help message and exit.



                                                                ARGSANDOPTS(1)

There are a couple of things you can do to customize the help text:

  • If you set a Description() string method on your args struct, then cli will call it, and the return value will appear after your program's name in the "Name" section.

    By convention, you should use a short, lower-case string for the description. For example, ls's description is:

    ls - list directory contents
    
  • If you set a ExtendedDescription() string method on your args struct, then cli will call it, and use the return value as the "Description" for your command. This method is also used in help text, described in the previous section.

  • If you set a value tag on a field, that will be shown instead of the <string> or <int> in the default output above.

  • If you have a field called XXX in your struct (this is the "actual" name for the field not what you put in the cli tag), and if you have a method called ExtendedUsage_XXX() string, then cli will call it, and use the return value as the usage for the flag in man pages. This only applies to flags; there is no corresponding conventional way to describe "positional" arguments in man pages.

Putting all of that together, you can do:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Force   bool     `cli:"-f,--force"`
	Output  string   `cli:"-o,--output" value:"format"`
	N       int      `cli:"-n" value:"times"`
	RFC3339 bool     `cli:"--rfc3339"`
	Foo     string   `cli:"foo"`
	Bar     string   `cli:"bar"`
	Baz     []string `cli:"baz..."`
}

func (_ args) Description() string {
	return "dummy command with custom man page"
}

func (_ args) ExtendedDescription() string {
	return "This is just a program that shows you how to customize man pages."
}

func (_ args) ExtendedUsage_Force() string {
	return "Do the thing no matter what."
}

func (_ args) ExtendedUsage_Output() string {
	return "The format to output in."
}

func (_ args) ExtendedUsage_N() string {
	return "How many times to do the thing."
}

func (_ args) ExtendedUsage_RFC3339() string {
	return "Use RFC3339 timestamps."
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/custommanpage in this repo, which you can run as:

$ UCARION_CLI_GENERATE_MAN="." go run ./examples/custommanpage/...
$ man ./custommanpage.1
CUSTOMMANPAGE(1)                                              CUSTOMMANPAGE(1)



NAME
       custommanpage - dummy command with custom man page

SYNOPSIS
       custommanpage [<options>] foo bar baz...

DESCRIPTION
       This is just a program that shows you how to customize man pages.

OPTIONS
       -f, --force
              Do the thing no matter what.

       -o, --output <format>
              The format to output in.

       -n <times>
              How many times to do the thing.

       --rfc3339
              Use RFC3339 timestamps.

       -h, --help
              Display help message and exit.



                                                              CUSTOMMANPAGE(1)
Generating Auto-Completions

The Bash and Zsh shells both support "completion" scripts that the shell will run when you press "tab" (the Fish shell uses man pages to populate completions, so the above section covers that) If you're not familiar with how Bash/Zsh completion works, here's a crash course:

  • You can register a completion script with Bash/Zsh using the builtin complete. In Bash, this builtin is available out of the box. In Zsh, you first have to run:

    autoload -U +X compinit && compinit
    autoload -U +X bashcompinit && bashcompinit
    
  • Once you've registered a completion script, then when Bash/Zsh needs to generate completions, it will call the relevant completion script with the environment vars COMP_LINE (containing the line typed so far) and COMP_CWORD (containing the index of the word to complete).

Typically, programs that support completion ship with a Bash or Zsh script alongside their main program, and they re-implement (a subset of) their flag parsing in a shell script in order to generate completions.

cli takes a different approach. With cli, every program is its own completion script. If cli.Run sees that the COMP_LINE and COMP_CWORD environment variables are present, then cli.Run will output a set of completions instead of running your program as usual.

For instance, to see what completions look like by default (see examples/argsandopts in this repo for where these flags and args are from):

$ go build ./examples/argsandopts/...
$ complete -o bashdefault -o default -C ./argsandopts argsandopts
$ ./argsandopts -<TAB>
--force    --output   --rfc3339  -n

To emphasize how non-magic this is, you could also get those completions by running ./argsandopts yourself:

$ COMP_LINE="./argsandopts -" COMP_CWORD="1" ./argsandopts
--force
--output
--rfc3339
-n

If your program has sub-commands, cli.Run will offer those sub-commands in its completions. For instance (see examples/nestedsubcmds in this repo for where these commands and flags are from):

$ ./nestedsubcmds <TAB>
--password  --username  config      get         set
$ ./nestedsubcmds config <TAB>
--config-file  get            set
Customizing Auto-Completions

By default, cli will not offer any autocompletions for the value of a flag or a positional argument. As a result of -o flags we passed to complete in the previous section, Bash/Zsh will fall back to its default behavior, which is to list files in the current directory:

$ ./argsandopts --output <TAB>
README.md       argsandopts*    cli.go          ... (etc)

If you have a field called XXX in your struct (this is the "actual" name for the field not what you put in the cli tag), and if you have a method called Autocomplete_XXX() []string, then cli will call it, and will use the return value as the suggested values for the flag or argument.

Crucially, your Autocomplete_XXX will be called after cli tries to parse the flags the user has provided. That means that if your completions for a flag or argument are a function of other flags, you can read those flag values to figure out what to complete. This is especially useful if you have some sort of --config-file or --username/--password flags that you need in order to authenticate with a system, and then poll that system to figure out your completions.

Putting all of that together, you can do:

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/ucarion/cli"
)

type args struct {
	Foo string `cli:"--foo"`
	Bar string `cli:"--bar"`
}

func (a args) Autocomplete_Bar() []string {
	if a.Foo == "" {
		return nil
	}

	return []string{strings.ToUpper(a.Foo), strings.ToLower(a.Foo)}
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/customcompletions in this repo, which you can run as:

$ go build ./examples/customcompletions/...
$ complete -o bashdefault -o default -C ./customcompletions customcompletions
$ ./customcompletions --foo hElLo --bar <TAB>
HELLO  hello
Advanced Flag/Arg Use-Cases

This section will go through some more advanced use-cases for things you can do with flags or arguments.

Passing a flag multiple times

If you mark a field as a flag, and that field's type is a slice (e.g. []string, []int, etc.), then cli will let users pass that flag multiple times. For example:

package main

import (
	"context"
	"fmt"

	"github.com/ucarion/cli"
)

type args struct {
	Names []string `cli:"--name"`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/repeatedflag in this repo, which you can run as:

$ go run ./examples/repeatedflag/...
main.args{Names:[]string(nil)}

$ go run ./examples/repeatedflag/... --name foo
main.args{Names:[]string{"foo"}}

$ go run ./examples/repeatedflag/... --name foo --name bar --name baz
main.args{Names:[]string{"foo", "bar", "baz"}}
Optionally-taking-value options

Some tools support options that can be provided either in the "boolean" way (e.g. mycmd --force) or in the "takes-a-value" way (e.g. mycmd --output=json). For instance, in git the --color flag, when it's supported, can be provided with or without a value:

# These two do the same thing
git show HEAD --color
git show HEAD --color=auto

# This is different
git show HEAD --color=never

cli supports this use-case. If you mark a field as a flag, and that field's type is a pointer (e.g. *string, *int, etc.), then cli will let users pass that flag with or without a value.

If users don't pass the flag at all, the field will remain nil when it's provided to you. If the users set the flag, but don't provide a value, then the field will be instantiated as a pointer to the zero value of the type (e.g. for *string, it would be a pointer to an empty string). If users set the flag and provide a value, the field will be a pointer to that parsed value.

For example:

package main

import (
	"context"
	"encoding/json"
	"os"

	"github.com/ucarion/cli"
)

type args struct {
	Color *string `cli:"--color"`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		// We display this as json to avoid just printing a pointer here.
		return json.NewEncoder(os.Stdout).Encode(args)
	})
}

This is examples/optionallytakingvalue in this repo, which you can run as:

$ go run ./examples/optionallytakingvalue/...
{"Color":null}
$ go run ./examples/optionallytakingvalue/... --color
{"Color":""}
$ go run ./examples/optionallytakingvalue/... --color=never
{"Color":"never"}

Optionally-taking-value options like this can be confusing to users. For instance, this is not a valid invocation, because you're not allowed to put a space between an optionally-taking-value option and its value:

$ go run ./examples/optionallytakingvalue/... --color never
unexpected argument: never

What's going on here is that cli, in accordance with Unix convention, parses --color as not having a value passed, and assumes never is a non-option argument. But examples/optionallytakingvalue doesn't define any non-option arguments, so cli reports an error to the user for the unexpected argument.

Custom parameter types

Out of the box, cli supports all of Go's number types (including floats, signed and unsigned ints, but not complex numbers), as well as bools and strings, for any option or argument. If you'd like to parse options into a different type, you can:

  1. Just do that parsing yourself, from within the function you pass to cli.Run, or

  2. Make sure the type implements the standard libary's encoding.TextUnmarshaler interface, which looks like this:

    type TextUnmarshaler interface {
    	UnmarshalText(text []byte) error
    }
    

For example, the standard library's net.IP type implements TextUnmarshaler, so you can do this:

package main

import (
	"context"
	"fmt"
	"net"

	"github.com/ucarion/cli"
)

type args struct {
	Foo net.IP `cli:"--foo"`
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/ipparam in this repo, which you can run as:

$ go run ./examples/ipparam/... --foo asdf
--foo: invalid IP address: asdf
exit status 1

go run ./examples/ipparam/... --foo 127.0.0.1
main.args{Foo:net.IP{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff, 0xff, 0x7f, 0x0, 0x0, 0x1}}

Alternatively, you can implement TextUnmarshaler on your own. For instance, here's a basic TextUnmarshaler implementation for a type that supports parsing numbers with suffixes like "B", "KB", "GB", "TB":

package main

import (
	"context"
	"fmt"
	"strconv"
	"strings"

	"github.com/ucarion/cli"
)

type args struct {
	Foo bytes `cli:"--foo"`
}

type bytes int

func (b *bytes) UnmarshalText(text []byte) error {
	s := string(text)

	var base string
	var factor int

	switch {
	case strings.HasSuffix(s, "KB"):
		base = s[:len(s)-2]
		factor = 1024
	case strings.HasSuffix(s, "MB"):
		base = s[:len(s)-2]
		factor = 1024 * 1024
	case strings.HasSuffix(s, "GB"):
		base = s[:len(s)-2]
		factor = 1024 * 1024 * 1024
	case strings.HasSuffix(s, "TB"):
		base = s[:len(s)-2]
		factor = 1024 * 1024 * 1024 * 1024
	case strings.HasSuffix(s, "B"):
		base = s[:len(s)-1]
		factor = 1
	default:
		return fmt.Errorf("missing units suffix (must be one of B, KB, MB, GB, TB): %s", s)
	}

	n, err := strconv.ParseInt(base, 0, 0)
	if err != nil {
		return err
	}

	*b = bytes(int(n) * factor)
	return nil
}

func main() {
	cli.Run(context.Background(), func(ctx context.Context, args args) error {
		fmt.Printf("%#v\n", args)
		return nil
	})
}

This is examples/customtype in this repo, which you can run as:

$ go run ./examples/customtype/... --foo asdf
--foo: missing units suffix (must be one of B, KB, MB, GB, TB): asdf
exit status 1

$ go run ./examples/customtype/... --foo 2B
main.args{Foo:2}

$ go run ./examples/customtype/... --foo 2KB
main.args{Foo:2048}

$ go run ./examples/customtype/... --foo 2GB
main.args{Foo:2147483648}

$ go run ./examples/customtype/... --foo 2TB
main.args{Foo:2199023255552}

With such a design, you could use this custom bytes type for any CLI parameter that you want formatted with B/KB/GB/TB suffixes. You could even publish a package with human-friendly types that implement TextUnmarshaler, like (for example) github.com/segmentio/cli/human, and then re-use those types across multiple projects.

Ultimately, cli relies on TextUnmarshaler in order to be broadly compatible with the Golang ecosystem and standard library, and does not bundle implementations of TextUnmarshaler so as to avoid picking winners and losers in the space of "human-friendly" string-parsing libraries.

Documentation

Overview

Package cli implements Unix-style arg parsing and evaluation.

The documentation here describes the contract that cli upholds. For more high-level "cookbook"-style documentation, see the cli README, available online at:

https://github.com/ucarion/cli

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(ctx context.Context, funcs ...interface{})

Run constructs and executes a command tree from a set of functions.

Command Trees

A command tree is cli's representation of a CLI application. The funcs passed to Run are used to construct a command tree; if the funcs cannot be constructed into a command tree, then Run panics.

Each of the values in funcs must be of the type:

func(context.Context, T) error

Where T is a struct, called a "config struct".

Run constructs a directed graph of such config structs, where "child" config structs point to their single "parent" config struct, and also keep track of the child's name. Config structs indicate their parent and name using the cli tag, described further below.

There must be exactly one config struct that does not have a parent config struct; this config struct is the "root" of the command-tree. The root config struct is not explicitly named; its name is derived from the first element of os.Args.

Run discovers the set of config structs by first using each of the second arguments to the elements of funcs as an entry-point. Run will then iteratively walk the set of config structs, discovering a full command tree. In other words: for a config struct to be part of the command tree, it either needs to be directly used by one of the elements of funcs, or it needs to be used reachable by following the parents of one of those directly-used structs.

If a config struct is directly used by one of the elements of funcs, then it is said to be "runnable". All other config structs are said to be "non-runnable".

To illustrate these concepts with an example, consider a tool like a subset of git, the version control tool. These are all valid invocations of "git":

git commit
git remote
git remote add
git worktree add

But these are not valid invocations (they will output a help message instead of actually performing any action):

git
git worktree

In cli's model, you can represent this as a command-tree like so:

git (non-runnable):
    commit (runnable)
    remote (runnable):
        add (runnable)
    worktree (non-runnable):
        add (runnable)

Config Structs

The previous section describes how config structs are discovered. This section will describe how to define a config struct, and elaborate on the data that can be associated with a config struct.

A config struct must be a Go struct. Each config struct may define a set of options (the stdlib flag package calls these "flags"), arguments (sometimes called "positional arguments"), and a single set of "trailing arguments". Additionally, each config struct may have up to one "parent" config type; if a config struct has a parent config type, it must also have a name. All of these attributes are defined using the struct tag with the key "cli".

If a field of a config struct is not tagged with "cli", then Run will ignore it. The "cli" tag may be used in a few forms.

The parent form is indicated by setting "cli" to "xxx,subcmd", where "xxx" is the name of the sub-command. The type of the field is the parent config type. For instance, this indicates that a config struct is named "bar", and its parent type is parentConfigType:

Foo parentConfigStruct `cli:"bar,subcmd"`

The option form is indicated by setting "cli" to one of "-x", "--yyy", or "-x,--yyy", where "x" is the "short" name of the option and "yyy" is the "long" name of the option. For example:

// This is an option with only a short name.
Force bool `cli:"-f"`

// This is an option with only a long name.
RFC3339 bool `cli:"--rfc3339"`

// This is an option with both a short name and a long name.
Verbose bool `cli:"-v,--verbose"

The argument form is indicated by setting "cli" to "xxx", where "xxx" is the name of the argument. For example:

// This is a argument called "path".
Path string `cli:"path"`

The trailing argument form is indicated by setting "cli" to "xxx...", where "xxx" is the name of the trailing arguments. For example:

// This is a set of trailing arguments called "files"
Files []string `cli:"files..."`

Put together, options, arguments, and trailing arguments construct a data model familiar to users of Unix-like tools. For instance, if you have a tool which you can invoke as (where "[...]" means something is optional):

mytool [-f] [--rfc3339] [-v | --verbose] path [file1 [file2 [file3 ...]]]

You could represent this with "cli" tags as:

type args struct {
    Force   bool     `cli:"-f"`
    RFC3339 bool     `cli:"--rfc3339"`
    Verbose bool     `cli:"-v,--verbose"
    Path    string   `cli:"path"`
    Files   []string `cli:"files..."`
}

If "mytool" were a subcommand of some larger "megatool", whose config type is megaArgs, then you could represent that relationship with an additional field:

type args struct {
    ParentArgs megaArgs `cli:"mytool,subcmd"`
    // ...
}

Any field that uses the "cli" tag may also use the "usage" tag. That tag's value is set to be the "usage" attribute of the option. The "usage" tag has no use on fields that are not options.

Any field that uses the "cli" tag may also use the "value" tag. That tag's value is set to be the "value name" of the option. The "value" tag has no use on fields that are not options that take value.

If a "cli"-using field named "XXX" has a corresponding method named "ExtendedUsage_XXX" on the struct with the signature:

func() string

Then Run will call that function, and the return value is set to be the "extended usage" attribute of the option.

If a "cli"-using field named "XXX" has a corresponding method named "Autocomplete_XXX" on the struct with the signature:

func() []string

Then that method is set to be the "autocompleter" attribute of the option.

If a config type satisfies satisfies this interface:

interface {
    Description() string
}

Then Run will call Description, and the return value will be used as the "description" attribute of the command.

If a config type satisfies satisfies this interface:

interface {
    ExtendedDescription() string
}

Then Run will call ExtendedDescription, and the return value will be used as the "extended description" attribute of the command.

These "usage", "value name", "extended usage", "autocompleter", "description", and "extended description" attributes are later used in the "Command-Line Argument Parsing", "Man Page Generation", and "Bash/Zsh Completions" sections below.

By default, all config types are implicitly populated by an additional option whose short name is "h" and whose long name is "help", unless those names are already specified. This additional option is internally marked as being a special help option; how this affects Run is covered further in "Command-Line Argument Parsing" below.

Config types may embed other structs. Any options, arguments, or trailing arguments defined within those embedded structs will be honored. However: the parent form of the "cli" tag will be ignored if it's placed within a embedded struct.

It's recommended that you use embedded structs to reduce code duplication if you have the same sets of options appear in many different commands in your tool.

Parameter Types

In the above examples, the fields were of type string and bool. This section will describe what other types are permitted as values for options, arguments, and trailing arguments.

Run supports all of the following types as field types out of the box:

bool
byte
int
uint
rune
string
int8
uint8
int16
uint16
int32
uint32
int64
uint64
float32
float64

For all of the types above (except string), values are parsed from os.Args using the appropriate method from the strconv package in the standard library.

Run also works with any type that implements TextUnmarshaler from the encoding standard library package.

Run will call TextUnmarshal on the zero value of your TextUnmarshal implementation, where the text is the value to parse. If TextUnmarshal returns an error, then the text is considered a bad argument.

Furthermore, all of the types above are supported in slices or pointers. In other words, if T is one of the types described previously (it is a TextUnmarshaler or is in the list of primitive types above), then []T and *T are supported as well. This rule does not apply recursively; [][]T is not supported.

Wrapping a type with a slice (that is, doing "[]T") indicates that the argument can be passed multiple times. For trailing arguments, the type must be wrapped in a slice; this is because trailing arguments must, by definition, support being passed multiple times.

Wrapping a type with a pointer (that is, doing "*T") indicates that the argument may, but does not have to, take a value. See below for more details on how command-line argument parsing works.

Command-Line Argument Parsing

If the COMP_LINE, COMP_CWORD, and UCARION_CLI_GENERATE_MAN environment variables are not populated, then Run will parse os.Args against the command tree formed from funcs, construct an instance of the appropriate config type with fields populated from the parsed args, and then call the appropriate function in funcs with ctx and the constructed config type.

At a high level, the syntax that Run expects from os.Args like this:

root-name [root-options] subcmd [subcmd-options] args...

In other words: Run expects the root name of the command in os.Args[0]. It expects that options for a command or subcommand go immediately after the command or subcommand's name. In other words, flags do not "propagate" or get "inherited". For example, if the root-level command takes an option "-x", and a subcommand "y" takes some option "-z", then this is a valid invocation:

cmd -x y -z

But this is not:

cmd y -x -z

Because the options for the root-level command must go before the sub-command's name.

Run follows the conventions established by GNU's extensions to the getopt standard from POSIX; these are the conventions familiar to users of most modern Linux distributions. In particular:

Options are always optional. Run implements this convention by defaulting all options to their zero value.

Non-trailing arguments are never optional. Trailing arguments are always optional, and default to be a zero-length slice.

Options whose type is bool cannot take a value. These options can only be set to true, and can only be included by being mentioned by name in os.Args. There are two syntaxes for doing this: the "short" form is for options that have a short name, and the "long" form is for options that have a long name. For example, if the option has short name "f" and long name "force", then these are equivalent:

// This is the "short" form
cmd -f

// This is the "long" form
cmd --force

Options whose type is not bool and not a pointer must take a value. There are four syntaxes for setting the value for these options: the two "short" forms are for options that have a short name, and the two "long" forms are for options that have a long name. For example, if the option has short name "o" and long name "output", then these are all equivalent, and set the option to the string "json":

// This is the "short stuck" form
cmd -ojson

// This is the "short detached" form
cmd -o json

// This is the "long stuck" form
cmd --output=json

// This is the "long detached" form
cmd --output json

Options whose type is a pointer may, but do not have to, take a value. To set the value, users must pass the value using either of the "stuck" forms of value-taking options immediately above. To avoid setting an explicit value, users must use either of the forms of the non-value-taking options further above.

If a user sets a value for a pointer-typed option, then the corresponding field will be populated as a pointer to the parsed value. If the user specifies the option but does not give it a value, then the corresponding field will be populated as a pointer to the zero value of the underlying type. If the user does not specify the option at all, then the field will be populated as nil.

When parsing sub-commands, the populated options for the parent command are set to the value of the field using the parent form of the "cli" tag. In other words: child commands can see the parsed options for their ancestor commands, by looking inside the value of the fields tagged with cli:"xxx,subcmd".

If the user has specified the special, automatically-populated help option in their arguments, then Run will output the usage message of the appropriate command or sub-command. If the arguments in os.Args ultimately lead to a non-runnable command, then Run will similarly output the usage message of the relevant command.

The usage message of a given command will contain the name of the command, its extended description, the name of its argument(s), and the names of its options and their usages.

If Run calls one of the elements of funcs and that function retuns an error, then the error will be printed to os.Stderr and Run will call os.Exit(1).

Man Page Generation

If the UCARION_CLI_GENERATE_MAN environment variable is non-empty and the COMP_LINE and COMP_CWORD environment variables are empty, then Run will not parse os.Args and will instead generate man pages.

Man pages will be generated in the directory specified by UCARION_CLI_GENERATE_MAN. Each command and sub-command within the command tree constructed from funcs will have its own man page.

The man page of a given command will contain the name of the command, its description and extended description, the name of its argument(s), the names of its options and their extended usages.

If Run encounters an I/O error while generating man pages, it panics.

Bash or Zsh Completion

If the COMP_LINE and COMP_CWORD environment variables are both non-empty, then Run will not run any of funcs, and will instead output a set of completions to stdout. In effect, this makes Run be its own Bash/Zsh completion script.

When generating completions, Run will construct a command tree from funcs, and will parse arguments from COMP_LINE (separated by strings.Fields) up to the index COMP_CWORD (parsed by strconv.Atoi). Then, Run may call the autocompleter function of the relevant option or argument that is expected next in the input, if such an autocompleter is available.

When Run calls an autocompleter, the method receiver will be a partially-populated config type. Autocompleter functions may rely on that partially-populated config type to inform completion generation; some applications may, for example, perform an authenticated request to some system if the user has specified credentials, and use the result of that request to return a set of suggestions.

For guidance on how to usefully set up a Run-using application to have Bash/Zsh completions, see the README for cli, available online at:

https://github.com/ucarion/cli

That README also contains cookbook-style documentation on how to use Run; it is expected that for most users, the README will be more useful than these docs, which serve more as a description of the contract that Run upholds.

Types

This section is empty.

Jump to

Keyboard shortcuts

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