How to Build a CLI Task Manager in Go

Cli
Build a basic Go CLI task manager using the flag package to handle add and list commands.

The sticky note problem

You're staring at a sticky note on your monitor. It has three items, one is crossed out, and you can't read the third one. You want a tool that lives in your terminal. You type task add "Buy milk" and it remembers. You type task list and it shows you what's pending. No GUI, no heavy install. Just a binary that does one thing well.

Go makes this easy because the standard library handles command-line parsing without pulling in external dependencies. You get a complete CLI with a few lines of code, zero imports beyond the core library, and a binary that runs anywhere.

How flag works

Command-line tools in Go often use the flag package. Think of flag as a strict librarian. You hand it a list of words, and it checks them against a catalog of allowed flags. If you ask for something that doesn't exist, the librarian stops you immediately.

For tools with multiple actions, you create separate flag sets. Each flag set is a self-contained parser for one subcommand. This keeps the add logic separate from the list logic. The flag package doesn't parse os.Args automatically. You create a FlagSet, call Parse on it, and it consumes the arguments you pass. Anything left over becomes a positional argument.

The flag package is strict. It protects you from typos in arguments.

Minimal example

Here's the skeleton: a single flag set that parses one argument and prints it back.

package main

import (
	"flag"
	"fmt"
	"os"
)

func main() {
	// Create a flag set for the "add" command.
	// ExitOnError makes the parser call os.Exit if parsing fails.
	addCmd := flag.NewFlagSet("add", flag.ExitOnError)

	// Parse arguments starting from index 2 (skipping "task" and "add").
	// os.Args[0] is the binary name, os.Args[1] is the subcommand.
	addCmd.Parse(os.Args[2:])

	// NArg returns the number of positional arguments after flags.
	// We expect exactly one: the task title.
	if addCmd.NArg() != 1 {
		fmt.Println("Usage: task add <title>")
		os.Exit(1)
	}

	// Arg(0) retrieves the first positional argument.
	title := addCmd.Arg(0)
	fmt.Printf("Added task: %s\n", title)
}

Parse early, fail fast. Check arguments before doing work.

Runtime walkthrough

When you run go run main.go add "Buy milk", the runtime populates os.Args with ["main", "add", "Buy milk"]. The program creates a FlagSet named "add". Calling Parse with os.Args[2:] feeds ["Buy milk"] to the parser. Since there are no defined flags, the parser treats "Buy milk" as a positional argument. NArg returns 1. Arg(0) returns "Buy milk".

If you run go run main.go add, NArg returns 0, and the usage message prints. The flag.ExitOnError setting ensures that if you pass an unknown flag like -foo, the program prints an error and exits with code 2 automatically. Without ExitOnError, Parse returns an error value that you must handle.

The flag package also supports typed flags. You can define a boolean flag with addCmd.Bool("done", false, "mark as done"). The parser converts the string -done to a boolean and stores it in the variable. This keeps type safety at the boundary.

Check bounds before indexing. os.Args is a slice, not magic.

A complete task manager

Here's a complete task manager with add and list commands, storing tasks in memory. The struct defines the data shape.

package main

import (
	"flag"
	"fmt"
	"os"
)

// Task represents a single item with a title and completion status.
type Task struct {
	Title string
	Done  bool
}

// tasks holds the list of tasks in memory.
// In a real app, this would persist to a file or database.
var tasks []Task

The main function dispatches based on the first argument. Each subcommand gets its own flag set.

func main() {
	// Create flag sets for each subcommand.
	// Each set parses its own arguments independently.
	addCmd := flag.NewFlagSet("add", flag.ExitOnError)
	listCmd := flag.NewFlagSet("list", flag.ExitOnError)

	// Check if a subcommand was provided.
	// os.Args[0] is the program name, so we need at least index 1.
	if len(os.Args) < 2 {
		fmt.Println("Usage: task <add|list> [args]")
		os.Exit(1)
	}

	// Dispatch based on the first argument.
	switch os.Args[1] {
	case "add":
		addCmd.Parse(os.Args[2:])
		if addCmd.NArg() < 1 {
			fmt.Println("Usage: task add <title>")
			os.Exit(1)
		}
		tasks = append(tasks, Task{Title: addCmd.Arg(0), Done: false})
		fmt.Println("Task added")

The list command iterates over the slice and prints status. The default case catches unknown commands.

	case "list":
		listCmd.Parse(os.Args[2:])
		for i, t := range tasks {
			status := "[ ]"
			if t.Done {
				status = "[x]"
			}
			fmt.Printf("%d. %s %s\n", i+1, status, t.Title)
		}

	default:
		fmt.Println("Unknown command")
		os.Exit(1)
	}
}

In-memory storage vanishes when the process ends. Persistence requires a file or database.

Methods and conventions

Go lets you attach behavior to types using methods. Here's how you add a method to toggle task completion.

// ToggleDone flips the completion status of a task.
// The receiver is a pointer so the change persists.
func (t *Task) ToggleDone() {
	t.Done = !t.Done
}

The receiver name is usually one or two letters matching the type. (t *Task) is standard. (this *Task) or (self *Task) are not. The community expects short receiver names.

Go code follows strict formatting rules. Run gofmt on your files. Most editors run it on save. Don't argue about indentation; let the tool decide. Trust gofmt. Argue logic, not formatting.

Error handling in Go is explicit. The flag package can return errors. Using flag.ExitOnError is convenient for scripts, but production code should handle errors explicitly. If you switch to flag.ContinueOnError, Parse returns an error. You'd write if err := addCmd.Parse(os.Args[2:]); err != nil { log.Fatal(err) }. This pattern makes the failure path visible. Go code prefers explicit error handling over silent exits.

Pitfalls and errors

If you access os.Args[1] without checking length, the program panics with index out of range. Always check len(os.Args) before indexing. If you forget to parse arguments, NArg returns 0 even if arguments exist. The flag package doesn't parse automatically; you must call Parse.

If you define a flag but don't use it, the compiler doesn't complain, but the flag won't appear in help output unless you register it properly. The flag package only processes flags that are defined on the FlagSet. Unknown flags trigger an error if ExitOnError is set.

If you pass a flag with the wrong type, the compiler rejects this with an invalid operation error if you try to assign it incorrectly, or the parser returns an error at runtime. For example, passing a string to a boolean flag causes the parser to fail with flag: help requested or a type mismatch error.

The worst goroutine bug is the one that never logs. CLI tools usually run synchronously, so goroutine leaks are rare. If you add background workers, ensure they have a cancellation path.

When to use flag

Use the flag package when you need a simple CLI with a few flags and subcommands. Use a third-party library like cobra or urfave/cli when your tool grows complex with nested commands, auto-generated help, and shell completions. Use os.Args directly when you want zero dependencies and full control over parsing logic. Use a configuration file when users need to set persistent options without typing flags every time.

Start simple. Add complexity only when the standard library stops helping.

Where to go next