In the previous tutorial, you learned about generics. Now let’s build something practical — a command-line tool.

Go is one of the best languages for building CLI tools. It compiles to a single binary with no dependencies. Tools like Docker, kubectl, Hugo, and GitHub CLI are all written in Go using Cobra. In this tutorial, you will build a complete TODO CLI app.

What is Cobra?

Cobra is a library for creating CLI applications in Go. It provides:

  • Commands and subcommands (app server start, app config set)
  • Flags (--port 8080, -v)
  • Auto-generated help text
  • Shell completion (bash, zsh, fish, PowerShell)

Over 80,000 GitHub stars. Used by the biggest Go projects in the world.

Project Setup

Create a new Go module and install Cobra:

mkdir todo-cli && cd todo-cli
go mod init github.com/kemalcodes/todo-cli
go get github.com/spf13/cobra

Your First Cobra Command

Start with a simple root command:

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {
    rootCmd := &cobra.Command{
        Use:   "todo",
        Short: "A simple TODO CLI app",
        Long:  "Todo is a command-line task manager. Add, list, complete, and delete tasks from your terminal.",
    }

    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Run it:

go run main.go
# Todo is a command-line task manager. Add, list, complete, and delete tasks from your terminal.
#
# Usage:
#   todo [command]
#
# Available Commands:
#   help        Help about any command

Cobra generates the help text automatically. Let’s add real commands.

Building the TODO App

Our app will have four commands:

  • todo add "Buy groceries" — add a task
  • todo list — list all tasks
  • todo complete 1 — mark task #1 as done
  • todo delete 1 — delete task #1

Task Storage

First, create the task model and storage. We store tasks in a JSON file:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "time"

    "github.com/spf13/cobra"
)

type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

type TaskStore struct {
    Tasks  []Task `json:"tasks"`
    NextID int    `json:"next_id"`
}

func getStorePath() string {
    home, _ := os.UserHomeDir()
    return filepath.Join(home, ".todo-tasks.json")
}

func loadTasks() (*TaskStore, error) {
    path := getStorePath()

    data, err := os.ReadFile(path)
    if err != nil {
        if os.IsNotExist(err) {
            return &TaskStore{NextID: 1}, nil
        }
        return nil, err
    }

    var store TaskStore
    if err := json.Unmarshal(data, &store); err != nil {
        return nil, err
    }
    return &store, nil
}

func saveTasks(store *TaskStore) error {
    data, err := json.MarshalIndent(store, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(getStorePath(), data, 0644)
}

Add Command

func addCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "add [task title]",
        Short: "Add a new task",
        Args:  cobra.MinimumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            store, err := loadTasks()
            if err != nil {
                return fmt.Errorf("failed to load tasks: %w", err)
            }

            title := ""
            for i, arg := range args {
                if i > 0 {
                    title += " "
                }
                title += arg
            }

            task := Task{
                ID:        store.NextID,
                Title:     title,
                Done:      false,
                CreatedAt: time.Now(),
            }

            store.Tasks = append(store.Tasks, task)
            store.NextID++

            if err := saveTasks(store); err != nil {
                return fmt.Errorf("failed to save tasks: %w", err)
            }

            fmt.Printf("Added task #%d: %s\n", task.ID, task.Title)
            return nil
        },
    }
}

List Command

func listCmd() *cobra.Command {
    var showAll bool

    cmd := &cobra.Command{
        Use:   "list",
        Short: "List all tasks",
        RunE: func(cmd *cobra.Command, args []string) error {
            store, err := loadTasks()
            if err != nil {
                return fmt.Errorf("failed to load tasks: %w", err)
            }

            if len(store.Tasks) == 0 {
                fmt.Println("No tasks yet. Add one with: todo add \"Your task\"")
                return nil
            }

            pending := 0
            done := 0

            for _, task := range store.Tasks {
                if task.Done {
                    done++
                    if !showAll {
                        continue
                    }
                } else {
                    pending++
                }

                status := "[ ]"
                if task.Done {
                    status = "[x]"
                }

                fmt.Printf("%s #%d: %s\n", status, task.ID, task.Title)
            }

            fmt.Printf("\n%d pending, %d completed\n", pending, done)
            return nil
        },
    }

    cmd.Flags().BoolVarP(&showAll, "all", "a", false, "Show completed tasks too")
    return cmd
}

Complete Command

func completeCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "complete [task id]",
        Short: "Mark a task as completed",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("invalid task ID: %s", args[0])
            }

            store, err := loadTasks()
            if err != nil {
                return fmt.Errorf("failed to load tasks: %w", err)
            }

            for i, task := range store.Tasks {
                if task.ID == id {
                    if task.Done {
                        fmt.Printf("Task #%d is already completed\n", id)
                        return nil
                    }
                    store.Tasks[i].Done = true
                    if err := saveTasks(store); err != nil {
                        return fmt.Errorf("failed to save tasks: %w", err)
                    }
                    fmt.Printf("Completed task #%d: %s\n", id, task.Title)
                    return nil
                }
            }

            return fmt.Errorf("task #%d not found", id)
        },
    }
}

Delete Command

func deleteCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "delete [task id]",
        Short: "Delete a task",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("invalid task ID: %s", args[0])
            }

            store, err := loadTasks()
            if err != nil {
                return fmt.Errorf("failed to load tasks: %w", err)
            }

            for i, task := range store.Tasks {
                if task.ID == id {
                    store.Tasks = append(store.Tasks[:i], store.Tasks[i+1:]...)
                    if err := saveTasks(store); err != nil {
                        return fmt.Errorf("failed to save tasks: %w", err)
                    }
                    fmt.Printf("Deleted task #%d: %s\n", id, task.Title)
                    return nil
                }
            }

            return fmt.Errorf("task #%d not found", id)
        },
    }
}

Putting It All Together

func main() {
    rootCmd := &cobra.Command{
        Use:   "todo",
        Short: "A simple TODO CLI app",
        Long:  "Todo is a command-line task manager. Add, list, complete, and delete tasks from your terminal.",
    }

    rootCmd.AddCommand(addCmd())
    rootCmd.AddCommand(listCmd())
    rootCmd.AddCommand(completeCmd())
    rootCmd.AddCommand(deleteCmd())

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

Try it:

go run main.go add Buy groceries
# Added task #1: Buy groceries

go run main.go add Learn Go generics
# Added task #2: Learn Go generics

go run main.go add Write tests
# Added task #3: Write tests

go run main.go list
# [ ] #1: Buy groceries
# [ ] #2: Learn Go generics
# [ ] #3: Write tests
#
# 3 pending, 0 completed

go run main.go complete 1
# Completed task #1: Buy groceries

go run main.go list
# [ ] #2: Learn Go generics
# [ ] #3: Write tests
#
# 2 pending, 1 completed

go run main.go list --all
# [x] #1: Buy groceries
# [ ] #2: Learn Go generics
# [ ] #3: Write tests
#
# 2 pending, 1 completed

go run main.go delete 3
# Deleted task #3: Write tests

Flags in Detail

Cobra supports two kinds of flags:

Local Flags

Only available on the command they are defined on:

cmd.Flags().StringVarP(&output, "output", "o", "text", "Output format (text, json)")
cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Maximum items to show")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output")

The VarP suffix means it takes a short flag too (-o, -l, -v).

Persistent Flags

Available on the command and all its subcommands:

rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "Config file path")

This flag works on every subcommand: todo --config my.json add "task".

Required Flags

Mark a flag as required:

cmd.Flags().StringVarP(&priority, "priority", "p", "", "Task priority")
cmd.MarkFlagRequired("priority")

Cobra will show an error if the user forgets the flag.

Configuration with Viper

Viper is Cobra’s companion library. It reads configuration from files, environment variables, and flags — all at once:

package main

import (
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

func main() {
    rootCmd := &cobra.Command{
        Use: "myapp",
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
            // Read config file
            viper.SetConfigName("config")
            viper.SetConfigType("yaml")
            viper.AddConfigPath(".")
            viper.AddConfigPath("$HOME/.myapp")

            // Read environment variables with MYAPP_ prefix
            viper.SetEnvPrefix("MYAPP")
            viper.AutomaticEnv()

            // Config file is optional
            if err := viper.ReadInConfig(); err != nil {
                if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
                    return err
                }
            }

            return nil
        },
    }

    serverCmd := &cobra.Command{
        Use:   "server",
        Short: "Start the server",
        Run: func(cmd *cobra.Command, args []string) {
            port := viper.GetInt("port")
            host := viper.GetString("host")
            debug := viper.GetBool("debug")

            fmt.Printf("Starting server on %s:%d (debug=%v)\n", host, port, debug)
        },
    }

    serverCmd.Flags().Int("port", 8080, "Server port")
    serverCmd.Flags().String("host", "localhost", "Server host")
    serverCmd.Flags().Bool("debug", false, "Enable debug mode")

    // Bind flags to viper
    viper.BindPFlag("port", serverCmd.Flags().Lookup("port"))
    viper.BindPFlag("host", serverCmd.Flags().Lookup("host"))
    viper.BindPFlag("debug", serverCmd.Flags().Lookup("debug"))

    rootCmd.AddCommand(serverCmd)
    rootCmd.Execute()
}

Viper checks these sources in order (highest priority first):

  1. Command-line flags
  2. Environment variables (MYAPP_PORT, MYAPP_HOST)
  3. Config file (config.yaml)
  4. Default values

A config.yaml file:

port: 3000
host: "0.0.0.0"
debug: true

Building and Installing

Build a single binary:

go build -o todo .

Install it to your $GOPATH/bin:

go install .

Now you can run todo from anywhere:

todo add "Deploy the app"
todo list

Cross-Platform Builds

Build for different operating systems:

# Linux
GOOS=linux GOARCH=amd64 go build -o todo-linux .

# Windows
GOOS=windows GOARCH=amd64 go build -o todo.exe .

# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o todo-macos .

Go cross-compiles without any extra tools. One command gives you a binary for any platform.

Adding Version Information

Inject version info at build time:

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func versionCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "version",
        Short: "Print version information",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("todo v%s\n", version)
            fmt.Printf("  commit: %s\n", commit)
            fmt.Printf("  built:  %s\n", date)
        },
    }
}

Build with version info:

go build -ldflags "-X main.version=1.0.0 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%d)" -o todo .
./todo version
# todo v1.0.0
#   commit: a1b2c3d
#   built:  2026-05-07

Error Handling and Exit Codes

Use RunE instead of Run for commands that can fail. Cobra prints the error and exits with code 1:

cmd := &cobra.Command{
    Use: "process",
    RunE: func(cmd *cobra.Command, args []string) error {
        if someCondition {
            return fmt.Errorf("something went wrong: %w", err)
        }
        return nil
    },
}

For custom exit codes:

if err := rootCmd.Execute(); err != nil {
    // Cobra already printed the error
    os.Exit(1)
}

Common Mistakes

1. Using Run instead of RunE.

Run ignores errors. RunE propagates them. Always use RunE for commands that do real work.

2. Not validating arguments.

Cobra has built-in argument validation:

cobra.ExactArgs(1)      // Exactly 1 argument
cobra.MinimumNArgs(1)   // At least 1 argument
cobra.MaximumNArgs(3)   // At most 3 arguments
cobra.NoArgs            // No arguments allowed

3. Hardcoding file paths.

Use os.UserHomeDir() or os.UserConfigDir() for cross-platform paths. Never hardcode /home/user/ or C:\Users\.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-23 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #24: Docker for Go, you will learn:

  • Why Go and Docker are a perfect combination
  • Multi-stage Docker builds for tiny images
  • Docker Compose with Go and PostgreSQL
  • Building for multiple platforms

This is part 23 of the Go Tutorial series. Follow along to learn Go from scratch.