Building a zero-dep CLI arg parser in Go

Design of a minimal, composable argument parser — no cobra, no viper, no surprises.

2 min read

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/.