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.