Something went wrong. Try again.
Simple, intuitive CLI framework for Go pkg.go.dev/go.followtheprocess.codes/cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772// Package cli provides a clean, minimal and simple mechanism for constructing CLI commands.package cli
import ( "context" "errors" "fmt" "io" "os" "slices" "strings" "unicode/utf8"
"go.followtheprocess.codes/hue/tabwriter"
publicflag "go.followtheprocess.codes/cli/flag"
"go.followtheprocess.codes/cli/internal/arg" "go.followtheprocess.codes/cli/internal/flag" "go.followtheprocess.codes/cli/internal/style")
const ( helpBufferSize = 2048 // helpBufferSize is sufficient to hold most command --help text. versionBufferSize = 256 // versionBufferSize is sufficient to hold a full --version text. defaultVersion = "dev" // defaultVersion is the version shown in --version when the user has not provided one. defaultShort = "A placeholder for something cool" // defaultShort is the default value for cli.Short.)
// Pre-styled section headers used in the help text.//// hue.Style.Text allocates a new string every call so we can't make these// compile-time constants, but there's no reason to re-style fixed section// headers on every help render either. Doing it once at package init drops a// handful of allocs per showHelp.////nolint:gochecknoglobals // Caching the styled titles.var ( usageTitle = style.Title.Text("Usage") optionsTitle = style.Title.Text("Options") commandsTitle = style.Title.Text("Commands") argumentsTitle = style.Title.Text("Arguments") examplesTitle = style.Title.Text("Examples"))
// 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 Builder func() (*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 New(name string, options ...Option) (*Command, error) { // This was actually a nilaway thing, indexing into os.Args without knowing the length if len(os.Args) < 1 { return nil, fmt.Errorf("bad arguments expected [<command> <args>...], got %v", os.Args) }
cmd := &Command{ flags: flag.NewSet(), stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr, rawArgs: os.Args[1:], name: name, version: defaultVersion, short: defaultShort, }
// Apply the options, gathering up all the validation errors // to report in one go var errs error for _, option := range options { errs = errors.Join(errs, option.apply(cmd)) }
// Ensure we always have at least help and version flags. Wired in // directly rather than via the Flag option to skip the closure + // Option interface boxing on every cli.New. errs = errors.Join( errs, addAutoBoolFlag(cmd.flags, &cmd.helpCalled, "help", 'h', "Show help for "+name), addAutoBoolFlag(cmd.flags, &cmd.versionCalled, "version", 'V', "Show version info for "+name), ) if errs != nil { return nil, errs }
// Additional validation that can't be done per-option // A command cannot have no subcommands and no run function, it must define one or the other if cmd.run == nil && len(cmd.subcommands) == 0 { return nil, fmt.Errorf( "command %s has no subcommands and no run function, a command must either be runnable or have subcommands", cmd.name, ) }
// Loop through each subcommand and set this command as their immediate parent for _, subcommand := range cmd.subcommands { subcommand.parent = cmd }
return cmd, nil}
// addAutoBoolFlag wires the implicit --help / --version flags onto the// flag set without going through the Flag option.func addAutoBoolFlag(set *flag.Set, target *bool, name string, short rune, usage string) error { f, err := flag.New(target, name, short, usage, flag.Config[bool]{}) if err != nil { return err }
return flag.AddToSet(set, f)}
// Command represents a CLI command.//// Commands in cli are recursive, that means that the root command// is not different or special compared to any of its subcommands, this structure// makes defining complex command trees as simple as creating a single command.//// In the command line 'git commit -m "Message"' both 'git' and 'commit'// would be a clio command, '-m' would be a Flag taking a string argument.//// Commands are constructed with the [New] function and customised by// providing a number of functional options to layer different settings// and functionality.type Command struct { // stdin is an [io.Reader] from which command input is read. // // It defaults to [os.Stdin] but can be overridden as desired e.g. for testing. stdin io.Reader
// stdout is an [io.Writer] to which normal command output is written. // // It defaults to [os.Stdout] but can be overridden as desired e.g. for testing. stdout io.Writer
// stderr is an [io.Writer] to which error command output is written. // // It defaults to [os.Stderr] but can be overridden as desired e.g. for testing. stderr io.Writer
// run is the function actually implementing the command, the command is passed into the function for access // to things like cmd.Stdout(). run func(ctx context.Context, cmd *Command) error
// flags is the set of flags for this command. flags *flag.Set
// parent is the immediate parent of this subcommand. If this command is the root // and has no parent, this will be nil. parent *Command
// name is the name of the command. name string
// short is the one line summary for the command, shown inline in the -h/--help output. short string
// long is the long form description for the command, shown when -h/--help is called on the command itself. long string
// version is the version of this command, shown when -v/--version is called. version string
// commit is the commit hash of the binary, if passed shown when -v/--version is called commit string
// buildDate is the date the release binary was built on, if passed shown when -v/--version is called buildDate string
// examples is examples of how to use the command. examples []example
// rawArgs are the raw arguments passed to the command prior to any parsing, defaulting to [os.Args] // (excluding the command name, so os.Args[1:]), can be overridden using // the [OverrideArgs] option for e.g. testing. rawArgs []string
// args are the command line arguments declared by the user using the [cli.Args] option. args []arg.Value
// subcommands is the list of subcommands this command has directly underneath it, // these may have any number of subcommands under them, this is how we form nested // command structures. // // If the command has no subcommands, this slice will be nil. subcommands []*Command
// helpCalled is whether or not the --help flag was used. helpCalled bool
// versionCalled is whether or not the --version flag was used. versionCalled bool}
// example is a single usage example for a [Command].//// The example will be shown in the -h/--help output as follows://// # Comment// $ Commandtype example struct { comment string // The comment for the example. command string // The command string for the example.}
// 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 (cmd *Command) Execute(ctx context.Context) error { if cmd == nil { return errors.New("Execute called on a nil Command") }
// Regardless of where we call execute, run it only from the root command, this is to ensure // that when we use the arguments to go and find the subcommand to run (if needed), then we // at the root of the command tree. if cmd.parent != nil { return fmt.Errorf("Execute must be called on the root of the command tree, was called on %s", cmd.name) }
// Use the raw arguments and the command tree to determine which subcommand (if any) // we should be invoking and swap that into 'cmd'. // // Slightly magical trick but it simplifies a lot of stuff below. cmd, args := findRequestedCommand(cmd, cmd.rawArgs)
if err := cmd.flagSet().Parse(args); err != nil { return fmt.Errorf("failed to parse command flags: %w", err) }
// If -h/--help was called, call the defined helpFunc and exit so that // the run function is never called. helpCalled, ok := cmd.flagSet().Help() if !ok { // Should never get here as we define a default help return errors.New("help flag not defined") }
if helpCalled { if err := showHelp(cmd); err != nil { return fmt.Errorf("help function returned an error: %w", err) }
return nil }
// If -v/--version was called, call the defined versionFunc and exit so that // the run function is never called versionCalled, ok := cmd.flagSet().Version() if !ok { // Again, should be unreachable return errors.New("version flag not defined") }
if versionCalled { if err := showVersion(cmd); err != nil { return fmt.Errorf("could not show version: %w", err) }
return nil }
nonExtraArgs := cmd.flagSet().Args() terminatorIndex := slices.Index(nonExtraArgs, "--")
if terminatorIndex != -1 { nonExtraArgs = nonExtraArgs[:terminatorIndex] }
for i, argument := range cmd.args { var str string // The argument has been provided if len(nonExtraArgs) > i { str = nonExtraArgs[i] } else { // It hasn't, use the default str = argument.Default() if str == "" { return fmt.Errorf("argument %q is required and no value was provided", argument.Name()) } }
if err := argument.Set(str); err != nil { return fmt.Errorf("could not parse argument %q from provided input %q: %w", argument.Name(), str, err) } }
// If the command is runnable, go and execute its run function if cmd.run != nil { return cmd.run(ctx, cmd) }
// The only way we get here is if the command has subcommands defined but got no arguments given to it // so just show the usage and error if err := showHelp(cmd); err != nil { return err }
return fmt.Errorf("command %q expected arguments (subcommands) but got none", cmd.name)}
// Stdout returns the configured Stdout for the Command.func (cmd *Command) Stdout() io.Writer { return cmd.root().stdout}
// Stderr returns the configured Stderr for the Command.func (cmd *Command) Stderr() io.Writer { return cmd.root().stderr}
// Stdin returns the configured Stdin for the Command.func (cmd *Command) Stdin() io.Reader { return cmd.root().stdin}
// Args returns the positional arguments passed to the command.func (cmd *Command) Args() []string { return cmd.flagSet().Args()}
// 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 (cmd *Command) ExtraArgs() (args []string, ok bool) { extra := cmd.flagSet().ExtraArgs() if len(extra) > 0 { return extra, true }
return nil, false}
// Flags returns the set of flags for the command.func (cmd *Command) flagSet() *flag.Set { if cmd.flags == nil { return flag.NewSet() }
return cmd.flags}
// root returns the root of the command tree.func (cmd *Command) root() *Command { for cmd.parent != nil { cmd = cmd.parent }
return cmd}
// hasFlag returns whether the command has a flag of the given name defined.func (cmd *Command) hasFlag(name string) bool { flag, ok := cmd.flagSet().Get(name) if !ok { return false }
return flag.NoArgValue() != ""}
// hasShortFlag returns whether the command has a shorthand flag of the given name defined.func (cmd *Command) hasShortFlag(name string) bool { if name == "" { return false }
char, _ := utf8.DecodeRuneInString(name)
flag, ok := cmd.flagSet().GetShort(char) if !ok { return false }
return flag.NoArgValue() != ""}
// findRequestedCommand uses the raw arguments and the command tree to determine what// (if any) subcommand is being requested and return that command along with the arguments// that were meant for it.//// On the first descent into a subcommand it snapshots args into a working// slice that we own; subsequent levels then mutate that slice in place via// [slices.Delete]. The original cmd.rawArgs is never touched, so re-Execute// on the same Command still sees pristine input.func findRequestedCommand(cmd *Command, args []string) (*Command, []string) { owned := false
for { // The next non-flag argument (if any) is the immediate subcommand // e.g. in 'go mod tidy' we're looking for 'mod'. idx, ok := firstNonFlagArg(cmd, args) if !ok { return cmd, args }
next := findSubCommand(cmd, args[idx]) if next == nil { return cmd, args }
if !owned { working := make([]string, len(args)) copy(working, args) args = working owned = true }
args = slices.Delete(args, idx, idx+1) cmd = next }}
// findSubCommand searches the immediate subcommands of cmd by name, looking for next.//// If next is not found, it will return nil.func findSubCommand(cmd *Command, next string) *Command { for _, subcommand := range cmd.subcommands { if subcommand.name == next { return subcommand } }
return nil}
// firstNonFlagArg walks args and returns the index of the first positional// (non-flag) argument along with a boolean indicating whether one was found.//// It consumes flag-value pairs (e.g. '--flag value' or '-f value') so they// aren't mistaken for positional arguments, and stops at '--'.func firstNonFlagArg(cmd *Command, args []string) (idx int, ok bool) { for i := 0; i < len(args); i++ { a := args[i] switch { case a == "--": // "--" terminates the flags return -1, false case strings.HasPrefix(a, "--") && !strings.Contains(a, "=") && !cmd.hasFlag(a[2:]): // If '--flag value' then skip value fallthrough case strings.HasPrefix(a, "-") && !strings.Contains(a, "=") && len(a) == 2 && !cmd.hasShortFlag(a[1:]): // '-f value' skip the value too. If there isn't one, we're done. if i+1 >= len(args) { return -1, false }
i++
continue case a != "" && !strings.HasPrefix(a, "-"): // First valid positional arg return i, true } }
return -1, false}
// showHelp is the default for a command's helpFunc.func showHelp(cmd *Command) error { if cmd == nil { return errors.New("showHelp called on a nil Command") }
// Note: The decision to not use text/template here is intentional, template calls // reflect.Value.MethodByName() and/or reflect.Type.MethodByName() which disables dead // code elimination in the compiler, meaning any application that uses cli for it's // command line interface will not be run through dead code elimination which could cause // significant increase in memory consumption and disk space. // See https://github.com/spf13/cobra/issues/2015 s := &strings.Builder{} s.Grow(helpBufferSize)
// One tabwriter threaded through every aligned section, reset // between them with ResetTabwriter so only the first section pays the // internal-buffer allocation cost. tw := style.Tabwriter(s)
// If we have a short description, write that if cmd.short != "" { s.WriteString(cmd.short) s.WriteString("\n\n") }
// If we have a long description, write that if cmd.long != "" { s.WriteString(cmd.long) s.WriteString("\n\n") }
s.WriteString(usageTitle) s.WriteString(": ") s.WriteString(style.Bold.Text(cmd.name))
if len(cmd.subcommands) == 0 { // We don't have any subcommands so usage will be: // "Usage: {name} [OPTIONS] ARGS..." s.WriteString(" [OPTIONS]")
if len(cmd.args) > 0 { // If we have named args, use the names in the help text writePositionalArgs(cmd, s) } else { // Otherwise, the command accepts arbitrary arguments s.WriteString(" ARGS...") } } else { // We do have subcommands, so usage will instead be: // "Usage: {name} [OPTIONS] COMMAND" s.WriteString(" [OPTIONS] COMMAND") }
// If we have defined, list them explicitly and use their descriptions if len(cmd.args) != 0 { if err := writeArgumentsSection(cmd, s, tw); err != nil { return err } }
// If the user defined some examples, show those if len(cmd.examples) != 0 { writeExamples(cmd, s) }
// Now show subcommands if len(cmd.subcommands) != 0 { if err := writeSubcommands(cmd, s, tw); err != nil { return err } }
// Now options if len(cmd.examples) != 0 || len(cmd.subcommands) != 0 || len(cmd.args) != 0 { // If there were examples or subcommands or named arguments, the last one would have printed a newline s.WriteString("\n") } else { // If there weren't, we need some more space s.WriteString("\n\n") }
s.WriteString(optionsTitle) s.WriteString(":\n\n")
if err := writeFlags(cmd, s, tw); err != nil { return err }
// Subcommand help if len(cmd.subcommands) != 0 { writeFooter(cmd, s) }
// Note: It's important to use cmd.Stderr() here over cmd.stderr // as it resolves to the root's stderr fmt.Fprint(cmd.Stderr(), s.String())
return nil}
// writePositionalArgs writes any positional arguments in the correct// format for the top level usage string in the help text string builder.func writePositionalArgs(cmd *Command, s *strings.Builder) { for _, arg := range cmd.args { s.WriteString(" ")
displayName := strings.ToUpper(arg.Name())
if arg.Default() != "" { // It has a default so is not required s.WriteString("[") s.WriteString(displayName) s.WriteString("]") } else { // It is required s.WriteString(displayName) } }}
// writeArgumentsSection writes the entire positional arguments block to the help// text string builder.func writeArgumentsSection(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { s.WriteString("\n\n") s.WriteString(argumentsTitle) s.WriteString(":\n\n") style.ResetTabwriter(tw, s)
for _, arg := range cmd.args { if def := arg.Default(); def != "" { fmt.Fprintf(tw, " %s\t%s\t%s\t[default: %s]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), def) } else { fmt.Fprintf(tw, " %s\t%s\t%s\t[required]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) } }
if err := tw.Flush(); err != nil { return fmt.Errorf("could not format arguments: %w", err) }
return nil}
// writeExamples writes the examples block to the help text string builder.func writeExamples(cmd *Command, s *strings.Builder) { // If there were positional args, the last one would have printed a newline if len(cmd.args) != 0 { s.WriteString("\n") } else { // If not, we need a bit more space s.WriteString("\n\n") }
s.WriteString(examplesTitle) s.WriteByte(':') s.WriteString("\n\n")
s.WriteString(" # ") s.WriteString(cmd.examples[0].comment) s.WriteByte('\n') s.WriteString(" $ ") s.WriteString(cmd.examples[0].command)
for _, example := range cmd.examples[1:] { s.WriteString("\n\n") s.WriteString(" # ") s.WriteString(example.comment) s.WriteByte('\n') s.WriteString(" $ ") s.WriteString(example.command) }
s.WriteByte('\n')}
// writeSubcommands writes the subcommand block to the help text string builder.func writeSubcommands(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { // If there were examples, the last one would have printed a newline if len(cmd.examples) != 0 { s.WriteByte('\n') } else { s.WriteString("\n\n") }
s.WriteString(commandsTitle) s.WriteByte(':') s.WriteString("\n\n")
style.ResetTabwriter(tw, s)
for _, subcommand := range cmd.subcommands { fmt.Fprintf(tw, " %s\t%s\n", style.Bold.Text(subcommand.name), subcommand.short) }
if err := tw.Flush(); err != nil { return fmt.Errorf("could not format subcommands: %w", err) }
return nil}
// writeFlags writes the flag usage block to the help text string builder.func writeFlags(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { style.ResetTabwriter(tw, s)
for name, fl := range cmd.flags.Sorted() { var shorthand string if fl.Short() != publicflag.NoShortHand { shorthand = "-" + string(fl.Short()) } else { shorthand = "N/A" }
defaultStr := "" if fl.Default() != "" { defaultStr = "[default: " + fl.Default() + "]" }
envStr := "" if fl.EnvVar() != "" { envStr = "(env: $" + fl.EnvVar() + ")" }
fmt.Fprintf( tw, " %s\t--%s\t%s\t%s\t%s\t%s\n", style.Bold.Text(shorthand), style.Bold.Text(name), fl.Type(), fl.Usage(), defaultStr, envStr, ) }
if err := tw.Flush(); err != nil { return fmt.Errorf("could not write flag usage: %w", err) }
return nil}
// writeFooter writes the footer to the help text string builder.func writeFooter(cmd *Command, s *strings.Builder) { s.WriteByte('\n') s.WriteString(`Use "`) s.WriteString(cmd.name) s.WriteString(" [command] -h/--help") s.WriteString(`" `) s.WriteString("for more information about a command.") s.WriteByte('\n')}
// showVersion is the default implementation of the --version flag.func showVersion(cmd *Command) error { if cmd == nil { return errors.New("showVersion called on a nil Command") }
name := cmd.name // Incase we need to show the subcommand name
if cmd.version == defaultVersion { // User has not set a version for this command, so we show the root version info cmd = cmd.root() }
s := &strings.Builder{} s.Grow(versionBufferSize) s.WriteString(style.Title.Text(name)) s.WriteString("\n\n") s.WriteString(style.Bold.Text("Version:")) s.WriteString(" ") s.WriteString(cmd.version) s.WriteString("\n")
if cmd.commit != "" { s.WriteString(style.Bold.Text("Commit:")) s.WriteString(" ") s.WriteString(cmd.commit) s.WriteString("\n") }
if cmd.buildDate != "" { s.WriteString(style.Bold.Text("BuildDate:")) s.WriteString(" ") s.WriteString(cmd.buildDate) s.WriteString("\n") }
// Note: It's important to use cmd.Stderr() here over cmd.stderr // as it resolves to the root's stderr fmt.Fprint(cmd.Stderr(), s.String())
return nil}