// Package cliutil is the CLI's plumbing: a small subcommand tree over the // standard flag package, and table/JSON rendering. // // It is deliberately not a CLI framework, but not for the reason usually // given. Measured on this machine, net/http and crypto/tls alone put the floor // for any Go HTTP client at 5.4 MB stripped; the pages binary is 6.0 MB, and // the same program written with cobra came out at 5.9 MB. Half a megabyte // either way is noise next to the TLS stack, so "cobra is too big" would be a // claim the numbers do not support. // // The actual reason is dependency surface. This tool has a fixed set of about // a dozen commands whose flags are plain strings, bools and ints; it needs no // shell completion, no generated man pages, no dynamic command registration. // The tree below is the part of a framework it would use, and it is small // enough to read in one sitting — which matters more for a binary that CI jobs // download and run with a deployment credential in the environment. package cliutil import ( "context" "errors" "flag" "fmt" "io" "sort" "strings" "text/tabwriter" ) // ErrUsage is returned when the command line does not name something runnable. // The caller prints the usage text that accompanies it and exits non-zero. var ErrUsage = errors.New("usage") // Command is one node of the command tree. A node either runs (Exec) or has // children (Sub), never both. type Command struct { // Name is the single word that selects this command. Name string // Args describes the positional arguments for the usage line, e.g. "". Args string // Short is the one-line summary listed by the parent. Short string // Long is optional additional prose printed by --help. Long string // Flags registers this command's flags. It runs once per invocation, before // parsing, so the variables it binds are the ones Exec reads. Flags func(fs *flag.FlagSet) // Exec runs the command with the positional arguments left after parsing. Exec func(ctx context.Context, args []string) error // Sub are the child commands, if any. Sub []*Command // parent is filled in during dispatch so usage text can print the full path. parent *Command } // Persistent registers flags that every command in the tree accepts. // // The values it binds must already hold whatever an outer parse produced, and // it must register them with those values as the defaults — see Globals.Register // in internal/clicmd. Registering with a fixed default instead would reset a // flag given before the subcommand name ("pages --server X project list"), // because flag.StringVar assigns the default at registration time. type Persistent func(fs *flag.FlagSet) // Run parses args against c and dispatches. out receives usage and help text. func Run(ctx context.Context, c *Command, args []string, out io.Writer, persistent Persistent) error { fs := flag.NewFlagSet(c.path(), flag.ContinueOnError) fs.SetOutput(out) fs.Usage = func() { c.printUsage(out, fs) } if persistent != nil { persistent(fs) } if c.Flags != nil { c.Flags(fs) } if len(c.Sub) == 0 { args = permute(fs, args) } if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { // flag has already printed the usage via fs.Usage. return nil } // flag printed the message; adding our own would double it. return ErrUsage } rest := fs.Args() if len(c.Sub) > 0 && len(rest) > 0 { for _, sub := range c.Sub { if sub.Name == rest[0] { sub.parent = c return Run(ctx, sub, rest[1:], out, persistent) } } fmt.Fprintf(out, "unknown command %q\n\n", rest[0]) c.printUsage(out, fs) return ErrUsage } // A leaf, or a parent invoked without naming a child. The latter still gets // its Exec so the root can answer --version before falling back to usage. if c.Exec == nil { c.printUsage(out, fs) return ErrUsage } err := c.Exec(ctx, rest) if errors.Is(err, ErrUsage) { c.printUsage(out, fs) } return err } // permute moves flags ahead of positional arguments, so that // // pages project create demo --spa // // works as well as the order the flag package wants: // // pages project create --spa demo // // Stopping at the first non-flag argument is right for a command that has // children — the first positional there names the child, and its flags belong // to it, not to us. A leaf has no such ambiguity, so it gets the permuting // parse people expect from every other CLI they use. // // Two tokens are left exactly where they are: "--" terminates flags for good, // and an unrecognised flag is passed through alone so that flag.Parse reports // it rather than this function silently swallowing the argument after it. func permute(fs *flag.FlagSet, args []string) []string { known := map[string]*flag.Flag{} fs.VisitAll(func(f *flag.Flag) { known[f.Name] = f }) var flags, rest []string for i := 0; i < len(args); i++ { a := args[i] if a == "--" { rest = append(rest, args[i+1:]...) break } // "-" alone is the conventional name for stdin, not a flag. if len(a) < 2 || a[0] != '-' { rest = append(rest, a) continue } flags = append(flags, a) name := strings.TrimLeft(a, "-") if strings.ContainsRune(name, '=') { continue // --name=value carries its own argument } // A non-boolean flag takes the next token with it. Booleans must not // swallow anything: "--spa demo" is a flag and a positional. if f, ok := known[name]; ok && !isBoolFlag(f) && i+1 < len(args) { i++ flags = append(flags, args[i]) } } // The separator makes the tail positional even if a value there looks like // a flag, which matters for paths and metadata values. return append(flags, append([]string{"--"}, rest...)...) } func isBoolFlag(f *flag.Flag) bool { b, ok := f.Value.(interface{ IsBoolFlag() bool }) return ok && b.IsBoolFlag() } // UsageErrorf returns an error that makes Run print the command's usage after // the caller reports the message. Use it for "wrong number of arguments" and // friends, where the fix is visible in the usage text. func UsageErrorf(format string, args ...any) error { return &usageError{msg: fmt.Sprintf(format, args...)} } type usageError struct{ msg string } func (e *usageError) Error() string { return e.msg } // Is makes errors.Is(err, ErrUsage) true for any usageError. func (e *usageError) Is(target error) bool { return target == ErrUsage } // path is the space-separated command path, used in usage text and as the // FlagSet name so flag's own error messages name the right command. func (c *Command) path() string { if c.parent == nil { return c.Name } return c.parent.path() + " " + c.Name } func (c *Command) printUsage(out io.Writer, fs *flag.FlagSet) { if c.Short != "" { fmt.Fprintf(out, "%s — %s\n\n", c.path(), c.Short) } fmt.Fprintf(out, "Usage:\n %s", c.path()) if len(c.Sub) > 0 { fmt.Fprint(out, " ") } if hasFlags(fs) { fmt.Fprint(out, " [flags]") } if c.Args != "" { fmt.Fprintf(out, " %s", c.Args) } fmt.Fprint(out, "\n") if len(c.Sub) > 0 { fmt.Fprint(out, "\nCommands:\n") tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) subs := append([]*Command(nil), c.Sub...) sort.Slice(subs, func(i, j int) bool { return subs[i].Name < subs[j].Name }) for _, sub := range subs { fmt.Fprintf(tw, " %s\t%s\n", sub.Name, sub.Short) } tw.Flush() } if hasFlags(fs) { fmt.Fprint(out, "\nFlags:\n") fs.PrintDefaults() } if c.Long != "" { fmt.Fprintf(out, "\n%s\n", strings.TrimSpace(c.Long)) } } func hasFlags(fs *flag.FlagSet) bool { n := 0 fs.VisitAll(func(*flag.Flag) { n++ }) return n > 0 }