Day 026 — Project: CLI Commands & Flags¶
Month 1 · Week 4 · ⬅ Day 025 · Day 027 ➡ · Journal index
🎯 Learning Objective¶
Give taskcli a usable command-line interface — subcommands (add, list, done, rm) built on the standard flag package, with clean exit codes and os.Args dispatch.
📚 Topics¶
flagpackage:flag.String/Int/Bool,flag.Parse,flag.Args- Subcommands via
flag.NewFlagSet(each command its own flag set) os.Argsdispatch · usage text ·flag.ExitOnErrorvsflag.ContinueOnError- Exit codes:
os.Exit(code)and themain → run() errorpattern
📖 Reading / Sources¶
-
pkg.go.dev/flag—FlagSet,Parse,Args - Command Line Interfaces in Go (gobyexample)
-
pkg.go.dev/os—Args,Exit,Stderr
📝 Notes¶
- The global
flagis fine for one flat set of flags, but subcommands each need their ownflag.NewFlagSet(name, flag.ExitOnError)sotaskcli add -title xandtaskcli done -id 3parse independent flags. - Dispatch on
os.Args[1](the subcommand), thenParse(os.Args[2:])on that command's flag set.os.Args[0]is the program name. flag.Parsestops at the first non-flag argument; flags must precede positional args (Go's flag pkg, unlike GNU getopt, doesn't reorder).fs.Args()returns the leftover positionals.- Keep
maintiny: do real work inrun() error, thenif err := run(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }. This keeps logic testable and gives a single exit point —os.Exitskips deferred functions, so never call it deep in the call tree. Connects to the [[errors]] toolkit. - Errors go to stderr, normal output to stdout; exit non-zero on failure so scripts/CI can detect it.
flag.ExitOnErrormakesParsecallos.Exit(2)on a bad flag (fine for a CLI). Useflag.ContinueOnError(returns the error) when you want to handle it yourself — e.g. in tests.- Provide a
usage()that lists subcommands; setfs.Usageper command for-h. - Validate inputs early (
-titlenon-empty,-id> 0) and return wrapped errors; let the top-level handler print + set the exit code.
💻 Code Examples¶
func run(args []string) error {
if len(args) < 1 {
return errors.New("usage: taskcli <add|list|done|rm> [flags]")
}
store, err := Load(storePath())
if err != nil {
return err
}
switch args[0] {
case "add":
fs := flag.NewFlagSet("add", flag.ContinueOnError)
title := fs.String("title", "", "task title (required)")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *title == "" {
return errors.New("add: -title is required")
}
t := store.Add(*title)
fmt.Printf("added #%d: %s\n", t.ID, t.Title)
return store.Save()
case "done":
fs := flag.NewFlagSet("done", flag.ContinueOnError)
id := fs.Int("id", 0, "task id to mark done")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if err := store.MarkDone(*id); err != nil {
return err // e.g. wraps ErrNotFound
}
return store.Save()
case "list":
for _, t := range store.All() {
mark := " "
if t.Done {
mark = "x"
}
fmt.Printf("[%s] #%d %s\n", mark, t.ID, t.Title)
}
return nil
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
The
flag-package mechanics (subcommands,FlagSet, positional args) are demonstrated runnable + stdlib-only inexamples/month-01/cli-flags· Run:go run ./examples/month-01/cli-flags add -title demo
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Parse subcommands with separate FlagSets |
✅ | examples/month-01/cli-flags |
run([]string) error pattern (testable main) |
✅ | (project main.go) |
🐛 Mistakes Made¶
- Put a positional before a flag (
taskcli add buy-milk -title ...) and-titlewas ignored — Go'sflagstops at the first non-flag. Reordered so flags come first. - Called
os.Exit(1)insiderun()and mydefer store.Close()never ran. Moved the singleos.Exittomainand returned errors instead. - Used
flag.ExitOnErrorin a unit test — a bad flag killed the test process. Switched the testable path toflag.ContinueOnError.
❓ Open Questions¶
- Worth adopting
cobra/urfave/clifor richer UX? (Not for Month 1 — stdlibflagkeeps zero deps and teaches the fundamentals.)
🧠 Active Recall (answer without looking)¶
-
Q: Why prefer a
run([]string) errorfunction over doing everything inmain?
A
`main` can't return an error or be unit-tested easily, and `os.Exit` skips deferred cleanup. Putting logic in `run` makes it testable and gives a single, top-level exit point. -
Q: What happens if you put a positional argument before a flag with the stdlib
flagpackage?
A
`flag.Parse` stops at the first non-flag token, so any flags after it are treated as positionals and not parsed. The flag pkg doesn't reorder args like GNU getopt — flags must come first.
🪶 Feynman Reflection¶
The CLI is just a dispatcher: read the first arg to pick a subcommand, hand the rest to that command's own flag set, do the work against the store, and save. The discipline is keeping main a one-liner that calls run() error so the real logic is testable and cleanup actually happens.
🕳️ Knowledge Gaps¶
- Shell completion and richer help formatting — nice-to-have, deferred.
✅ Summary¶
taskcli now parses subcommands with per-command FlagSets, validates input, writes errors to stderr with a non-zero exit, and keeps main thin via the run() error pattern.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 027: write table-driven tests for the store/commands and a project README.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(taskcli): subcommands and flags via stdlib flag package (day 026)