Skip to content

Commit df98c09

Browse files
authored
Add package for creating CLIs (#100)
* 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
1 parent 1503805 commit df98c09

10 files changed

+1955
-0
lines changed

cli/cli.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright 2023 The Authors (see AUTHORS file)
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package cli defines an SDK for building performant and consistent CLIs. All
16+
// commands start with a [RootCommand] which can then accept one or more nested
17+
// subcommands. Subcommands can also be [RootCommand], which creates nested CLIs
18+
// (e.g. "my-tool do the-thing").
19+
//
20+
// The CLI provides opinionated, formatted help output including flag structure.
21+
// It also provides a more integrated experience for defining CLI flags, hiding
22+
// flags, and generating aliases.
23+
//
24+
// To minimize startup times, things are as lazy-loaded as possible. This means
25+
// commands are instantiated only when needed. Most applications will create a
26+
// private global variable that returns the root command:
27+
//
28+
// var rootCmd = func() cli.Command {
29+
// return &cli.RootCommand{
30+
// Name: "my-tool",
31+
// Version: "1.2.3",
32+
// Commands: map[string]cli.CommandFactory{
33+
// "eat": func() cli.Command {
34+
// return &EatCommand{}
35+
// },
36+
// "sleep": func() cli.Command {
37+
// return &SleepCommand{}
38+
// },
39+
// },
40+
// }
41+
// }
42+
//
43+
// This CLI could be invoked via:
44+
//
45+
// $ my-tool eat
46+
// $ my-tool sleep
47+
//
48+
// Deeply-nested [RootCommand] behave like nested CLIs:
49+
//
50+
// var rootCmd = func() cli.Command {
51+
// return &cli.RootCommand{
52+
// Name: "my-tool",
53+
// Version: "1.2.3",
54+
// Commands: map[string]cli.CommandFactory{
55+
// "transport": func() cli.Command {
56+
// return &cli.RootCommand{
57+
// Name: "transport",
58+
// Description: "Subcommands for transportation",
59+
// Commands: map[string]cli.CommandFactory{
60+
// "bus": func() cli.Command {
61+
// return &BusCommand{}
62+
// },
63+
// "car": func() cli.Command {
64+
// return &CarCommand{}
65+
// },
66+
// "train": func() cli.Command {
67+
// return &TrainCommand{}
68+
// },
69+
// },
70+
// }
71+
// },
72+
// },
73+
// }
74+
// }
75+
//
76+
// This CLI could be invoked via:
77+
//
78+
// $ my-tool transport bus
79+
// $ my-tool transport car
80+
// $ my-tool transport train
81+
package cli

cli/command.go

+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// Copyright 2023 The Authors (see AUTHORS file)
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cli
16+
17+
import (
18+
"bufio"
19+
"bytes"
20+
"context"
21+
"errors"
22+
"flag"
23+
"fmt"
24+
"io"
25+
"os"
26+
"sort"
27+
"strings"
28+
29+
"github.com/mattn/go-isatty"
30+
)
31+
32+
// Command is the interface for a command or subcommand. Most of these functions
33+
// have default implementations on [BaseCommand].
34+
type Command interface {
35+
// Desc provides a short, one-line description of the command. It should be
36+
// shorter than 50 characters.
37+
Desc() string
38+
39+
// Help is the long-form help output. It should include usage instructions and
40+
// flag information.
41+
//
42+
// Callers can insert the literal string "{{ COMMAND }}" which will be
43+
// replaced with the actual subcommand structure.
44+
Help() string
45+
46+
// Flags returns the list of flags that are defined on the command.
47+
Flags() *FlagSet
48+
49+
// Hidden indicates whether the command is hidden from help output.
50+
Hidden() bool
51+
52+
// Run executes the command.
53+
Run(ctx context.Context, args []string) error
54+
55+
// Prompt provides a mechanism for asking for user input. It reads from
56+
// [Stdin]. If there's an input stream (e.g. a pipe), it will read the pipe.
57+
// If the terminal is a TTY, it will prompt. Otherwise it will fail if there's
58+
// no pipe and the terminal is not a tty.
59+
Prompt(msg string) (string, error)
60+
61+
// Stdout returns the stdout stream. SetStdout sets the stdout stream.
62+
Stdout() io.Writer
63+
SetStdout(w io.Writer)
64+
65+
// Stderr returns the stderr stream. SetStderr sets the stderr stream.
66+
Stderr() io.Writer
67+
SetStderr(w io.Writer)
68+
69+
// Stdin returns the stdin stream. SetStdin sets the stdin stream.
70+
Stdin() io.Reader
71+
SetStdin(r io.Reader)
72+
73+
// Pipe creates new unqiue stdin, stdout, and stderr buffers, sets them on the
74+
// command, and returns them. This is most useful for testing where callers
75+
// want to simulate inputs or assert certain command outputs.
76+
Pipe() (stdin, stdout, stderr *bytes.Buffer)
77+
}
78+
79+
// CommandFactory returns a new instance of a command. This returns a function
80+
// instead of allocations because we want the CLI to load as fast as possible,
81+
// so we lazy load as much as possible.
82+
type CommandFactory func() Command
83+
84+
// Ensure [RootCommand] implements [Command].
85+
var _ Command = (*RootCommand)(nil)
86+
87+
// RootCommand represents a command root for a parent or collection of
88+
// subcommands.
89+
type RootCommand struct {
90+
BaseCommand
91+
92+
// Name is the name of the command or subcommand. For top-level commands, this
93+
// should be the binary name. For subcommands, this should be the name of the
94+
// subcommand.
95+
Name string
96+
97+
// Description is the human-friendly description of the command.
98+
Description string
99+
100+
// Hide marks the entire subcommand as hidden. It will not be shown in help
101+
// output.
102+
Hide bool
103+
104+
// Version defines the version information for the command. This can be
105+
// omitted for subcommands as it will be inherited from the parent.
106+
Version string
107+
108+
// Commands is the list of sub commands.
109+
Commands map[string]CommandFactory
110+
}
111+
112+
// Desc is the root command description. It is used to satisfy the [Command]
113+
// interface.
114+
func (r *RootCommand) Desc() string {
115+
return r.Description
116+
}
117+
118+
// Hidden determines whether the command group is hidden. It is used to satisfy
119+
// the [Command] interface.
120+
func (r *RootCommand) Hidden() bool {
121+
return r.Hide
122+
}
123+
124+
// Help compiles structured help information. It is used to satisfy the
125+
// [Command] interface.
126+
func (r *RootCommand) Help() string {
127+
var b strings.Builder
128+
129+
longest := 0
130+
names := make([]string, 0, len(r.Commands))
131+
for name := range r.Commands {
132+
names = append(names, name)
133+
if l := len(name); l > longest {
134+
longest = l
135+
}
136+
}
137+
sort.Strings(names)
138+
139+
fmt.Fprintf(&b, "Usage: %s COMMAND\n\n", r.Name)
140+
for _, name := range names {
141+
cmd := r.Commands[name]()
142+
if cmd == nil {
143+
continue
144+
}
145+
146+
if !cmd.Hidden() {
147+
fmt.Fprintf(&b, " %-*s%s\n", longest+4, name, cmd.Desc())
148+
}
149+
}
150+
151+
return strings.TrimRight(b.String(), "\n")
152+
}
153+
154+
// Run executes the command and prints help output or delegates to a subcommand.
155+
func (r *RootCommand) Run(ctx context.Context, args []string) error {
156+
name, args := extractCommandAndArgs(args)
157+
158+
// Short-circuit top-level help.
159+
if name == "" || name == "-h" || name == "-help" || name == "--help" {
160+
fmt.Fprintln(r.Stderr(), formatHelp(r.Help(), r.Name, r.Flags()))
161+
return nil
162+
}
163+
164+
// Short-circuit version.
165+
if name == "-v" || name == "-version" || name == "--version" {
166+
fmt.Fprintln(r.Stderr(), r.Version)
167+
return nil
168+
}
169+
170+
cmd, ok := r.Commands[name]
171+
if !ok {
172+
return fmt.Errorf("unknown command %q: run \"%s -help\" for a list of "+
173+
"commands", name, r.Name)
174+
}
175+
instance := cmd()
176+
177+
// Ensure the child inherits the streams from the root.
178+
instance.SetStdin(r.stdin)
179+
instance.SetStdout(r.stdout)
180+
instance.SetStderr(r.stderr)
181+
182+
// If this is a subcommand, prefix the name with the parent and inherit some
183+
// values.
184+
if typ, ok := instance.(*RootCommand); ok {
185+
typ.Name = r.Name + " " + typ.Name
186+
typ.Version = r.Version
187+
return typ.Run(ctx, args)
188+
}
189+
190+
if err := instance.Run(ctx, args); err != nil {
191+
// Special case requesting help.
192+
if errors.Is(err, flag.ErrHelp) {
193+
fmt.Fprintln(instance.Stderr(), formatHelp(instance.Help(), r.Name+" "+name, instance.Flags()))
194+
return nil
195+
}
196+
//nolint:wrapcheck // We want to bubble this error exactly as-is.
197+
return err
198+
}
199+
return nil
200+
}
201+
202+
// extractCommandAndArgs is a helper that pulls the subcommand and arguments.
203+
func extractCommandAndArgs(args []string) (string, []string) {
204+
switch len(args) {
205+
case 0:
206+
return "", nil
207+
case 1:
208+
return args[0], nil
209+
default:
210+
return args[0], args[1:]
211+
}
212+
}
213+
214+
// formatHelp is a helper function that does variable replacement from the help
215+
// string.
216+
func formatHelp(help, name string, flags *FlagSet) string {
217+
h := strings.Trim(help, "\n")
218+
if flags != nil {
219+
if v := strings.Trim(flags.Help(), "\n"); v != "" {
220+
h = h + "\n\n" + v
221+
}
222+
}
223+
return strings.ReplaceAll(h, "{{ COMMAND }}", name)
224+
}
225+
226+
// BaseCommand is the default command structure. All commands should embed this
227+
// structure.
228+
type BaseCommand struct {
229+
stdout, stderr io.Writer
230+
stdin io.Reader
231+
}
232+
233+
// Flags returns the base command flags, which is always nil.
234+
func (c *BaseCommand) Flags() *FlagSet {
235+
return nil
236+
}
237+
238+
// Hidden indicates whether the command is hidden. The default is unhidden.
239+
func (c *BaseCommand) Hidden() bool {
240+
return false
241+
}
242+
243+
// Prompt prompts the user for a value. If stdin is a tty, it prompts. Otherwise
244+
// it reads from the reader.
245+
func (c *BaseCommand) Prompt(msg string) (string, error) {
246+
scanner := bufio.NewScanner(io.LimitReader(c.Stdin(), 64*1_000))
247+
248+
if c.Stdin() == os.Stdin && isatty.IsTerminal(os.Stdin.Fd()) {
249+
fmt.Fprint(c.Stdout(), msg)
250+
}
251+
252+
scanner.Scan()
253+
254+
if err := scanner.Err(); err != nil {
255+
return "", fmt.Errorf("failed to read stdin: %w", err)
256+
}
257+
return scanner.Text(), nil
258+
}
259+
260+
// Stdout returns the stdout stream.
261+
func (c *BaseCommand) Stdout() io.Writer {
262+
if v := c.stdout; v != nil {
263+
return v
264+
}
265+
return os.Stdout
266+
}
267+
268+
// SetStdout sets the standard out.
269+
func (c *BaseCommand) SetStdout(w io.Writer) {
270+
c.stdout = w
271+
}
272+
273+
// Stderr returns the stderr stream.
274+
func (c *BaseCommand) Stderr() io.Writer {
275+
if v := c.stderr; v != nil {
276+
return v
277+
}
278+
return os.Stderr
279+
}
280+
281+
// SetStdout sets the standard error.
282+
func (c *BaseCommand) SetStderr(w io.Writer) {
283+
c.stderr = w
284+
}
285+
286+
// Stdin returns the stdin stream.
287+
func (c *BaseCommand) Stdin() io.Reader {
288+
if v := c.stdin; v != nil {
289+
return v
290+
}
291+
return os.Stdin
292+
}
293+
294+
// SetStdout sets the standard input.
295+
func (c *BaseCommand) SetStdin(r io.Reader) {
296+
c.stdin = r
297+
}
298+
299+
// Pipe creates new unqiue stdin, stdout, and stderr buffers, sets them on the
300+
// command, and returns them. This is most useful for testing where callers want
301+
// to simulate inputs or assert certain command outputs.
302+
func (c *BaseCommand) Pipe() (stdin, stdout, stderr *bytes.Buffer) {
303+
stdin = bytes.NewBuffer(nil)
304+
stdout = bytes.NewBuffer(nil)
305+
stderr = bytes.NewBuffer(nil)
306+
c.stdin = stdin
307+
c.stdout = stdout
308+
c.stderr = stderr
309+
return
310+
}

0 commit comments

Comments
 (0)