A Real CLI
Rebuild the CLI with Cobra
Goal
By the end of this lesson, every Tasker command is built with Cobra, the library behind kubectl, the Docker CLI, Hugo, and the GitHub CLI. You get automatic help pages, argument checks, short and long flags, a global --file flag, command aliases, "did you mean" suggestions, and shell completion, with less code than before.
What you will learn
- How to add a third-party library with
go get, and whatgo.modandgo.sumrecord - How to define commands with
cobra.Command - How to validate the number of arguments
- The difference between local flags and persistent (global) flags
- What a function literal and a closure are
- Why commands should write to
cmd.OutOrStdout()instead ofos.Stdout
Why this matters
In Lesson 3.4, you wrote the command routing, the usage text, and the flag parsing by hand. Every new command meant more switch cases and more help text to keep up to date. Cobra handles all of that from a description of each command. Most professional Go command-line tools use it, so the structure you learn here is the structure you will find in real projects.
Step 1: Add Cobra to the project
Run this in the project root:
go get github.com/spf13/cobra@latest
Go downloads Cobra and the libraries it needs. Open go.mod. It now has a require section, similar to this:
require github.com/spf13/cobra v1.10.2
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
Your version numbers may be newer.
requirelists every module your project depends on, with an exact version.// indirectmarks modules that your code does not import directly. Cobra needs them.pflagis the flag library Cobra uses.- A new file,
go.sum, appears next togo.mod. It stores a checksum for every downloaded module. If someone changes a library's code after you downloaded it, the checksum does not match, and Go refuses to build. Commit both files to Git.
At the end of this lesson, you run go mod tidy. It removes modules you no longer use and marks direct dependencies correctly.
Step 2: Plan the new layout
The command code moves into a new package, internal/cli. The main package becomes very small:
cmd/tasker/
└── main.go (only calls the root command)
internal/cli/
├── root.go (the tasker command and global flags)
├── path.go (finds the task file)
├── add.go
├── done.go
├── list.go
└── shell.go
One file per command keeps each file short. When you add a command, you add a file.
Delete cmd/tasker/commands.go and cmd/tasker/path.go. Move cmd/tasker/shell.go to internal/cli/shell.go. You update it in Step 8.
Step 3: The root command
Create internal/cli/root.go:
// Package cli defines the commands of the tasker program.
package cli
import (
"github.com/spf13/cobra"
"github.com/yourname/tasker/internal/storage"
"github.com/yourname/tasker/internal/task"
)
// Version is the current version of Tasker.
const Version = "0.2.0"
// options holds the values of flags that every command can use.
type options struct {
file string
}
// NewRootCmd builds the tasker command with all its subcommands.
func NewRootCmd() *cobra.Command {
opts := &options{}
root := &cobra.Command{
Use: "tasker",
Short: "Tasker is a simple task manager for your terminal",
Long: `Tasker is a simple task manager for your terminal.
It stores tasks in a JSON file. By default, the file is ~/.tasker/tasks.json.
Use the --file flag or the TASKER_FILE environment variable to use another file.`,
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
}
root.PersistentFlags().StringVar(&opts.file, "file", "", "path to the task file")
root.AddCommand(
newAddCmd(opts),
newListCmd(opts),
newDoneCmd(opts),
newShellCmd(opts),
)
return root
}
// openStore opens the store that uses the task file.
func (o *options) openStore() (*task.Store, error) {
path, err := dataPath(o.file)
if err != nil {
return nil, err
}
return task.Open(storage.NewFile(path))
}
A cobra.Command is a struct. You describe the command with its fields:
Useis the command name and a short usage pattern.Shortis the one-line description in command lists.Longis shown on the command's own help page.Versionadds a--versionflag automatically.SilenceUsage: truestops Cobra from printing the full usage text after every error. A wrong task ID should show the error, not a whole help page.SilenceErrors: truestops Cobra from printing errors itself.mainprints them, in Tasker'serror: ...format.
root.PersistentFlags() defines flags that work on the root command and all its subcommands. --file is a persistent flag, so tasker list --file x.json and tasker --file x.json add Test both work. StringVar stores the flag value directly in opts.file.
root.AddCommand attaches the subcommands. Each one is built by a function that receives opts, so every command can read the --file value.
Step 4: Find the task file
Create internal/cli/path.go. It is the dataPath function from Lesson 3.3, with one new rule: the flag wins.
package cli
import (
"fmt"
"os"
"path/filepath"
)
// dataPath returns the path of the task file.
// The --file flag wins over the TASKER_FILE environment variable,
// and both win over the default path in the home folder.
func dataPath(flagValue string) (string, error) {
if flagValue != "" {
return flagValue, nil
}
if path := os.Getenv("TASKER_FILE"); path != "" {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find home folder: %w", err)
}
return filepath.Join(home, ".tasker", "tasks.json"), nil
}
The order "flag, then environment variable, then default" is a convention that most command-line tools follow. The most specific setting wins.
Step 5: The add command and closures
Create internal/cli/add.go:
package cli
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yourname/tasker/internal/task"
)
func newAddCmd(opts *options) *cobra.Command {
return &cobra.Command{
Use: "add <title> [#tag...]",
Short: "Add a new task",
Example: ` tasker add Buy milk '#shop'`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := opts.openStore()
if err != nil {
return err
}
title, tags := task.SplitTags(args)
t, err := store.Add(title, tags)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Added:", t)
return nil
},
}
}
Args: cobra.MinimumNArgs(1) checks the arguments before your code runs. Cobra has several of these checks: NoArgs, ExactArgs(n), MinimumNArgs(n), MaximumNArgs(n), and RangeArgs(min, max).
RunE is the function that runs the command. The E means it returns an error. Cobra passes the error up to Execute, and main prints it.
Look at how RunE is written: func(cmd *cobra.Command, args []string) error { ... }. This is a function literal, a function without a name, written directly as a value. In Go, functions are values. You can store them in variables and struct fields, and pass them to other functions.
The function literal uses opts, a variable from newAddCmd, even though Cobra calls it much later, after newAddCmd has returned. A function literal that uses variables from its surrounding function is called a closure. It keeps those variables alive. This is how each command reads the --file flag value: Cobra parses the flag into opts.file, and the closure reads it when the command runs.
cmd.OutOrStdout() returns the output stream for the command. Normally it is os.Stdout. In Module 5, your tests replace it with a buffer and check what the command printed. If you write to os.Stdout directly, tests cannot capture the output.
Step 6: The done command
Create internal/cli/done.go:
package cli
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yourname/tasker/internal/task"
)
func newDoneCmd(opts *options) *cobra.Command {
return &cobra.Command{
Use: "done <id>",
Aliases: []string{"complete"},
Short: "Mark a task as done",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := task.ParseID(args[0])
if err != nil {
return err
}
store, err := opts.openStore()
if err != nil {
return err
}
t, err := store.Complete(id)
if err != nil {
return fmt.Errorf("task %d: %w", id, err)
}
fmt.Fprintln(cmd.OutOrStdout(), "Completed:", t)
return nil
},
}
}
Aliases gives a command other names. Now tasker complete 3 works the same as tasker done 3.
Step 7: The list command and local flags
Create internal/cli/list.go:
package cli
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"github.com/yourname/tasker/internal/task"
)
func newListCmd(opts *options) *cobra.Command {
var (
tag string
openOnly bool
asJSON bool
)
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List tasks",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := opts.openStore()
if err != nil {
return err
}
tasks := task.Filter(store.List(), tag, openOnly)
out := cmd.OutOrStdout()
if asJSON {
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return err
}
fmt.Fprintln(out, string(data))
return nil
}
for _, t := range tasks {
fmt.Fprintln(out, t)
}
return nil
},
}
cmd.Flags().StringVarP(&tag, "tag", "t", "", "show only tasks with this tag")
cmd.Flags().BoolVar(&openOnly, "open", false, "show only open tasks")
cmd.Flags().BoolVar(&asJSON, "json", false, "print tasks as JSON")
return cmd
}
cmd.Flags() defines local flags. They only work on this command.
The Var functions store the value in a variable you provide, so you do not need pointers as with the flag package. The flag variables tag, openOnly, and asJSON are captured by the RunE closure, the same way opts is.
StringVarP has an extra argument, "t", the short name. Cobra uses the common style from Linux tools: long flags start with two dashes (--tag) and short flags with one (-t).
Step 8: Move the shell into Cobra
Open internal/cli/shell.go. Change the first line to package cli. Replace the whole beginning of the old runShell function, up to and including the line that prints Loaded %d tasks, with this:
func newShellCmd(opts *options) *cobra.Command {
var inMemory bool
cmd := &cobra.Command{
Use: "shell",
Short: "Start an interactive session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
var store *task.Store
var err error
if inMemory {
store, err = task.Open(storage.NewMemory())
} else {
store, err = opts.openStore()
}
if err != nil {
return err
}
return runShell(store, cmd.InOrStdin(), cmd.OutOrStdout())
},
}
cmd.Flags().BoolVar(&inMemory, "memory", false, "keep tasks in memory only and do not touch the task file")
return cmd
}
// runShell reads commands from in and writes results to out until the input ends.
func runShell(store *task.Store, in io.Reader, out io.Writer) error {
fmt.Fprintf(out, "Loaded %d tasks\n", store.Len())
scanner := bufio.NewScanner(in)
The rest of the shell stays as it is, with three mechanical changes:
- Replace every
fmt.Println(withfmt.Fprintln(out,. - Replace every
fmt.Printf(withfmt.Fprintf(out,, andfmt.Print("> ")withfmt.Fprint(out, "> "). - In the imports, remove
"flag"and"os", and add"io"and"github.com/spf13/cobra".
runShell now reads from any io.Reader and writes to any io.Writer. These are two of the small interfaces from the standard library, with one method each. A file, the keyboard, a network connection, and a buffer in memory all satisfy them. In Module 5, you test the shell by giving it a string as input and checking the output, without typing anything.
Step 9: The new main
Replace cmd/tasker/main.go:
package main
import (
"fmt"
"os"
"github.com/yourname/tasker/internal/cli"
)
func main() {
if err := cli.NewRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Execute reads os.Args, finds the right command, checks the arguments and flags, and calls RunE. The pattern from Lesson 3.4 is still here: one place prints errors and sets the exit code.
Now tidy the module and build:
go mod tidy
go build ./cmd/tasker
Step 10: Try it
Run ./tasker with no arguments. Cobra generates the help page:
Tasker is a simple task manager for your terminal.
It stores tasks in a JSON file. By default, the file is ~/.tasker/tasks.json.
Use the --file flag or the TASKER_FILE environment variable to use another file.
Usage:
tasker [command]
Available Commands:
add Add a new task
completion Generate the autocompletion script for the specified shell
done Mark a task as done
help Help about any command
list List tasks
shell Start an interactive session
Flags:
--file string path to the task file
-h, --help help for tasker
-v, --version version for tasker
Use "tasker [command] --help" for more information about a command.
You did not write any of this text by hand. It comes from the Use, Short, and Long fields and the flag definitions.
Try the commands:
export TASKER_FILE=./data/tasks.json
./tasker add Buy milk '#shop'
./tasker add Pay rent
./tasker complete 1
./tasker ls -t shop
./tasker --version
Added: 1. [ ] Buy milk #shop
Added: 2. [ ] Pay rent
Completed: 1. [x] Buy milk #shop
1. [x] Buy milk #shop
tasker version 0.2.0
Try some mistakes:
./tasker add
./tasker done 1 2
./tasker lst
./tasker list --colour
error: requires at least 1 arg(s), only received 0
error: accepts 1 arg(s), received 2
error: unknown command "lst" for "tasker"
Did you mean this?
list
error: unknown flag: --colour
Cobra checks arguments and flags before your code runs, and it even suggests the command you probably meant.
Step 11: Shell completion
Cobra added a completion command for you. It generates a script that lets your terminal complete Tasker commands and flags when you press Tab. For example, in Bash:
source <(./tasker completion bash)
Now type ./tasker l and press Tab. The terminal completes list. Type ./tasker list -- and press Tab twice to see all flags. Run ./tasker completion --help for Zsh, Fish, and PowerShell instructions.
Why it works: describe commands, let the library do the routing
With the flag package, you wrote how to parse input. With Cobra, you describe what each command is: its name, its arguments, its flags, and the function to run. Cobra builds the routing, the validation, the help pages, and the completion from that description.
The task and storage packages did not change at all. You replaced the whole user interface, and the core of Tasker stayed the same. This is the separation from Module 3 paying off.
Exercises
- A
deletecommand. Addinternal/cli/delete.gowith adelete <id>command and the aliasrm. Usecobra.ExactArgs(1). - A
--doneflag. Add--donetolist. Usecmd.MarkFlagsMutuallyExclusive("open", "done")so Cobra rejects both flags together. Read the error it prints. - A
tagscommand. Addtasker tags, which prints each tag and its count. Usecobra.NoArgs. - Better examples. Add an
Examplefield tolistanddone. Run./tasker list --helpto see it. - Complete task IDs. Cobra commands have a
ValidArgsFunctionfield. Use it indoneto suggest the IDs of open tasks when the user presses Tab. Hint: return a[]stringof IDs andcobra.ShellCompDirectiveNoFileComp.
Checkpoint
You are ready for the next lesson if you can:
- Add a library with
go getand explain whatgo.modandgo.sumstore - Define a Cobra command with
Use,Short,Args, andRunE - Explain the difference between
Flags()andPersistentFlags() - Explain what a closure is and how
RunEreads flag values - Explain why commands write to
cmd.OutOrStdout()
