A Real CLI

Rebuild the CLI with Cobra

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

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 what go.mod and go.sum record
  • 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 of os.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.

  • require lists every module your project depends on, with an exact version.
  • // indirect marks modules that your code does not import directly. Cobra needs them. pflag is the flag library Cobra uses.
  • A new file, go.sum, appears next to go.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:

  • Use is the command name and a short usage pattern.
  • Short is the one-line description in command lists. Long is shown on the command's own help page.
  • Version adds a --version flag automatically.
  • SilenceUsage: true stops Cobra from printing the full usage text after every error. A wrong task ID should show the error, not a whole help page.
  • SilenceErrors: true stops Cobra from printing errors itself. main prints them, in Tasker's error: ... 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( with fmt.Fprintln(out, .
  • Replace every fmt.Printf( with fmt.Fprintf(out, , and fmt.Print("> ") with fmt.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

  1. A delete command. Add internal/cli/delete.go with a delete <id> command and the alias rm. Use cobra.ExactArgs(1).
  2. A --done flag. Add --done to list. Use cmd.MarkFlagsMutuallyExclusive("open", "done") so Cobra rejects both flags together. Read the error it prints.
  3. A tags command. Add tasker tags, which prints each tag and its count. Use cobra.NoArgs.
  4. Better examples. Add an Example field to list and done. Run ./tasker list --help to see it.
  5. Complete task IDs. Cobra commands have a ValidArgsFunction field. Use it in done to suggest the IDs of open tasks when the user presses Tab. Hint: return a []string of IDs and cobra.ShellCompDirectiveNoFileComp.

Checkpoint

You are ready for the next lesson if you can:

  • Add a library with go get and explain what go.mod and go.sum store
  • Define a Cobra command with Use, Short, Args, and RunE
  • Explain the difference between Flags() and PersistentFlags()
  • Explain what a closure is and how RunE reads flag values
  • Explain why commands write to cmd.OutOrStdout()