Every CLI tool I write starts the same way: add cobra, watch the binary balloon to 8 MB, spend twenty minutes fighting its init magic, regret everything. So I wrote a 200-line arg parser I’ve now copied into six projects and never touched again.
what I actually need
- Positional args
- Flags with short and long forms (
-v,--verbose) - Subcommands (one level deep is enough)
- Help text generated automatically
- Zero allocations in the hot path
No config file unmarshalling. No completion scripts. No plugin system.
Just parse os.Args and get out of the way.
the core type
type Parser struct {
name string
flags []Flag
args []Arg
cmds map[string]*Parser
}
type Flag struct {
Short string
Long string
Help string
Value interface{ Set(string) error }
}
The Value interface mirrors flag.Value from stdlib, so you can drop
in flag.Bool, flag.String, etc., or write your own.
parsing in 60 lines
The parse loop is a simple state machine: if the token starts with --,
strip it and look up the flag by long name; if it starts with -, look
up by short name; otherwise it’s either a positional or a subcommand.
func (p *Parser) Parse(args []string) error {
i := 0
for i < len(args) {
tok := args[i]
switch {
case tok == "--":
p.positionals = append(p.positionals, args[i+1:]...)
return nil
case strings.HasPrefix(tok, "--"):
name := tok[2:]
// ... look up and set flag
case strings.HasPrefix(tok, "-") && len(tok) == 2:
name := tok[1:]
// ... short flag
default:
if sub, ok := p.cmds[tok]; ok {
return sub.Parse(args[i+1:])
}
p.positionals = append(p.positionals, tok)
}
i++
}
return nil
}
what I gave up
- Combined short flags (
-xvf) — I’ve never actually needed this =syntax (--output=foo) — trivially addable, not worth the complexity for now- Shell completion — generate it yourself from the Flag metadata if you need it
The full thing is 214 lines including the help formatter. It lives in
internal/flags/ in each project. No go get, no indirect deps, no
mystery.
The source is in the clifmt repo
under internal/flags/.