1 The Problem
We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.
2 How to Think About It
Think about commands and arguments, before any code:
add, list, done. → 2. Each takes arguments (the task text, or which number). → 3. flag.NewFlagSet reads each subcommand’s own arguments and, for free, generates its own -h help. → 4. Tasks persist in a file between commands. This is how real CLI tools are structured.
3 The Build — explained part by part
Here is the complete task manager using the standard library’s flag package. Each part is explained below.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
)
// Task is one to-do item. JSON tags keep the saved file lowercase, matching
// the shape the Python version writes.
type Task struct {
Task string `json:"task"`
Done bool `json:"done"`
}
const file = "tasks.json"
func load() []Task {
data, err := os.ReadFile(file)
if err != nil {
return []Task{}
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return []Task{}
}
return tasks
}
func save(tasks []Task) {
data, _ := json.Marshal(tasks)
os.WriteFile(file, data, 0644)
}
// addTask appends a new, undone task.
func addTask(tasks []Task, text string) []Task {
return append(tasks, Task{Task: text, Done: false})
}
// markDone marks task `number` (counting from 1) as done, if it exists.
func markDone(tasks []Task, number int) []Task {
if number >= 1 && number <= len(tasks) {
tasks[number-1].Done = true
}
return tasks
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: tasks <add|list|done> [args]")
os.Exit(1)
}
// Each subcommand gets its own FlagSet — Go's version of argparse's
// add_subparsers(). It gives each subcommand its own flags and, for
// free, its own -h help text.
switch os.Args[1] {
case "add":
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
addCmd.Parse(os.Args[2:])
if addCmd.NArg() < 1 {
fmt.Println(`Usage: tasks add "task text"`)
os.Exit(1)
}
text := addCmd.Arg(0)
tasks := addTask(load(), text)
save(tasks)
fmt.Printf("Added: %s\n", text)
case "list":
listCmd := flag.NewFlagSet("list", flag.ExitOnError)
listCmd.Parse(os.Args[2:])
for i, t := range load() {
mark := " "
if t.Done {
mark = "x"
}
fmt.Printf("%d. [%s] %s\n", i+1, mark, t.Task)
}
case "done":
doneCmd := flag.NewFlagSet("done", flag.ExitOnError)
doneCmd.Parse(os.Args[2:])
if doneCmd.NArg() < 1 {
fmt.Println("Usage: tasks done N")
os.Exit(1)
}
number, err := strconv.Atoi(doneCmd.Arg(0))
if err != nil {
fmt.Println("Please give a whole number, e.g. tasks done 1")
os.Exit(1)
}
tasks := markDone(load(), number)
save(tasks)
fmt.Printf("Marked task %d done.\n", number)
default:
fmt.Printf("Unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
argparse.add_subparsers() does, but flag.NewFlagSet is the accepted idiom for it: give each subcommand (add, list, done) its own FlagSet, and each one gets its own flags, its own -h help text, and its own error handling. flag.ExitOnError means a bad flag prints a message and exits, instead of you having to check an error yourself.switch os.Args[1] { case "add": ... } —
os.Args[0] is always the program name, so os.Args[1] is the first real argument — the subcommand name. The switch routes to the right block, the same job Python’s set_defaults(func=...) does more indirectly.addCmd.Parse(os.Args[2:]) then addCmd.Arg(0) — hand the subcommand everything after its own name, and read the first plain (non-flag) argument — here, the task text.
type Task struct { Task string; Done bool } — a typed record instead of Python’s untyped dict. The JSON tags keep the saved file’s field names lowercase.
os.Args[1] without first checking len(os.Args) >= 2 — running the program with no arguments panics with “index out of range”.number - 1 when indexing into the tasks slice — people count tasks from 1, slices from 0.markDone does.fmt.Sscanf and ignoring a bad result — typing tasks done abc would silently mark task 0.strconv.Atoi and check its error, printing a clear message on failure.4 Test & Prove Each Part
We test the task operations — add and mark-done — directly, without invoking the command-line parsing at all.
package main
import "testing"
func TestAdd(t *testing.T) {
tasks := addTask([]Task{}, "Buy milk")
if tasks[0].Task != "Buy milk" {
t.Errorf("tasks[0].Task = %q; want %q", tasks[0].Task, "Buy milk")
}
if tasks[0].Done {
t.Errorf("tasks[0].Done = true; want false")
}
}
func TestMarkDone(t *testing.T) {
tasks := addTask([]Task{}, "Task")
tasks = markDone(tasks, 1)
if !tasks[0].Done {
t.Errorf("tasks[0].Done = false; want true")
}
}
func TestDoneCorrectTask(t *testing.T) {
tasks := addTask(addTask([]Task{}, "a"), "b")
tasks = markDone(tasks, 2)
if tasks[0].Done {
t.Errorf("tasks[0].Done = true; want false (task 1 untouched)")
}
if !tasks[1].Done {
t.Errorf("tasks[1].Done = false; want true")
}
}
Run with go test -v ./.... The task logic is separate from flag and os.Args, so we test it directly with plain Go values. The main function’s only job is to parse the command line and call these tested functions.
5 The Interface
What it expects
go run tasks.go add "Buy milk"What it returns
1. [ ] Buy milk
2. [x] Walk dog6 Run It & Automate It
Save the code as tasks.go and run it with go run tasks.go — Go compiles and executes in one step, no separate build needed while you are experimenting.
go run tasks.go add "Buy milk"Then
go run tasks.go list and go run tasks.go done 1. Try go run tasks.go add -h — flag generated it for free, just like argparse does in Python.A CI tool like Jenkins runs go test automatically whenever the code changes — every line below has a plain explanation.
$ go run tasks.go add "Buy milk"
Added: Buy milk
$ go run tasks.go list
1. [ ] Buy milk
$ go run tasks.go done 1
Marked task 1 done.
$ go run tasks.go list
1. [x] Buy milkgo run tasks.go add "text", list, or done N.done was not a number. Check for typos or stray quotes.markDone range check should have caught this — make sure it runs before the slice is touched.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Go') {
steps {
sh 'go version' // confirm Go is installed
sh 'test -f go.mod || go mod init cli_task_manager' // create a module if none exists
}
}
stage('Run the tests') {
steps {
sh 'go vet ./...' // catch obvious mistakes before running
sh 'go test -v ./...' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Delete command. Add a
removesubcommand with its ownFlagSet. (Teaches: another subcommand.) - Priorities. Add a
-priorityflag toaddwithaddCmd.String(...). (Teaches: optional flags.) - Filter list. Add a
-doneboolean flag tolistto show only completed tasks. (Teaches: flags that change behaviour.)
flag.NewFlagSet: subcommands, per-command arguments, automatic help, and routing to handlers — the same structure every professional Go command-line tool uses (including go itself, which is built this way). Related: Standard Library, JSON & Encoding.