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.
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.Checkholds every library to this rule. Endtakes 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 answerprogress/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 trueprogress/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]: okfanout/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.0sprogress/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:
✓ 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]
progress/display by a test of the kit. Pick a theme.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: --boguscobratree/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
--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)
}
}progress/progresstest/example_test.go. CI compiles it.What to read next#
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.