CHAPTER 34 · CAPSTONE

Capstone: Build a CLI Task Manager

You've covered an entire language. This chapter ties the pieces together by building a real, useful CLI tool: todo, a terminal task manager that stores tasks as JSON and supports add, list, done, and rm commands. It uses almost every concept from the course.

Objectives

  • Structure a Go project into packages.
  • Parse command-line arguments with flag.
  • Persist data to a JSON file (Chapters 27, 29).
  • Handle errors and use contexts (Chapters 23, 26).
  • Write table-driven tests (Chapter 31).
  • Build and ship a static binary (Chapter 4).

The spec

The binary supports four subcommands:

todo add "buy milk"        # add a task
todo list                  # show all tasks (pending + completed)
todo done 3                # mark task #3 as complete
todo rm 3                  # remove task #3

Tasks live in ~/.todo.json. Each task has: ID, Title, CreatedAt (time), CompletedAt (*time, nil if pending).

Project layout

todo/
├── go.mod                      // module example.com/todo
├── main.go                     // package main, entry point
├── cmd/
│   └── todo/
│       └── main.go             // (alternative: binary under cmd/)
└── internal/
    └── task/
        ├── task.go             // Task type + methods
        ├── store.go            // loading/saving JSON
        └── task_test.go

For this small project, keeping main.go at the root and the logic in internal/task is fine. For larger CLIs, many binaries per tool live under cmd/NAME/.

The task store

internal/task/task.go:

package task

import "time"

// A Task is a single TODO item.
type Task struct {
    ID          int        `json:"id"`
    Title       string     `json:"title"`
    CreatedAt   time.Time  `json:"createdAt"`
    CompletedAt *time.Time `json:"completedAt,omitempty"`
}

// Done reports whether the task has been completed.
func (t Task) Done() bool { return t.CompletedAt != nil }

internal/task/store.go:

package task

import (
    "encoding/json"
    "errors"
    "fmt"
    "os"
    "time"
)

var ErrNotFound = errors.New("task not found")

// Store is the on-disk JSON task list.
type Store struct {
    Path  string
    Tasks []Task
}

// Load reads tasks from the given path. If the file doesn't exist, the returned
// store is empty.
func Load(path string) (*Store, error) {
    s := &Store{Path: path}
    b, err := os.ReadFile(path)
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            return s, nil
        }
        return nil, fmt.Errorf("load %q: %w", path, err)
    }
    if err := json.Unmarshal(b, &s.Tasks); err != nil {
        return nil, fmt.Errorf("parse %q: %w", path, err)
    }
    return s, nil
}

// Save writes the tasks back to disk atomically.
func (s *Store) Save() error {
    b, err := json.MarshalIndent(s.Tasks, "", "  ")
    if err != nil { return fmt.Errorf("marshal: %w", err) }

    tmp := s.Path + ".tmp"
    if err := os.WriteFile(tmp, b, 0o644); err != nil {
        return fmt.Errorf("write %q: %w", tmp, err)
    }
    return os.Rename(tmp, s.Path)    // atomic replace
}

// Add appends a new task and returns it.
func (s *Store) Add(title string) Task {
    id := 1
    for _, t := range s.Tasks {
        if t.ID >= id { id = t.ID + 1 }
    }
    t := Task{ID: id, Title: title, CreatedAt: time.Now()}
    s.Tasks = append(s.Tasks, t)
    return t
}

// Complete marks a task as done.
func (s *Store) Complete(id int) error {
    for i := range s.Tasks {
        if s.Tasks[i].ID == id {
            now := time.Now()
            s.Tasks[i].CompletedAt = &now
            return nil
        }
    }
    return fmt.Errorf("complete id=%d: %w", id, ErrNotFound)
}

// Remove deletes a task.
func (s *Store) Remove(id int) error {
    for i, t := range s.Tasks {
        if t.ID == id {
            s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("remove id=%d: %w", id, ErrNotFound)
}

Commands

Each subcommand is a small function in main.go:

func cmdAdd(s *task.Store, args []string) error {
    if len(args) == 0 {
        return errors.New("usage: todo add TITLE")
    }
    title := strings.Join(args, " ")
    t := s.Add(title)
    fmt.Printf("added #%d: %s\n", t.ID, t.Title)
    return s.Save()
}

func cmdList(s *task.Store, _ []string) error {
    if len(s.Tasks) == 0 {
        fmt.Println("no tasks")
        return nil
    }
    for _, t := range s.Tasks {
        mark := "[ ]"
        if t.Done() { mark = "[x]" }
        fmt.Printf("%s #%d  %s\n", mark, t.ID, t.Title)
    }
    return nil
}

func cmdDone(s *task.Store, args []string) error {
    if len(args) != 1 { return errors.New("usage: todo done ID") }
    id, err := strconv.Atoi(args[0])
    if err != nil { return fmt.Errorf("bad id: %w", err) }
    if err := s.Complete(id); err != nil { return err }
    fmt.Printf("done #%d\n", id)
    return s.Save()
}

func cmdRm(s *task.Store, args []string) error {
    if len(args) != 1 { return errors.New("usage: todo rm ID") }
    id, err := strconv.Atoi(args[0])
    if err != nil { return fmt.Errorf("bad id: %w", err) }
    if err := s.Remove(id); err != nil { return err }
    fmt.Printf("removed #%d\n", id)
    return s.Save()
}

main and the flag parsing

package main

import (
    "errors"
    "flag"
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "strings"

    "example.com/todo/internal/task"
)

func main() {
    path := flag.String("file", defaultPath(), "path to tasks JSON file")
    flag.Usage = func() {
        fmt.Fprintln(os.Stderr, "usage: todo [--file PATH] COMMAND [ARGS...]")
        fmt.Fprintln(os.Stderr, "commands: add, list, done, rm")
    }
    flag.Parse()

    args := flag.Args()
    if len(args) == 0 { flag.Usage(); os.Exit(2) }

    store, err := task.Load(*path)
    if err != nil {
        fmt.Fprintln(os.Stderr, "error:", err); os.Exit(1)
    }

    cmd, rest := args[0], args[1:]
    var runErr error
    switch cmd {
    case "add":  runErr = cmdAdd(store, rest)
    case "list": runErr = cmdList(store, rest)
    case "done": runErr = cmdDone(store, rest)
    case "rm":   runErr = cmdRm(store, rest)
    default:
        fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd); flag.Usage(); os.Exit(2)
    }
    if runErr != nil {
        fmt.Fprintln(os.Stderr, "error:", runErr)
        os.Exit(1)
    }
}

func defaultPath() string {
    home, err := os.UserHomeDir()
    if err != nil { return ".todo.json" }
    return filepath.Join(home, ".todo.json")
}

Tests

internal/task/task_test.go:

package task

import (
    "path/filepath"
    "testing"
)

func TestStore(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "tasks.json")

    s, err := Load(path)
    if err != nil { t.Fatal(err) }
    if len(s.Tasks) != 0 { t.Fatalf("fresh store should be empty, got %d", len(s.Tasks)) }

    t1 := s.Add("write more tests")
    t2 := s.Add("ship")
    if err := s.Save(); err != nil { t.Fatal(err) }

    // Reload and verify persistence
    s2, err := Load(path)
    if err != nil { t.Fatal(err) }
    if len(s2.Tasks) != 2 { t.Fatalf("got %d tasks, want 2", len(s2.Tasks)) }

    if err := s2.Complete(t1.ID); err != nil { t.Fatal(err) }
    if !s2.Tasks[0].Done() { t.Error("t1 should be done") }

    if err := s2.Remove(t2.ID); err != nil { t.Fatal(err) }
    if len(s2.Tasks) != 1 { t.Errorf("after rm: len=%d, want 1", len(s2.Tasks)) }
}

Using t.TempDir() gets you a disposable directory that's auto-cleaned up. Run with go test ./... -race.

Ship it

cd todo
go mod init example.com/todo
go build -o todo .
./todo add "read the spec"
./todo add "sleep"
./todo list
./todo done 1
./todo list

Want a native binary for another OS?

GOOS=linux  GOARCH=amd64 go build -o todo-linux .
GOOS=darwin GOARCH=arm64 go build -o todo-mac .
GOOS=windows GOARCH=amd64 go build -o todo.exe .

Three binaries, three clients, zero runtime dependencies. That's the Go deployment pitch in one command.

Where to go next

This app is intentionally small. Ideas if you want to keep building:

  • Priorities, tags, due dates: more struct fields and filtering.
  • Colored output with fatih/color.
  • Sync to a web API using net/http (Chapter 30).
  • TUI with bubbletea.
  • Persistence in SQLite via modernc.org/sqlite.
  • Subcommand routing library like cobra for bigger CLIs.
  • Publish to homebrew with GoReleaser.

But most importantly, start a real project that scratches your own itch. That's where mastery comes from.

Finish

34 chapters. From "what is coding" to shipping a working CLI written entirely by you. You now know:

  • The language: types, control flow, functions, composite types, pointers, methods, interfaces, generics.
  • Concurrency: goroutines, channels, contexts, the pieces the rest of the world is still catching up to.
  • The standard library: I/O, time, JSON, HTTP, the four pillars of most services.
  • The craft: naming, testing, benchmarking, profiling, documentation, modules.

You can now read any Go codebase, contribute to open-source projects, and build your own production services. Go forth (pun intended).

Happy hacking.

~ Cyrus

Course complete.