Skip to content

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

  • flag package: flag.String/Int/Bool, flag.Parse, flag.Args
  • Subcommands via flag.NewFlagSet (each command its own flag set)
  • os.Args dispatch · usage text · flag.ExitOnError vs flag.ContinueOnError
  • Exit codes: os.Exit(code) and the main → run() error pattern

📖 Reading / Sources

📝 Notes

  • The global flag is fine for one flat set of flags, but subcommands each need their own flag.NewFlagSet(name, flag.ExitOnError) so taskcli add -title x and taskcli done -id 3 parse independent flags.
  • Dispatch on os.Args[1] (the subcommand), then Parse(os.Args[2:]) on that command's flag set. os.Args[0] is the program name.
  • flag.Parse stops 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 main tiny: do real work in run() error, then if err := run(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }. This keeps logic testable and gives a single exit point — os.Exit skips 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.ExitOnError makes Parse call os.Exit(2) on a bad flag (fine for a CLI). Use flag.ContinueOnError (returns the error) when you want to handle it yourself — e.g. in tests.
  • Provide a usage() that lists subcommands; set fs.Usage per command for -h.
  • Validate inputs early (-title non-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 in examples/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 -title was ignored — Go's flag stops at the first non-flag. Reordered so flags come first.
  • Called os.Exit(1) inside run() and my defer store.Close() never ran. Moved the single os.Exit to main and returned errors instead.
  • Used flag.ExitOnError in a unit test — a bad flag killed the test process. Switched the testable path to flag.ContinueOnError.

❓ Open Questions

  • Worth adopting cobra/urfave/cli for richer UX? (Not for Month 1 — stdlib flag keeps zero deps and teaches the fundamentals.)

🧠 Active Recall (answer without looking)

  1. Q: Why prefer a run([]string) error function over doing everything in main?

    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.

  2. Q: What happens if you put a positional argument before a flag with the stdlib flag package?

    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)