1. Help as part of the CLI interface
If you are building a CLI for the first time, it is tempting to treat help as something secondary: “the main thing is that it works, and people will figure it out somehow.” In practice, the opposite happens: a person runs the command, makes a mistake in the arguments, gets an unclear error, gets irritated and… stops using the program. That is why help is not decoration, but part of the contract between the application and the user.
One more important point: a CLI is not only “a human in a terminal”, but also scripts. A script will not “guess” what you meant and will not appreciate poetry like “something went wrong”. It needs a stable invocation form and predictable behavior. Help patterns are a way to make your program understandable both for people and for automation.
Help and usage: similar, but answer different questions
At this stage of the course we distinguish two closely related things that beginners often mix up: help and usage. They are similar in that both output hints, but they differ in when and why you show them.
Help is documentation “on user request”: they explicitly asked for -h, --help, or help. Usage is a hint “in response to incorrect usage”: the user made a mistake, and we show a short explanation plus the correct invocation format.
In this lecture we focus on help patterns: how the user asks for help and what they should see. We deliberately do not dive deep into “which exit code to return” here—what matters more for us is the UX entry points into help and a consistent text style.
Three expected help patterns
There are three ways users most often expect to get help. The good news: we can support all three with almost no extra code complexity. The bad news: if you support only “one way that we personally like”, users will still try the other two (and consider your program picky).
Below is a handy expectations table. It is useful to keep it in mind as a UX contract.
| What the user types | What they expect to see | What the help is about |
|---|---|---|
|
general help | list of commands + general format |
|
help for add | how to run add, which flags |
|
help for add | the same, but via a separate help command |
Often an additional “implicit” pattern is added: if the user types just todo with no arguments, the program usually shows the root help. This is not strictly required, but it is very friendly: “you came without a command—here is a map.”
2. A consistent help format
Even if you implement all entry points (help, -h, help <cmd>), you can still ruin everything if the root help and subcommand help look different. Users get used to a structure: where to look for Usage, where flags are, where examples are. And if one command starts with examples, another with flags, a third with philosophy about the meaning of tasks, people will spend effort not on work but on “decoding the interface.”
Let’s agree on a simple skeleton. It is the same for the root command and for subcommands, but the content differs:
- Usage: — the canonical invocation format
- Commands: — only for root help (list of subcommands)
- Flags: — the flags of this exact command (root or a specific subcommand)
- Examples: — 2–4 examples, as short as possible
Important: sections are not “a lot of text”. They are anchors that let the eye quickly find what it needs.
PrintDefaults() instead of “listing flags by hand”
A common beginner problem: help is written manually… and then flags change, and help becomes outdated. The classic result: documentation lies, and the user suffers.
In Go, the flag package provides a simple way to avoid desynchronization: PrintDefaults() prints the flags you actually declared in code, along with their descriptions. The idea is simple: the flag description lives next to the flag declaration. So when you change a flag, you almost automatically update the help as well.
Yes, you can write “prettier” help by hand, but for a learning project (and for many real utilities) it is an unnecessarily expensive luxury.
Usage examples: short and copyable
The Examples: section is a place where beginners often either write nothing, or write a novel. Both are bad. A user in a terminal does not want to read “the origin story of the todo app”. They want one or two commands they can copy and get a result.
Good examples usually look like this:
- one example for “the most common scenario”
- one example for “a bit more advanced” (for example, with a flag)
- one example for “checking state” (for example, list)
An example for root help:
func printRootHelp() {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " todo <cmd> [flags] [args]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " todo add -title \"buy milk\"")
fmt.Fprintln(os.Stderr, " todo list")
}
And for add you can do this:
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " todo add -title <text>")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " todo add -title \"call mom\"")
}
Notice again: these are short lines, no more than 2–4 examples. Help should be free of unnecessary philosophy, but still nourishing.
3. Root help: todo help and todo -h
Root help is general. It answers the question: “Which commands exist and how do I choose the one I need?”. At the code level it is convenient to keep it in a separate function so the dispatcher can call it from multiple places: for todo help, for todo -h, for todo with no arguments, and for an unknown command (as a hint like “here is the list of valid commands”).
Pay attention: help/usage is usually printed to stderr so it does not mix with the command’s “meaningful output” (for example, the task list in stdout). This is especially useful when stdout is redirected to a file or a pipe.
Minimal root help implementation:
package main
import (
"flag"
"fmt"
"os"
)
func printRootHelp() {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " todo <cmd> [flags] [args]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Commands: add, list, done, delete, help")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags:")
flag.PrintDefaults()
}
Here we are not showing a “nice commands table” yet, because our goal is the pattern. Later (in topics about formatted output) you will learn to do it even more neatly, but for now what matters is: the text is short, clear, and the sections go in a fixed order.
Root -h: a simple and clear entry point to help
For the root command we do not have a separate FlagSet (yet), because the root usually either has no flags or has “global flags” (for example, --version, --format, --config). In this lecture we do not complicate the architecture with global flags; it is enough to apply a simple rule: if the first argument is -h or --help, show the root help.
Minimal startup handling:
package main
func dispatch(args []string) {
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") {
printRootHelp()
return
}
// ... then the regular logic
}
Yes, this is a “manual check”, but it is simple, clear, and matches user expectations. When you add global flags, you will replace this with a root FlagSet, but today UX patterns matter more.
4. Subcommand help: todo add -h and todo help add
The user types todo add -h and expects help specifically for add, not general help. Here we have two key points.
First: each command has its own flag.FlagSet, because each command has its own set of flags. Second: each FlagSet can have its own Usage function, which prints help specifically for that command.
FlagSet and Usage for the add command
A constructor skeleton for a FlagSet for the add command:
package main
import (
"flag"
"fmt"
"os"
)
func newAddFlagSet() *flag.FlagSet {
fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
_ = fs.String("title", "", "task title (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage:")
fmt.Fprintln(os.Stderr, " todo add -title <text>")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags:")
fs.PrintDefaults()
}
return fs
}
Pay attention to flag.ContinueOnError. You need it so the parser does not terminate the process by itself, but instead returns an error to the caller. Otherwise you lose control over behavior.
The help command as a normal subcommand
The idea is simple: help is the same kind of subcommand as add or list, except instead of performing an action, it displays help.
We need to support two scenarios:
- todo help → root help
- todo help add → help for the add subcommand (as if it were todo add -h)
Minimal dispatcher logic for the help command:
package main
import "os"
func dispatch(args []string) {
if len(args) == 0 {
printRootHelp()
return
}
if args[0] == "help" {
if len(args) == 1 {
printRootHelp()
return
}
printCmdHelp(args[1])
return
}
_ = os.Stdout // next comes parsing regular commands
}
The implementation of printCmdHelp can be straightforward: “create the command’s FlagSet and call Usage().”
package main
import "fmt"
func printCmdHelp(cmd string) {
switch cmd {
case "add":
newAddFlagSet().Usage()
default:
fmt.Fprintln(os.Stderr, "unknown command:", cmd)
printRootHelp()
}
}
Here we intentionally implement a fallback: if the command is unknown, we still show the root help so the user is not left in a void.
Why todo <cmd> -h “magically” works in flag
The flag package has built-in support for -h/-help/--help (behavior depends on the specific parser and mode). From a practical standpoint, what matters for us is this: if the user passed -h, the parser should lead us to printing fs.Usage().
To make that happen predictably, we already did two things:
- fs := flag.NewFlagSet(..., flag.ContinueOnError)
- fs.Usage = func() { ... }
Then the command launch template is: “created fs → declared flags → fs.Parse(argv) → if there is an error, it is either help or a usage error”.
A pseudo-handler for the add command, which we need specifically for the help pattern:
package main
import (
"errors"
"flag"
)
func runAdd(argv []string) error {
fs := newAddFlagSet()
if err := fs.Parse(argv); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil // help has already been shown via fs.Usage()
}
return err
}
return nil
}
Notice an important idea: help is not an “error”, it is a “behavior branch”. Even if under the hood flag returns flag.ErrHelp, at the application level we treat it as a normal situation: the user asked for help.
5. Diagram and minimal main.go
Sometimes it is useful to visualize the flow—especially if you later expand the list of commands and are afraid of “breaking help”.
flowchart TD
U[User enters a command] --> D["dispatch(args)"]
D -->|todo / todo help| RH["printRootHelp()"]
D -->|todo -h| RH
D -->|todo help add| CH["printCmdHelp('add')"]
D -->|todo add -h| AC["runAdd(argv)"]
AC -->|flag.ErrHelp| AH["fs.Usage()"]
The idea is simple: the user has many “doors” into help, but all of them lead to one consistent text style that you control.
Below is a minimal application “skeleton” that demonstrates the help patterns. This is not the final architecture, but a learning assembly point: it includes root help, a help subcommand, and help for add.
package main
import "os"
func main() {
dispatch(os.Args[1:])
}
package main
import "fmt"
func dispatch(args []string) {
if len(args) == 0 {
printRootHelp()
return
}
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") {
printRootHelp()
return
}
if args[0] == "help" {
if len(args) == 1 {
printRootHelp()
return
}
printCmdHelp(args[1])
return
}
if args[0] == "add" {
_ = runAdd(args[1:])
return
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
printRootHelp()
}
Yes, runAdd does “nothing” yet besides parsing flags and help. That is fine: today our goal is the UX patterns of help.
6. Common mistakes in help patterns
Mistake #1: root help and subcommand help are written in different styles.
This often happens when you first “quickly sketched” the root help, then added the add command and wrote its help in a completely different way. As a result, the user has to relearn how to read your CLI every time. The fix is simple: define one section skeleton (Usage / Flags / Examples, plus Commands for the root) and copy it as a template.
Mistake #2: flags are listed manually, and help becomes outdated.
Today you have the -title flag; tomorrow you add -priority, but forget to update help. The user follows help—and gets an error. In Go this is especially annoying because PrintDefaults() solves it almost for free: the flag description lives next to the declaration, and help “updates itself”.
Mistake #3: todo help add works, but todo add -h does not (or vice versa).
The user is not required to memorize the “correct path” to help in your program. They will try both. So it is better to support both patterns: help as a command and -h as a flag. If everything is built around FlagSet.Usage(), this is usually not hard: help <cmd> simply calls fs.Usage().
Mistake #4: help is printed to stdout and breaks the command’s “data output”.
Imagine todo list prints a task list to stdout, and the user runs todo list > tasks.txt. If help/error output suddenly also goes to stdout, the file will be “full of junk” and the script will be broken. That is why help and error messages are printed to stderr, while the “command result” is printed to stdout.
Mistake #5: help turns into a “wall of text”, and there are no examples.
It is very tempting to explain everything: what a task is, why it matters, how the world works, but the user came for a quick answer to “how do I run this?”. If help is long, the eye cannot find what matters. Less text is better, but обязательно include an Examples section with 2–3 copyable commands.
GO TO FULL VERSION