Skip to content

Commit

Permalink
Add package for creating CLIs (#100)
Browse files Browse the repository at this point in the history
* Add package for creating CLIs

This adds the `cli` package which creates well-structured command line interfaces and flag parsing.

* Document prompt

* Note users can run -help

* Add more detailed examples

* Interpolate {{ COMMAND }}

* Make flag handling easier

* Add an example for persistent flags

* Faster trim
  • Loading branch information
sethvargo authored Mar 23, 2023
1 parent 1503805 commit df98c09
Show file tree
Hide file tree
Showing 10 changed files with 1,955 additions and 0 deletions.
81 changes: 81 additions & 0 deletions cli/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2023 The Authors (see AUTHORS file)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package cli defines an SDK for building performant and consistent CLIs. All
// commands start with a [RootCommand] which can then accept one or more nested
// subcommands. Subcommands can also be [RootCommand], which creates nested CLIs
// (e.g. "my-tool do the-thing").
//
// The CLI provides opinionated, formatted help output including flag structure.
// It also provides a more integrated experience for defining CLI flags, hiding
// flags, and generating aliases.
//
// To minimize startup times, things are as lazy-loaded as possible. This means
// commands are instantiated only when needed. Most applications will create a
// private global variable that returns the root command:
//
// var rootCmd = func() cli.Command {
// return &cli.RootCommand{
// Name: "my-tool",
// Version: "1.2.3",
// Commands: map[string]cli.CommandFactory{
// "eat": func() cli.Command {
// return &EatCommand{}
// },
// "sleep": func() cli.Command {
// return &SleepCommand{}
// },
// },
// }
// }
//
// This CLI could be invoked via:
//
// $ my-tool eat
// $ my-tool sleep
//
// Deeply-nested [RootCommand] behave like nested CLIs:
//
// var rootCmd = func() cli.Command {
// return &cli.RootCommand{
// Name: "my-tool",
// Version: "1.2.3",
// Commands: map[string]cli.CommandFactory{
// "transport": func() cli.Command {
// return &cli.RootCommand{
// Name: "transport",
// Description: "Subcommands for transportation",
// Commands: map[string]cli.CommandFactory{
// "bus": func() cli.Command {
// return &BusCommand{}
// },
// "car": func() cli.Command {
// return &CarCommand{}
// },
// "train": func() cli.Command {
// return &TrainCommand{}
// },
// },
// }
// },
// },
// }
// }
//
// This CLI could be invoked via:
//
// $ my-tool transport bus
// $ my-tool transport car
// $ my-tool transport train
package cli
310 changes: 310 additions & 0 deletions cli/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
// Copyright 2023 The Authors (see AUTHORS file)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cli

import (
"bufio"
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"

"github.com/mattn/go-isatty"
)

// Command is the interface for a command or subcommand. Most of these functions
// have default implementations on [BaseCommand].
type Command interface {
// Desc provides a short, one-line description of the command. It should be
// shorter than 50 characters.
Desc() string

// Help is the long-form help output. It should include usage instructions and
// flag information.
//
// Callers can insert the literal string "{{ COMMAND }}" which will be
// replaced with the actual subcommand structure.
Help() string

// Flags returns the list of flags that are defined on the command.
Flags() *FlagSet

// Hidden indicates whether the command is hidden from help output.
Hidden() bool

// Run executes the command.
Run(ctx context.Context, args []string) error

// Prompt provides a mechanism for asking for user input. It reads from
// [Stdin]. If there's an input stream (e.g. a pipe), it will read the pipe.
// If the terminal is a TTY, it will prompt. Otherwise it will fail if there's
// no pipe and the terminal is not a tty.
Prompt(msg string) (string, error)

// Stdout returns the stdout stream. SetStdout sets the stdout stream.
Stdout() io.Writer
SetStdout(w io.Writer)

// Stderr returns the stderr stream. SetStderr sets the stderr stream.
Stderr() io.Writer
SetStderr(w io.Writer)

// Stdin returns the stdin stream. SetStdin sets the stdin stream.
Stdin() io.Reader
SetStdin(r io.Reader)

// Pipe creates new unqiue stdin, stdout, and stderr buffers, sets them on the
// command, and returns them. This is most useful for testing where callers
// want to simulate inputs or assert certain command outputs.
Pipe() (stdin, stdout, stderr *bytes.Buffer)
}

// CommandFactory returns a new instance of a command. This returns a function
// instead of allocations because we want the CLI to load as fast as possible,
// so we lazy load as much as possible.
type CommandFactory func() Command

// Ensure [RootCommand] implements [Command].
var _ Command = (*RootCommand)(nil)

// RootCommand represents a command root for a parent or collection of
// subcommands.
type RootCommand struct {
BaseCommand

// Name is the name of the command or subcommand. For top-level commands, this
// should be the binary name. For subcommands, this should be the name of the
// subcommand.
Name string

// Description is the human-friendly description of the command.
Description string

// Hide marks the entire subcommand as hidden. It will not be shown in help
// output.
Hide bool

// Version defines the version information for the command. This can be
// omitted for subcommands as it will be inherited from the parent.
Version string

// Commands is the list of sub commands.
Commands map[string]CommandFactory
}

// Desc is the root command description. It is used to satisfy the [Command]
// interface.
func (r *RootCommand) Desc() string {
return r.Description
}

// Hidden determines whether the command group is hidden. It is used to satisfy
// the [Command] interface.
func (r *RootCommand) Hidden() bool {
return r.Hide
}

// Help compiles structured help information. It is used to satisfy the
// [Command] interface.
func (r *RootCommand) Help() string {
var b strings.Builder

longest := 0
names := make([]string, 0, len(r.Commands))
for name := range r.Commands {
names = append(names, name)
if l := len(name); l > longest {
longest = l
}
}
sort.Strings(names)

fmt.Fprintf(&b, "Usage: %s COMMAND\n\n", r.Name)
for _, name := range names {
cmd := r.Commands[name]()
if cmd == nil {
continue
}

if !cmd.Hidden() {
fmt.Fprintf(&b, " %-*s%s\n", longest+4, name, cmd.Desc())
}
}

return strings.TrimRight(b.String(), "\n")
}

// Run executes the command and prints help output or delegates to a subcommand.
func (r *RootCommand) Run(ctx context.Context, args []string) error {
name, args := extractCommandAndArgs(args)

// Short-circuit top-level help.
if name == "" || name == "-h" || name == "-help" || name == "--help" {
fmt.Fprintln(r.Stderr(), formatHelp(r.Help(), r.Name, r.Flags()))
return nil
}

// Short-circuit version.
if name == "-v" || name == "-version" || name == "--version" {
fmt.Fprintln(r.Stderr(), r.Version)
return nil
}

cmd, ok := r.Commands[name]
if !ok {
return fmt.Errorf("unknown command %q: run \"%s -help\" for a list of "+
"commands", name, r.Name)
}
instance := cmd()

// Ensure the child inherits the streams from the root.
instance.SetStdin(r.stdin)
instance.SetStdout(r.stdout)
instance.SetStderr(r.stderr)

// If this is a subcommand, prefix the name with the parent and inherit some
// values.
if typ, ok := instance.(*RootCommand); ok {
typ.Name = r.Name + " " + typ.Name
typ.Version = r.Version
return typ.Run(ctx, args)
}

if err := instance.Run(ctx, args); err != nil {
// Special case requesting help.
if errors.Is(err, flag.ErrHelp) {
fmt.Fprintln(instance.Stderr(), formatHelp(instance.Help(), r.Name+" "+name, instance.Flags()))
return nil
}
//nolint:wrapcheck // We want to bubble this error exactly as-is.
return err
}
return nil
}

// extractCommandAndArgs is a helper that pulls the subcommand and arguments.
func extractCommandAndArgs(args []string) (string, []string) {
switch len(args) {
case 0:
return "", nil
case 1:
return args[0], nil
default:
return args[0], args[1:]
}
}

// formatHelp is a helper function that does variable replacement from the help
// string.
func formatHelp(help, name string, flags *FlagSet) string {
h := strings.Trim(help, "\n")
if flags != nil {
if v := strings.Trim(flags.Help(), "\n"); v != "" {
h = h + "\n\n" + v
}
}
return strings.ReplaceAll(h, "{{ COMMAND }}", name)
}

// BaseCommand is the default command structure. All commands should embed this
// structure.
type BaseCommand struct {
stdout, stderr io.Writer
stdin io.Reader
}

// Flags returns the base command flags, which is always nil.
func (c *BaseCommand) Flags() *FlagSet {
return nil
}

// Hidden indicates whether the command is hidden. The default is unhidden.
func (c *BaseCommand) Hidden() bool {
return false
}

// Prompt prompts the user for a value. If stdin is a tty, it prompts. Otherwise
// it reads from the reader.
func (c *BaseCommand) Prompt(msg string) (string, error) {
scanner := bufio.NewScanner(io.LimitReader(c.Stdin(), 64*1_000))

if c.Stdin() == os.Stdin && isatty.IsTerminal(os.Stdin.Fd()) {
fmt.Fprint(c.Stdout(), msg)
}

scanner.Scan()

if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
return scanner.Text(), nil
}

// Stdout returns the stdout stream.
func (c *BaseCommand) Stdout() io.Writer {
if v := c.stdout; v != nil {
return v
}
return os.Stdout
}

// SetStdout sets the standard out.
func (c *BaseCommand) SetStdout(w io.Writer) {
c.stdout = w
}

// Stderr returns the stderr stream.
func (c *BaseCommand) Stderr() io.Writer {
if v := c.stderr; v != nil {
return v
}
return os.Stderr
}

// SetStdout sets the standard error.
func (c *BaseCommand) SetStderr(w io.Writer) {
c.stderr = w
}

// Stdin returns the stdin stream.
func (c *BaseCommand) Stdin() io.Reader {
if v := c.stdin; v != nil {
return v
}
return os.Stdin
}

// SetStdout sets the standard input.
func (c *BaseCommand) SetStdin(r io.Reader) {
c.stdin = r
}

// Pipe creates new unqiue stdin, stdout, and stderr buffers, sets them on the
// command, and returns them. This is most useful for testing where callers want
// to simulate inputs or assert certain command outputs.
func (c *BaseCommand) Pipe() (stdin, stdout, stderr *bytes.Buffer) {
stdin = bytes.NewBuffer(nil)
stdout = bytes.NewBuffer(nil)
stderr = bytes.NewBuffer(nil)
c.stdin = stdin
c.stdout = stdout
c.stderr = stderr
return
}
Loading

0 comments on commit df98c09

Please sign in to comment.