❯ go-clikit
v0.10.0 pkg.go.dev ↗

tour

Tour: your first command

Follow one command from the first span to its test. Each step adds one package of the kit, and each code block is an example that CI runs.

This tour builds the parts of a command that resets machines. It has seven steps. You need Go 1.26 or later, and one command:

$ go get github.com/GSI-HPC/go-clikit/progress

Each code block below is an Example function of the kit. CI runs each one and compares its output with the output that the block shows. So the code on this page compiles, and does what the page says.

1234567ReportNo costMany hostsShowStrict flagsResultTestprogressprogressfanoutcliprogresscobratreeclioutprogresstest
The tour adds one package at each step. The arrows show the order.

1. Report the work of a library#

A library does not draw. It reports its work as spans. A span is one unit of work: here, one step that resets the machines, and one target for each machine. The span goes in the context.Context, so the library needs no new parameter.

// reset is library code: it reports its work as spans in ctx, whether a
// command watches or not.
func reset(ctx context.Context, nodes []string) error {
	ctx, step := progress.Start(ctx, progress.KindStep, "reset the machines",
		progress.Flagged(progress.FlagFold), progress.Total(len(nodes)))
	// Every target is announced queued before the first runs, so that a
	// display knows the whole of the work from the start.
	targets := make([]*progress.Span, len(nodes))
	for i, node := range nodes {
		_, targets[i] = progress.Start(ctx, progress.KindTarget, node, progress.Queued(), progress.Node(node))
	}
	var err error
	for i, target := range targets {
		target.Run()
		if nodes[i] == "exe02" {
			failed := errors.New("exe02: no answer")
			target.End(failed)
			err = failed
			continue
		}
		target.End(nil)
	}
	step.End(err)
	return err
}
reset in progress/example_test.go, which the examples of the package call.

Three things in this code are important:

  • The step says that it folds its targets (FlagFold) and how many it expects (Total). A display can then count the work from the start.
  • Each target is started queued, with Queued, before the first one runs. progresstest.Check holds every library to this rule.
  • End takes the error of the work. A nil error ends the span ok. Any other error ends it failed or canceled.

A command makes a Bus, puts it in the context, and starts its own span. Here, the sink of the Bus prints each event:

bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{printer{}}})
ctx, command := progress.Start(progress.WithBus(context.Background(), bus), progress.KindCommand, "power reset")
command.End(reset(ctx, []string{"exe01", "exe02"}))
bus.Close()
1 start command "power reset" running
2 start step "reset the machines" running
3 start target "exe01" queued
4 start target "exe02" queued
5 run target "exe01" running
6 end target "exe01" ended ok
7 run target "exe02" running
8 end target "exe02" ended failed (target): exe02: no answer
9 end step "reset the machines" ended failed (target): exe02: no answer
10 end command "power reset" ended failed (target): exe02: no answer
Example in progress/example_test.go. CI runs it and checks its output.

Each event has a number, Seq. Every sink gets the events in this one order. Read more in Spans and the Bus.

2. No listener, no cost#

A library can report its work when no command listens. Without a Bus in the context, Start gives back the same context and a nil span. The methods of a nil span do nothing and allocate nothing.

ctx := context.Background()
got, span := progress.Start(ctx, progress.KindStep, "reset the machines")
span.Run()
span.End(nil)
fmt.Println(got == ctx, span == nil)
true true
ExampleStart_withoutABus in progress/example_test.go. CI runs it and checks its output.

3. Run the work on many hosts#

The loop in step 1 resets one machine at a time. fanout.Map runs the work on many items at once, at most Limit at a time. It reports each item as a target, and keeps the rules of step 1 for you.

capture := &progresstest.Capture{}
bus := progress.NewBus(progress.BusOptions{Sinks: []progress.Sink{capture}})
ctx := progress.WithBus(context.Background(), bus)

nodes := []string{"exe01", "exe02", "exe03", "exe04"}
outcomes, err := fanout.Map(ctx, nodes, fanout.MapOptions[string]{Step: "uptime", Limit: 2},
	func(_ context.Context, node string) (string, error) {
		if node == "exe03" {
			return "", fmt.Errorf("%s: no answer", node)
		}
		return strings.ToUpper(node), nil
	})
for i, o := range outcomes {
	fmt.Println(nodes[i], o.Value, o.Err)
}
fmt.Println(err)
bus.Close()
fmt.Print(capture.Tree())
exe01 EXE01 <nil>
exe02 EXE02 <nil>
exe03  exe03: no answer
exe04 EXE04 <nil>
1 of 4 failed: exe03
step uptime total=4 limit=2 [fold]: failed (target): 1 of 4 failed: exe03
  target exe03: failed (target): {}: no answer
  target exe[01-02,04]: ok
ExampleMap in fanout/example_test.go. CI runs it and checks its output.

Map gives the outcomes in the order of the items. The error of the step names each item that failed. The last lines are the tree of spans that progresstest.Capture kept, with the targets folded into node sets.

4. Show the work#

The command decides how to show the work. cliprogress reads the words of --progress and gives the command a Session: the Bus, the display, the event log and the summary of one command.

clock := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
r, err := cliprogress.Start(context.Background(), cliprogress.Options{
	Program: "prog",
	Mode:    cliprogress.Setting{Flag: "--progress", Given: true, Value: "plain"},
	Stderr:  os.Stdout,
	Now:     func() time.Time { return clock },
})
if err != nil {
	fmt.Println(err) // a usage error
	return
}
ctx, cmd := progress.Start(r.Context(), progress.KindCommand, "delete cluster")
_, step := progress.Start(ctx, progress.KindStep, "mesh")
clock = clock.Add(2 * time.Second)
failed := errors.New("network sind-mesh has active endpoints")
step.End(failed)
cmd.End(failed)
r.Finish(true)
[0:00] delete cluster › mesh: start
[0:02] delete cluster › mesh: failed in 2.0s
prog: delete cluster: failed in 2.0s
ExampleStart in progress/cliprogress/example_test.go. CI runs it and checks its output.

This example asks for plain lines, which a test can compare. On a terminal, auto draws the live tree:

reinstall exe[01-12] --progress=tty
✓ fetching the base image  5.0s  2.0 GiB at 410 MiB/s
reinstall ◦ 0:10.5
╎ reinstalling the nodes  ◉◉◉◉◉◌◌◌◌◌ 6/12 ◦ 4 running ◦ 2 queued
  ╎ ◡ exe07  2.8s/30s  POST /redfish/v1/Systems/1
  ╎ ◡ exe08  2.1s/30s  POST /redfish/v1/Systems/1
  ╎ ◡ exe09  1.4s/30s  POST /redfish/v1/Systems/1
  ╎ ◡ exe10  0.7s/30s  POST /redfish/v1/Systems/1
  ╎ ✓ exe[01-06]
The live tree, recorded from progress/display by a test of the kit. Pick a theme.
NOTE

The kit reads no environment. The program reads --progress, the variable that stands in for it, NO_COLOR and TERM, and gives what it read to cliprogress. Read more in Policy stays in the program.

5. Make the command line strict#

cobra lets some mistakes pass. A word that names no command prints the help and succeeds, so a script with a typing error goes on to its next step. cobratree.Strict refuses these mistakes, and marks each error as a usage error.

root := &cobra.Command{Use: "prog", SilenceErrors: true, SilenceUsage: true}
get := &cobra.Command{Use: "get", Short: "Show things"}
get.AddCommand(&cobra.Command{
	Use:  "cluster [NAME]",
	Args: cobra.MaximumNArgs(1),
	RunE: func(*cobra.Command, []string) error { return nil },
})
root.AddCommand(get)
cobratree.Strict(root, cobratree.Moved(map[string]string{"status": "get cluster"}))

for _, args := range [][]string{
	{"gte"},
	{"status"},
	{"help", "get", "clustr"},
	{"get", "cluster", "a", "b"},
	{"get", "cluster", "--bogus"},
} {
	root.SetArgs(args)
	err := root.Execute()
	code := 1
	if cobratree.IsUsage(err) {
		code = 2
	}
	fmt.Printf("%d %v\n", code, err)
}
2 unknown command "gte" for "prog" (did you mean "get"?)
2 unknown command "status" for "prog" (it is now "get cluster")
2 unknown help topic "get clustr": unknown command "clustr" for "prog get" (did you mean "cluster"?)
2 accepts at most 1 arg(s), received 2 (usage: prog get cluster [NAME])
2 unknown flag: --bogus
ExampleStrict in cobratree/example_test.go. CI runs it and checks its output.

The kit names no exit code. In this example the program gives a usage error the code 2, which it finds with IsUsage.

6. Write the result#

The result goes to standard output, as a table for a person or as JSON for a program. cliout aligns each column by the columns that a terminal gives the text, and escapes each cell.

table := &cliout.Table{
	Columns: []cliout.Column{{Name: "NODE"}, {Name: "CPUS", Right: true}, {Name: "STATE"}, {Name: "REASON", Wide: true}},
	Caption: "3 nodes, 1 drained",
}
table.Add("exe0001", "128", "drained", "fan failure\nexe0002 idle")
table.Add("exe0002", "64", "idle")
table.Add("exe10", "8", "idle")

for _, wide := range []bool{false, true} {
	if err := table.Write(os.Stdout, wide); err != nil {
		fmt.Println(err)
	}
	fmt.Println("--")
}
NODE     CPUS  STATE
exe0001   128  drained
exe0002    64  idle
exe10       8  idle

3 nodes, 1 drained
--
NODE     CPUS  STATE    REASON
exe0001   128  drained  fan failure\nexe0002 idle
exe0002    64  idle
exe10       8  idle

3 nodes, 1 drained
--
ExampleTable in cliout/example_test.go. CI runs it and checks its output.

The reason of exe0001 holds a newline. Without the escape, the terminal would show a row that is not in the table.

7. Test what the command reports#

progresstest.Watch gives a test a context with a Bus of its own. When the test ends, Check holds the events to the rules of the kit, and Finish gives the tree of spans, in an order that does not change from run to run.

_ = func(t *testing.T) {
	ctx, w := progresstest.Watch(t.Context(), t)
	_ = reset(ctx, []string{"exe1", "exe2", "exe3"})
	want := `step reset the machines total=3 [fold]: failed (target): exe3: no answer
  target exe3: failed (target): {}: no answer
  target exe[1-2]: ok
`
	if got := w.Finish(); got != want {
		t.Errorf("the work reported\n%s\nwant\n%s", got, want)
	}
}
ExampleWatch in progress/progresstest/example_test.go. CI compiles it.

You have used seven packages. The guides each do one task in full. The concepts tell why the kit works as it does. pkg.go.dev has every name.