Writing CLIs in Go is great, but there's one annoyance with the stdlib flag package:
flags must be defined before any positional arguments.
For example, if you have a --debug flag, you can't use it after a positional argument, it'll be
treated as a 2nd positional argument.
# ✅ Works as expeceted
$ mycli --debug run
# ❌ Does not work as expected
$ mycli run --debug
Flag parsing stops just before the first non-flag argument (- is a non-flag argument) or after
the terminator --.
(GoDoc Reference — Command line flag syntax)
The Go project took a firm stance on this issue. For those interested, see the following GitHub issues (there are more, but these are the most relevant):
The challenge is that most users nowadays expect flag order to be irrelevant, especially since tools from other ecosystems behave this way.
The Command Line Interface Guidelines outlines best practices for authoring CLI tools — and they're pretty good. Of note, this snippet:
The order of flags, generally speaking, does not affect program semantics.
The Heroku CLI Style Guide also
encourages this approach. Only mentioning it because of nostalgia, the heroku CLI had a fairly
nice user experience back in the day.
This also allows the user to specify the flags in any order
There's loads more examples, guides and popular tools I could link to, but we'll leave it at that.
So what?
Imagine you're tweaking a (long) command and something isn't working as expected. You reach for a
global --verbose or --debug flag, but now you have the cognitive overhead of figuring out where
to slot the flag.
Or, you're a new user who just installed a shiny new CLI written in Go and it's not immediately working as you'd expect. You're not sure if you're missing a flag, or if you're using it wrong. Leading to a furstating experience.
Weak arguments? Maybe. But it bugs me, and I'm sure I'm not alone.
There's a wealth of third-party Go packages and entire CLI frameworks that handle this, but I want to do the bare minimum to get things working for 99% of cases, using only the stdlib when possible.
So here's a fairly simple solution that works for me. There's one edge case, but overall, I think it does the job right. Gotcha section for more details.
Snippet
The current implementation lives in pressly/cli, in
xflag. It avoids reflection and unsafe by using
-- as a sentinel, then reparsing the positional arguments with the standard library.
// ParseToEnd is a drop-in replacement for flag.Parse. It improves upon the standard behavior by
// parsing flags even when they are interspersed with positional arguments. This overcomes Go's
// default limitation of stopping flag parsing upon encountering the first positional argument. For
// more details, see:
//
// - https://github.com/golang/go/issues/4513
// - https://github.com/golang/go/issues/63138
//
// This is a bit unfortunate, but most users nowadays consuming CLI tools expect this behavior.
func ParseToEnd(f *flag.FlagSet, arguments []string) error {
if err := f.Parse(arguments); err != nil {
return err
}
if f.NArg() == 0 {
return nil
}
var args []string
remainingArgs := f.Args()
for i := 0; i < len(remainingArgs); i++ {
arg := remainingArgs[i]
// If the arg looks like a flag, parses like a flag, and quacks like a flag, then it
// probably is a flag.
//
// Note, there's an edge cases here which we EXPLICITLY do not handle, and quite honestly
// 99.999% of the time you wouldn't build a CLI with this behavior.
//
// If you want to treat an unknown flag as a positional argument. For example:
//
// $ ./cmd --valid=true arg1 --unknown-flag=foo arg2
//
// Right now, this will trigger an error. But *some* users might want that unknown flag to
// be treated as a positional argument. It's trivial to add this behavior, by using VisitAll
// to iterate over all defined flags (regardless if they are set), and then checking if the
// flag is in the map of known flags.
if len(arg) > 1 && arg[0] == '-' {
// If we encounter a "--", treat all subsequent arguments as positional. The "--" itself
// is stripped, consistent with the standard library's behavior.
if arg == "--" {
args = append(args, remainingArgs[i+1:]...)
break
}
if err := f.Parse(remainingArgs[i:]); err != nil {
return err
}
remainingArgs = f.Args()
i = -1 // Reset to handle newly parsed arguments.
continue
}
args = append(args, arg)
}
if len(args) > 0 {
// Use "--" as a sentinel to set the FlagSet's internal args field without unsafe
// reflection. When flag.Parse encounters "--" it stops processing and stores the remaining
// arguments as positional args, which is exactly what we need.
return f.Parse(append([]string{"--"}, args...))
}
return nil
}
Gotcha
Disclaimer, there's one thing to be aware of:
Some CLI author might want to treat an unknown flag as a positional argument. For example:
$ ./cmd --valid=true arg1 --unknown-flag=foo arg2
flag provided but not defined: -unknown-flag
The use case here is treating --unknown-flag=foo as a positional argument. It's fairly easy to
solve, but I'm not sure if it's worth the effort.
If it looks like a flag, parses like a flag, and quacks like a flag, then it probably is a flag.