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

guides › add progress to a command

Add progress to a command

Give a command the --progress flag, a display on standard error, an event log and a summary, with cliprogress.

A command that runs for more than a second shows how far it got. This guide adds the flag --progress, the displays, the event log and the summary to the main of a program.

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]
What the user gets with –progress=tty.

Procedure#

  1. Read the flag and the variable that stands in for it, such as CLUSTERCTL_PROGRESS. Put them in a cliprogress.Setting.
  2. Ask the system about standard error: cliprogress.IsTerminal, cliprogress.TerminalSize, cliprogress.InForeground and cliprogress.UTF8Locale.
  3. Pick the theme, and let cliprogress.ThemeFor take out the colours that NO_COLOR and TERM forbid.
  4. Call cliprogress.Start with a context that ends on the interrupt. An error is a usage error: report it as such.
  5. Put the writer of your logger through cliprogress.Session.Lines, and your own writes to the terminal through cliprogress.Session.Writer.
  6. Start the command’s span in cliprogress.Session.Context, run the work, and end the span with the error.
  7. Call cliprogress.Session.Finish. Then put your writers back, and write the error.
flags := flag.NewFlagSet("clusterctl", flag.ContinueOnError)
mode := flags.String("progress", "", "how to show progress: auto, tty, counter, plain or none")
if err := flags.Parse(os.Args[1:]); err != nil {
	return
}
given := false
flags.Visit(func(f *flag.Flag) { given = given || f.Name == "progress" })

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
theme, _ := display.ParseTheme("tide")
onTerminal := cliprogress.IsTerminal(os.Stderr)
session, err := cliprogress.Start(ctx, cliprogress.Options{
	Program: "clusterctl",
	Mode: cliprogress.Setting{
		Flag: "--progress", Given: given, Value: *mode,
		Variable: "CLUSTERCTL_PROGRESS", Env: os.Getenv("CLUSTERCTL_PROGRESS"),
	},
	Stderr:     os.Stderr,
	OnTerminal: onTerminal,
	Dumb:       os.Getenv("TERM") == "dumb",
	Size:       func() (int, int, error) { return cliprogress.TerminalSize(os.Stderr) },
	Foreground: func() bool { return cliprogress.InForeground(os.Stderr) },
	ASCII:      !cliprogress.UTF8Locale(os.Getenv),
	Theme:      cliprogress.ThemeFor(theme, onTerminal, os.Getenv),
	Noun: func(n int) string {
		if n == 1 {
			return "host"
		}
		return "hosts"
	},
})
if err != nil {
	fmt.Fprintln(os.Stderr, "clusterctl:", err) // a usage error
	return
}
// The logger's lines go through the display while it draws.
log.SetOutput(session.Lines(os.Stderr))

ctx, cmd := progress.Start(session.Context(), progress.KindCommand, "reinstall")
err = reinstall(ctx, flags.Args())
cmd.End(err)
session.Finish(err != nil)
log.SetOutput(os.Stderr)
if err != nil {
	fmt.Fprintln(os.Stderr, "clusterctl:", err)
}
Example_mainWithProgress in internal/docsite/example_test.go. CI compiles it.
CAUTION

End the command’s span before you call Finish. Finish closes the Bus, and a span that is open then ends as canceled.

What the user gets#

--progressStandard error shows
auto (the default)the tree on a terminal, nothing elsewhere
ttythe tree, or the counter on a terminal too small for it
counterone line, redrawn
plainone line for each change worth one, also in a log
nonewhat it shows without the kit, byte for byte

The words, and their order, are an interface (decision 21). The texts of the errors and notes that cliprogress writes are an interface too.

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.

Ask a question#

A question waits for its answer with no display drawn over it. cliprogress.Ask takes the displays off the terminal while it waits, and cliprogress.Yes reads the answer.

answer, err := cliprogress.Ask(context.Background(), strings.NewReader("y\n"), os.Stdout, "Delete 3 hosts? [y/N] ")
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(cliprogress.Yes(answer))
Delete 3 hosts? [y/N] true
ExampleAsk in progress/cliprogress/example_test.go. CI runs it and checks its output.