cli

package module
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Feb 16, 2025 License: MIT Imports: 10 Imported by: 5

README

CLI

License Go Reference Go Report Card GitHub CI codecov

Tiny, simple, but powerful CLI framework for modern Go 🚀

demo

[!WARNING] CLI is still in development and is not yet stable

Project Description

cli is a simple, minimalist, zero-dependency yet functional and powerful CLI framework for Go. Inspired by things like spf13/cobra and urfave/cli, but building on lessons learned and using modern Go techniques and idioms.

Installation

go get github.com/FollowTheProcess/cli@latest

Quickstart

package main

import (
    "fmt"
    "os"

    "github.com/FollowTheProcess/cli"
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

func run() error {
    var count int
    cmd, err := cli.New(
        "quickstart",
        cli.Short("Short description of your command"),
        cli.Long("Much longer text..."),
        cli.Version("v1.2.3"),
        cli.Commit("7bcac896d5ab67edc5b58632c821ec67251da3b8"),
        cli.BuildDate("2024-08-17T10:37:30Z"),
        cli.Allow(cli.MinArgs(1)), // Must have at least one argument
        cli.Stdout(os.Stdout),
        cli.Example("Do a thing", "quickstart something"),
        cli.Example("Count the things", "quickstart something --count 3"),
        cli.Flag(&count, "count", 'c', 0, "Count the things"),
        cli.Run(runQuickstart(&count)),
    )
    if err != nil {
        return err
    }

    return cmd.Execute()
}

func runQuickstart(count *int) func(cmd *cli.Command, args []string) error {
    return func(cmd *cli.Command, args []string) error {
        fmt.Fprintf(cmd.Stdout(), "Hello from quickstart!, my args were: %v, count was %d\n", args, *count)
        return nil
    }
}

Will get you the following:

quickstart

[!TIP] See usage section below and more examples under ./examples

Usage

Commands

To create CLI commands, you simply call cli.New:

cmd, err := cli.New(
    "name", // The name of your command
    cli.Short("A new command") // Shown in the help
    cli.Run(func(cmd *cli.Command, args []string) error {
        // This function is what your command does
        fmt.Printf("name called with args: %v\n", args)
        return nil
    })
)

[!TIP] The command can be customised by applying any number of functional options for setting the help text, describing the arguments or flags it takes, adding subcommands etc. see https://pkg.go.dev/github.com/FollowTheProcess/cli#Option

Sub Commands

To add a subcommand underneath the command you've just created, it's again cli.New:

// Best to abstract it into a function
func buildSubcommand() (*cli.Command, error) {
    return cli.New(
        "sub", // Name of the sub command e.g. 'clone' for 'git clone'
        cli.Short("A sub command"),
        // etc..
    )
}

And add it to your parent command:

// From the example above
cmd, err := cli.New(
    "name", // The name of your command
    // ...
    cli.SubCommands(buildSubcommand),
)

This pattern can be repeated recursively to create complex command structures.

Flags

Flags in cli are generic, that is, there is one way to add a flag to your command, and that's with the cli.Flag option to cli.New

type options struct {
    name string
    force bool
    size uint
    items []string
}

func buildCmd() (*cli.Command, error) {
    var opts options
    return cli.New(
        // ...
        // Signature is cli.Flag(*T, name, shorthand, default, description)
        cli.Flag(&options.name, "name", 'n', "", "The name of something"),
        cli.Flag(&options.force, "force", cli.NoShortHand, false, "Force delete without confirmation"),
        cli.Flag(&options.size, "size", 's', 0, "Size of something"),
        cli.Flag(&options.items, "items", 'i', nil, "Items to include"),
        cli.Run(runCmd(&options)), // Pass the parsed flag values to your command run function
    )
}

The types are all inferred automatically! No more BoolSliceVarP

The types you can use for flags currently are:

  • int
  • int8
  • int16
  • int32
  • int64
  • uint
  • uint8
  • uint16
  • uint32
  • uint64
  • uintptr
  • float32
  • float64
  • string
  • bool
  • []byte (interpreted as a hex string)
  • Count (special type for flags that count things e.g. a --verbosity flag may be used like -vvv to increase verbosity to 3)
  • time.Time
  • time.Duration
  • net.IP
  • []int
  • []int8
  • []int16
  • []int32
  • []int64
  • []uint
  • []uint16
  • []uint32
  • []uint64
  • []float32
  • []float64
  • []string

[!NOTE] You basically can't get this wrong, if you try and use an unsupported type, the Go compiler will yell at you

Core Principles

When designing and implementing cli, I had some core goals and guiding principles for implementation.

😱 Well behaved libraries don't panic

cli validates heavily and returns errors for you to handle. By contrast spf13/cobra (and by extension spf13/pflag) panic in a number of (IMO unnecessary) conditions including:

  • Duplicate subcommand
  • Command adding itself as a subcommand
  • Duplicate flag
  • Invalid shorthand flag letter

The design of cli is such that commands are instantiated with cli.New and a number of functional options. These options are in charge of configuring your command and each will perform validation prior to applying the setting.

These errors are joined and bubbled up to you in one go via cli.New so you don't have to play error whack-a-mole, and more importantly your application won't panic!

🧘🏻 Keep it Simple

cli has an intentionally small public interface and gives you only what you need to build amazing CLI apps:

  • No huge structs with hundreds of fields
  • No confusing or conflicting options
  • Customisation in areas where it makes sense, sensible opinionated defaults everywhere else
  • No reflection or struct tags

There is one and only one way to do things (and that is usually to use an option in cli.New)

👨🏻‍🔬 Use Modern Techniques

The dominant Go CLI toolkits were mostly built many years (and many versions of Go) ago. They are reliable and battle hardened but because of their high number of users, they have had to be very conservative with changes.

cli has none of these constraints and can use bang up to date Go techniques and idioms.

One example is generics, consider how you define a flag:

var force bool
cli.New("demo", cli.Flag(&force, "force", 'f', false, "Force something"))

Note the type bool is inferred by cli.Flag. This will work with any type allowed by the Flaggable generic constraint so you'll get compile time feedback if you've got it wrong. No more flag.BoolStringSliceVarP 🎉

🥹 A Beautiful API

cli heavily leverages the functional options pattern to create a delightful experience building a CLI tool. It almost reads like plain english:

var count int
cmd, err := cli.New(
    "test",
    cli.Short("Short description of your command"),
    cli.Long("Much longer text..."),
    cli.Version("v1.2.3"),
    cli.Allow(cli.MinArgs(1)),
    cli.Stdout(os.Stdout),
    cli.Example("Do a thing", "test run thing --now"),
    cli.Flag(&count, "count", 'c', 0, "Count the things"),
)
🔐 Immutable State

Typically, commands are implemented as a big struct with lots of fields. cli is no different in this regard.

What is different though is that this large struct can only be configured with cli.New. Once you've built your command, it can't be modified.

This eliminates a whole class of bugs and prevents misconfiguration and footguns 🔫

🚧 Good Libraries are Hard to Misuse

Everything in cli is (hopefully) clear, intuitive, and well-documented. There's a tonne of strict validation in a bunch of places and wherever possible, misuse results in a compilation error.

Consider the following example of a bad shorthand value:

var delete bool

// Note: "de" is a bad shorthand, it's two letters
cli.New("demo", cli.Flag(&delete, "delete", "de", false, "Delete something"))

In cli this is impossible as we use rune as the type for a flag shorthand, so the above example would not compile. Instead you must specify a valid rune:

var delete bool

// Ahhh, that's better
cli.New("demo", cli.Flag(&delete, "delete", 'd', false, "Delete something"))

And if you don't want a shorthand? i.e. just --delete with no -d option:

var delete bool
cli.New("demo", cli.Flag(&delete, "delete", cli.NoShortHand, false, "Delete something"))

In the Wild

I built cli for my own uses really, so I've quickly adopted it across a number of tools. See the following projects for some working examples in real code:

Documentation

Overview

Package cli provides a clean, minimal and simple mechanism for constructing CLI commands.

Index

Constants

View Source
const NoShortHand = flag.NoShortHand

NoShortHand should be passed as the "short" argument to Flag if the desired flag should be the long hand version only e.g. --count, not -c/--count.

Variables

This section is empty.

Functions

This section is empty.

Types

type ArgValidator

type ArgValidator func(cmd *Command, args []string) error

ArgValidator is a function responsible for validating the provided positional arguments to a Command.

An ArgValidator should return an error if it thinks the arguments are not valid.

func AnyArgs

func AnyArgs() ArgValidator

AnyArgs is a positional argument validator that allows any arbitrary args, it never returns an error.

This is the default argument validator on a Command instantiated with cli.New.

func BetweenArgs

func BetweenArgs(min, max int) ArgValidator

BetweenArgs is a positional argument validator that allows between min and max arguments (inclusive), any outside that range will return an error.

func Combine

func Combine(validators ...ArgValidator) ArgValidator

Combine allows multiple positional argument validators to be composed together.

The first validator to fail will be the one that returns the error.

func ExactArgs

func ExactArgs(n int) ArgValidator

ExactArgs is a positional argument validator that allows exactly n args, any more or less will return an error.

func MaxArgs

func MaxArgs(n int) ArgValidator

MaxArgs is a positional argument validator that returns an error if there are more than n arguments.

func MinArgs

func MinArgs(n int) ArgValidator

MinArgs is a positional argument validator that requires at least n arguments.

func NoArgs

func NoArgs() ArgValidator

NoArgs is a positional argument validator that does not allow any arguments, it returns an error if there are any arguments.

func ValidArgs

func ValidArgs(valid []string) ArgValidator

ValidArgs is a positional argument validator that only allows arguments that are contained in the valid slice. If any non-valid arguments are seen, an error will be returned.

type Builder added in v0.3.0

type Builder func() (*Command, error)

Builder is a function that constructs and returns a Command, it makes constructing complex command trees easier as they can be passed directly to the SubCommands option.

type Command

type Command struct {
	// contains filtered or unexported fields
}

Command represents a CLI command. In terms of an example, in the line git commit -m <msg>; 'commit' is the command. It can have any number of subcommands which themselves can have subcommands etc. The root command in this example is 'git'.

func New

func New(name string, options ...Option) (*Command, error)

New builds and returns a new Command.

The command can be customised by passing in a number of options enabling you to do things like configure stderr and stdout, add or customise help or version output add subcommands and run functions etc.

Without any options passed, the default implementation returns a Command with no subcommands, a -v/--version and a -h/--help flag, hooked up to os.Stdin, os.Stdout and os.Stderr and accepting arbitrary positional arguments from os.Args (with the command path stripped, equivalent to os.Args[1:]).

Options will validate their inputs where possible and return errors which will be bubbled up through New to aid debugging invalid configuration.

func (*Command) Arg added in v0.6.0

func (cmd *Command) Arg(name string) string

Arg looks up a named positional argument by name.

If the argument was defined with a default, and it was not provided on the command line then the value returned will be the default value.

If no named argument exists with the given name, it will return "".

func (*Command) Execute

func (cmd *Command) Execute() error

Execute parses the flags and arguments, and invokes the Command's Run function, returning any error.

If the flags fail to parse, an error will be returned and the Run function will not be called.

func (*Command) ExtraArgs

func (cmd *Command) ExtraArgs() (args []string, ok bool)

ExtraArgs returns any additional arguments following a "--", and a boolean indicating whether or not they were present. This is useful for when you want to implement argument pass through in your commands.

If there were no extra arguments, it will return nil, false.

func (*Command) Stderr

func (cmd *Command) Stderr() io.Writer

Stderr returns the configured Stderr for the Command.

func (*Command) Stdin

func (cmd *Command) Stdin() io.Reader

Stdin returns the configured Stdin for the Command.

func (*Command) Stdout

func (cmd *Command) Stdout() io.Writer

Stdout returns the configured Stdout for the Command.

type FlagCount

type FlagCount = flag.Count

FlagCount is a type used for a flag who's job is to increment a counter, e.g. a "verbosity" flag may be used like so "-vvv" which should increase the verbosity level to 3.

Count flags may be used in the following ways:

  • -vvv
  • --verbose --verbose --verbose (although not sure why you'd do this)
  • --verbose=3

All have the same effect of increasing the verbosity level to 3.

--verbose 3 however is not supported, this is due to an internal parsing implementation detail.

type Flaggable

type Flaggable flag.Flaggable

Flaggable is a type constraint that defines any type capable of being parsed as a command line flag.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is a functional option for configuring a Command.

func Allow

func Allow(validator ArgValidator) Option

Allow is an Option that allows for validating positional arguments to a Command.

You provide a validator function that returns an error if it encounters invalid arguments, and it will be run for you, passing in the non-flag arguments to the Command that was called.

Successive calls overwrite previous ones, use Combine to compose multiple validators.

// No positional arguments allowed
cli.New("test", cli.Allow(cli.NoArgs()))

func BuildDate added in v0.2.0

func BuildDate(date string) Option

BuildDate is an Option that sets the build date for a binary built with CLI. It is particularly useful for embedding rich version info into a binary using ldflags

Without this option, the build date is simply omitted from the version info shown when -v/--version is called.

If set to a non empty string, the build date will be shown.

cli.New("test", cli.BuildDate("2024-07-06T10:37:30Z"))

func Commit added in v0.2.0

func Commit(commit string) Option

Commit is an Option that sets the commit hash for a binary built with CLI. It is particularly useful for embedding rich version info into a binary using ldflags.

Without this option, the commit hash is simply omitted from the version info shown when -v/--version is called.

If set to a non empty string, the commit hash will be shown.

cli.New("test", cli.Commit("b43fd2c"))

func Example

func Example(comment, command string) Option

Example is an Option that adds an example to a Command.

Examples take the form of an explanatory comment and a command showing the command to the CLI, these will show up in the help text.

For example, a program called "myrm" that deletes files and directories might have an example declared as follows:

cli.Example("Delete a folder recursively without confirmation", "myrm ./dir --recursive --force")

Which would show up in the help text like so:

Examples:
# Delete a folder recursively without confirmation
$ myrm ./dir --recursive --force

An arbitrary number of examples can be added to a Command, and calls to Example are additive.

func Flag

func Flag[T Flaggable](p *T, name string, short rune, value T, usage string) Option

Flag is an Option that adds a flag to a Command, storing its value in a variable via it's pointer 'p'.

The variable is set when the flag is parsed during command execution. The value provided by the 'value' argument to Flag is used as the default value, which will be used if the flag value was not given via the command line.

If the default value is not the zero value for the type T, the flags usage message will show the default value in the commands help text.

To add a long flag only (e.g. --delete with no -d option), pass NoShortHand for "short".

Flags linked to slice values (e.g. []string) work by appending the passed values to the slice so multiple values may be given by repeat usage of the flag e.g. --items "one" --items "two".

// Add a force flag
var force bool
cli.New("rm", cli.Flag(&force, "force", 'f', false, "Force deletion without confirmation"))

func Long

func Long(long string) Option

Long is an Option that sets the full description for a Command.

The long description will appear in the help text for a command. Users are responsible for wrapping the text at a sensible width.

For consistency of formatting, all leading and trailing whitespace is stripped.

Successive calls will simply overwrite any previous calls.

cli.New("rm", cli.Long("... lots of text here"))

func NoColour added in v0.10.0

func NoColour(noColour bool) Option

NoColour is an Option that disables all colour output from the Command.

CLI respects the values of $NO_COLOR and $FORCE_COLOR automatically so this need not be set for most applications.

Setting this option takes precedence over all other colour configuration.

func OptionalArg added in v0.10.0

func OptionalArg(name, description, value string) Option

OptionalArg is an Option that adds a named positional argument, with a default value, to a Command.

An optional named argument is given a name, a description, and a default value that will be shown in the help text. If the argument isn't given when the command is invoke, the default value is used in it's place.

The order of calls matters, each call to OptionalArg effectively appends an optional, named positional argument to the command so the following:

cli.New(
    "cp",
    cli.OptionalArg("src", "The file to copy", "./default-src.txt"),
    cli.OptionalArg("dest", "Where to copy to", "./default-dest.txt"),
)

results in a command that will expect the following args *in order*

cp src.txt dest.txt

If the argument should be required (e.g. no sensible default), use RequiredArg.

Arguments added to the command may be retrieved by name from within command logic with Command.Arg.

func OverrideArgs added in v0.6.0

func OverrideArgs(args []string) Option

OverrideArgs is an Option that sets the arguments for a Command, overriding any arguments parsed from the command line.

Without this option, the command will default to os.Args[1:], this option is particularly useful for testing.

Successive calls override previous ones.

// Override arguments for testing
cli.New("test", cli.OverrideArgs([]string{"test", "me"}))

func RequiredArg added in v0.10.0

func RequiredArg(name, description string) Option

RequiredArg is an Option that adds a required named positional argument to a Command.

A required named argument is given a name, and a description that will be shown in the help text. Failure to provide this argument on the command line when the command is invoked will result in an error from Command.Execute.

The order of calls matters, each call to RequiredArg effectively appends a required, named positional argument to the command so the following:

cli.New(
    "cp",
    cli.RequiredArg("src", "The file to copy"),
    cli.RequiredArg("dest", "Where to copy to"),
)

results in a command that will expect the following args *in order*

cp src.txt dest.txt

If the argument should have a default value if not specified on the command line, use OptionalArg.

Arguments added to the command may be retrieved by name from within command logic with Command.Arg.

func Run

func Run(run func(cmd *Command, args []string) error) Option

Run is an Option that sets the run function for a Command.

The run function is the actual implementation of your command i.e. what you want it to do when invoked.

Successive calls overwrite previous ones.

func Short

func Short(short string) Option

Short is an Option that sets the one line usage summary for a Command.

The one line usage will appear in the help text as well as alongside subcommands when they are listed.

For consistency of formatting, all leading and trailing whitespace is stripped.

Successive calls will simply overwrite any previous calls.

cli.New("rm", cli.Short("Delete files and directories"))

func Stderr

func Stderr(stderr io.Writer) Option

Stderr is an Option that sets the Stderr for a Command.

Successive calls will simply overwrite any previous calls. Without this option the command will default to os.Stderr.

// Set stderr to a temporary buffer
buf := &bytes.Buffer{}
cli.New("test", cli.Stderr(buf))

func Stdin

func Stdin(stdin io.Reader) Option

Stdin is an Option that sets the Stdin for a Command.

Successive calls will simply overwrite any previous calls. Without this option the command will default to os.Stdin.

// Set stdin to os.Stdin (the default anyway)
cli.New("test", cli.Stdin(os.Stdin))

func Stdout

func Stdout(stdout io.Writer) Option

Stdout is an Option that sets the Stdout for a Command.

Successive calls will simply overwrite any previous calls. Without this option the command will default to os.Stdout.

// Set stdout to a temporary buffer
buf := &bytes.Buffer{}
cli.New("test", cli.Stdout(buf))

func SubCommands

func SubCommands(builders ...Builder) Option

SubCommands is an Option that attaches 1 or more subcommands to the command being configured.

Sub commands must have unique names, any duplicates will result in an error.

This option is additive and can be called as many times as desired, subcommands are effectively appended on every call.

func Version

func Version(version string) Option

Version is an Option that sets the version for a Command.

Without this option, the command defaults to a version of "dev".

cli.New("test", cli.Version("v1.2.3"))

Directories

Path Synopsis
examples
internal
colour
Package colour implements basic text colouring for cli's limited needs.
Package colour implements basic text colouring for cli's limited needs.
flag
Package flag provides a command line flag definition and parsing library.
Package flag provides a command line flag definition and parsing library.
table
Package table implements a thin wrapper around text/tabwriter to keep formatting consistent across cli.
Package table implements a thin wrapper around text/tabwriter to keep formatting consistent across cli.

Jump to

Keyboard shortcuts

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