← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Go Project

CLI Task Manager

A proper command-line task manager with commands and arguments, like real CLI tools. Learn to build a polished terminal program.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

Think about commands and arguments, before any code:

The plan — in plain English
1. The tool has subcommands: 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.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

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.

Gotasks.go
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)
	}
}
⚠ No in-browser playground here
Go compiles to a real binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once Go is installed.
What each part does — in plain words
flag.NewFlagSet("add", flag.ExitOnError) — Go’s standard library has no built-in subcommand support the way Python’s 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.
Common mistakes — and how to avoid them
✗ Reading os.Args[1] without first checking len(os.Args) >= 2 — running the program with no arguments panics with “index out of range”.
✓ Always check the argument count first and print a usage message, as the code above does.
✗ Forgetting number - 1 when indexing into the tasks slice — people count tasks from 1, slices from 0.
✓ Subtract 1, and check the number is in range before touching the slice, as markDone does.
✗ Parsing the task number with fmt.Sscanf and ignoring a bad result — typing tasks done abc would silently mark task 0.
✓ Use 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.

Adding a task appends it, not done
Marking done sets the flag
Marking one task done leaves the others alone
Gotasks_test.go
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

INPUTaddtasks add "text"
What it expects
go run tasks.go add "Buy milk"
OUTPUTlisttasks list
What it returns
1. [ ] Buy milk
2. [x] Walk dog

6 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.

Run it locally
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.

What you should see when it works
Terminala real run
$ 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 milk
If it breaks — how to fix it
🚨 Unknown command: (blank)
You ran the program with no subcommand. Run go run tasks.go add "text", list, or done N.
🚨 Please give a whole number, e.g. tasks done 1
The argument after done was not a number. Check for typos or stray quotes.
🚨 panic: runtime error: index out of range [0] with length 0
You tried to mark a task done on an empty list. The markDone range check should have caught this — make sure it runs before the slice is touched.
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. Delete command. Add a remove subcommand with its own FlagSet. (Teaches: another subcommand.)
  2. Priorities. Add a -priority flag to add with addCmd.String(...). (Teaches: optional flags.)
  3. Filter list. Add a -done boolean flag to list to show only completed tasks. (Teaches: flags that change behaviour.)
What you learned
You learned to build a real CLI tool with 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.