01

A to-do list that lives in your terminal

You type a command. Your task is saved. Let's trace exactly what happens in between.

$ tasks add "Ship the new landing page"
Added task 1

$ tasks add "Write release notes"
Added task 2

$ tasks ls
ID Task Created
1 Ship the new landing page a few seconds ago
2 Write release notes a few seconds ago

$ tasks done 1
Marked task 1 as done

Four commands. That's the whole app.

tasks add
Create a new task with a description. The app assigns it a unique ID automatically.
📋
tasks list / ls
Show your open tasks. Add --all to see completed ones too.
tasks done
Mark a task complete by its ID. It stays in the file but gets filtered out of the default list.
🗑️
tasks delete
Permanently remove a task from the file. IDs are never reused.
💡
The terminal is just a different kind of UI

There's no button to click — you type a command instead. Under the hood, it works the same way a web app does: you send input, the app processes it, and you get output. The difference is text in, text out.

What's happening when you type that command?

Let's trace end-to-end what happens the moment you hit Enter on tasks add "Ship the landing page":

1
Your shell finds the tasks binaryThe operating system looks on your PATH for an executable called "tasks" — the compiled Go program.
2
main.go starts and hands off to CobraThe entry point calls cmd.Execute(), which kicks off the command-routing engine.
3
Cobra matches "add" and runs its handlerThe handler in cmd/add.go gets the description "Ship the landing page" as its argument.
4
The Store opens, locks the file, adds the taskinternal/store/store.go reads the CSV file, appends the new task, and writes it back.
5
"Added task 1" prints to your screenThe handler prints the new task ID to stdout and the Store closes (releasing the file lock).
🎯
Why this matters for directing AI

When you ask AI to "add a priority field to tasks," it needs to touch the Store (where tasks are defined and saved) AND the list command (which displays them). Knowing the flow helps you give better instructions.

02

Meet the cast

Every codebase is a story with characters. Here are the four main players in this one.

🎭
Think of it like a small crew on a film set

There's a director (main.go), a routing coordinator (Cobra), specialists who handle each scene (cmd/ files), and a prop master who manages all the physical items (the Store). Everyone knows their job and stays out of each other's way.

The file structure

📁 Terminal-Task-Tracker/
  📄 main.go Entry Point
  📄 go.mod Dependencies
  📁 cmd/ Command Handlers
    📄 root.go
    📄 add.go
    📄 list.go
    📄 done.go
    📄 delete.go
  📁 internal/store/ Data Layer
    📄 store.go
    📄 lock.go

Click each actor to learn their role

🚪
main.go
🔀
Cobra Router
add.go
📋
list.go
done.go
🗑️
delete.go
🗄️
store.go
🔒
lock.go
📊
~/.tasks.csv
main.go — The Front Door
Just 5 lines. Its only job is to call cmd.Execute() which hands everything off to Cobra. Think of it as the on/off switch for the whole app.
Cobra Router — The Traffic Controller
Cobra is an external library (like a plug-in) that reads what you typed — "tasks add", "tasks list", etc. — and routes it to the right handler. You never write this routing logic yourself; Cobra handles it.
add.go — The "Add" Specialist
When you type "tasks add ...", this file runs. It calls openStore(), then Store.Add(), and prints the new task ID. One file, one command, one responsibility.
list.go — The "List" Specialist
Handles "tasks list" and "tasks ls". It also manages the --all flag. Uses a tabwriter to make the output line up in neat columns.
done.go — The "Mark Complete" Specialist
Converts your text ID ("2") to a number, then calls Store.Complete(). Handles the error if that ID doesn't exist.
delete.go — The "Delete" Specialist
Same pattern as done.go — parse the ID, call Store.Delete(). If the task is not found, the user gets a clear error message.
store.go — The Filing Cabinet Manager
The heart of the data layer. Loads tasks from CSV into memory, exposes Add/Complete/Delete/List methods, and writes changes back on Close(). All business logic about tasks lives here.
lock.go — The Gatekeeper
Opens the CSV file and immediately places an exclusive lock on it (flock). No other process can write to the file until this one is done. Prevents data corruption if two terminals run tasks at the same time.
~/.tasks.csv — The Source of Truth
A plain text file on your computer. Four columns: ID, Description, CreatedAt, IsComplete. Every command reads from and/or writes to this single file.

The key architectural pattern: one job each

Notice how each file does exactly one thing. This is called the Single Responsibility Principle. When you ask AI to add a feature, you can be precise: "update only the Store" or "add a new command file in cmd/".

How they talk to each other

0 / 6
Quiz — Module 2

You want to add a "priority" field (high/medium/low) to every task. Which part of the codebase would you need to change first?

03

How a command actually works

Let's read real code together. Side-by-side, line by line — no CS degree needed.

📬
Think of Cobra commands like postal forms

You fill in the "Use" field (the command name), the "Short" field (the label on the envelope), and the "RunE" field (the instructions for what to do when this form arrives). Cobra is the postal service — it reads the form and executes the instructions.

The "add" command, decoded

CODE — cmd/add.go
var addCmd = &cobra.Command{
    Use:   "add <description>",
    Short: "Add a new task",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        s, err := openStore()
        if err != nil {
            return err
        }
        defer s.Close()
        t := s.Add(args[0])
        fmt.Fprintf(cmd.OutOrStdout(), "Added task %d\n", t.ID)
        return nil
    },
}
PLAIN ENGLISH

Create a new command object and store it in addCmd.

When someone types "tasks add ..." this is the command name pattern to match.

A one-line description shown in the help menu.

Require exactly one argument — the task description. Error out if they provide zero or two.

RunE is the function that actually runs. It receives the typed arguments as a list of strings.

Open the CSV file (and lock it). Store the result in "s".

If something went wrong opening the file, stop here and report the error.

Close parenthesis of the error check.

"defer" means: run s.Close() when this function exits, no matter what. Saves and unlocks the file.

Add the task (args[0] = the first argument you typed). Get back the new task object "t".

Print "Added task 1" (or whatever ID) to the terminal output.

Return nil = no error, everything worked.

End of the function and the command definition.

"defer" is a safety net

defer s.Close() means "close and save the file when this function finishes — even if there's an error." Without it, you'd have to remember to close the file in every possible exit path. Defer handles it automatically. You can tell AI: "use defer for any cleanup at the end of a function."

How root.go shares setup code

Every command needs to open the Store. Instead of repeating that code five times, root.go defines a shared helper called openStore() that all commands can call.

CODE — cmd/root.go (excerpt)
func resolveFile() (string, error) {
    if dataFile != "" {
        return dataFile, nil
    }
    if env := os.Getenv("TASKS_FILE"); env != "" {
        return env, nil
    }
    home, err := os.UserHomeDir()
    if err != nil {
        return "", fmt.Errorf("resolve home dir: %w", err)
    }
    return filepath.Join(home, ".tasks.csv"), nil
}
PLAIN ENGLISH

Function that figures out where the tasks file lives. Returns a file path or an error.

If the user passed --file /some/path.csv, use that path.

Return that path (second value "nil" = no error).

End of that check.

Otherwise, check the TASKS_FILE environment variable. If it's set, use that.

Return the env variable path.

End of that check.

Otherwise, find the user's home directory (e.g. /Users/yourname).

If we can't find it, return an error message.

End of error check.

Default: use ~/.tasks.csv (join home + ".tasks.csv").

End of function.

The command flow, visualized

⌨️
Terminal
🔀
Cobra
📂
root.go
🗄️
Store
Click "Next Step" to trace the flow
0 / 8
Quiz — Module 3

A user reports that after a crash, their ~/.tasks.csv file is locked and nobody can access it. Where in the code would you look to make sure the file always gets unlocked, even when errors happen?

04

The Store: your trusty filing cabinet

All task data lives here. This is the most important file in the codebase.

🗂️
Think of it like a physical filing cabinet with a lock

When you open the Store, you grab the cabinet key (flock) and pull all the folders into your arms (load into memory). You make changes in your arms. When you're done, you put everything back in the drawer exactly as arranged (save to CSV), then return the key (unlock). Nobody else can open the drawer while you have the key.

The Task — the blueprint for a single task

CODE — internal/store/store.go
type Task struct {
    ID          int
    Description string
    CreatedAt   time.Time
    IsComplete  bool
}
PLAIN ENGLISH

Define a blueprint (called a struct) named Task.

Every task has an ID — a whole number (1, 2, 3...).

A description — any text, like "Ship the landing page".

A timestamp for when it was created, in full date+time format.

A true/false flag: has this task been marked done?

End of the blueprint.

How tasks are saved: the CSV format

# ~/.tasks.csv
ID,Description,CreatedAt,IsComplete
1,Ship the new landing page,2024-07-27T16:45:19-05:00,true
2,Write release notes,2024-07-27T16:45:26-05:00,false
3,Review open-source PR,2024-07-27T16:45:31-05:00,false
📄 Plain text file ✅ Human readable 🔑 RFC3339 timestamps 🔒 flock protected

CSV is just a text file with commas. No database needed. You can open ~/.tasks.csv in any text editor or spreadsheet app and see all your tasks.

The file lock — why it exists

CODE — internal/store/lock.go
func loadFile(path string) (*os.File, error) {
    f, err := os.OpenFile(path,
        os.O_RDWR|os.O_CREATE, os.ModePerm)
    if err != nil {
        return nil, fmt.Errorf("open %s: %w", path, err)
    }
    if err := syscall.Flock(int(f.Fd()),
        syscall.LOCK_EX); err != nil {
        _ = f.Close()
        return nil, fmt.Errorf("lock %s: %w", path, err)
    }
    return f, nil
}
PLAIN ENGLISH

Function: open the tasks file. Takes a path string, returns a file handle or an error.

Open the file at that path. O_RDWR = read+write access. O_CREATE = create if it does not exist.

 

If we could not open it (wrong permissions?), return an error with the file path for context.

 

Now grab an exclusive lock on the file. LOCK_EX means: nobody else can read or write until we unlock. This is like putting a "DO NOT DISTURB" sign on the file.

 

If locking failed, close the file we just opened (clean up) and return an error.

 

 

All good — return the locked file handle to the caller.

End of function.

🔒
flock prevents data corruption

Imagine two terminal windows both running tasks add at the same moment. Without a lock, both would read the file, both add a task, and both write — one overwriting the other. flock makes the second process wait until the first is done. Same pattern you would use with any shared resource in AI-built apps.

How Add() works — the dirty flag pattern

CODE — internal/store/store.go
func (s *Store) Add(description string) Task {
    id := 1
    for _, t := range s.tasks {
        if t.ID >= id {
            id = t.ID + 1
        }
    }
    t := Task{
        ID:          id,
        Description: description,
        CreatedAt:   time.Now(),
        IsComplete:  false,
    }
    s.tasks = append(s.tasks, t)
    s.dirty = true
    return t
}
PLAIN ENGLISH

Add method on Store. Takes a description string, returns the new Task.

Start with ID = 1 as a candidate.

Loop through all existing tasks.

If any task has an ID equal to or higher than our candidate, bump our candidate up by 1.

End of loop — now "id" is guaranteed to be higher than all existing IDs.

Build a new Task object with all its fields filled in.

Use the ID we just calculated.

Use the description the user typed.

Set the creation time to right now.

New tasks start as not complete.

End of task creation.

Add this new task to our in-memory list.

Set dirty = true: this tells Close() that it needs to write to disk.

Return the new task so the caller can print its ID.

End of Add().

Quiz — Module 4

A user runs "tasks list" (read-only — no changes made). The app opens the file, reads all tasks, and closes. Does Close() write anything to disk?

05

The whole picture

You have seen every part. Now let's step back and see how they form a complete, reliable system.

The patterns worth knowing

🗂️
Layered Architecture
Commands (cmd/) never touch the CSV directly. They always go through the Store. This means you can change how data is stored (e.g., switch from CSV to SQLite) without rewriting any commands.
🏁
Dirty Flag Pattern
Track whether anything changed. Only write to disk if dirty=true. Saves unnecessary I/O on read-only commands like "list". A pattern you can reuse in any data-persistence feature.
🔒
Exclusive File Locking
Use flock to serialize concurrent access to a shared file. Critical any time multiple processes might write to the same resource. Ask AI: "add an exclusive flock to the file access."
Defer for Cleanup
Schedule cleanup (close/unlock) with defer immediately after opening. It runs no matter how the function exits — even on error. Never forget to clean up a resource again.
🎓
The vocabulary you now own

You can tell AI: "use a layered architecture with a dedicated data store," "add a dirty flag so we only write when necessary," "use defer for file cleanup," "add flock for safe concurrent access." These are precise, professional instructions — not hand-wavy guesses.

Extending this codebase — what to change where

A
Add a new field to tasks (e.g., priority, due date)1. Update the Task struct in store.go. 2. Update load() and save() to handle the new CSV column. 3. Update list.go to display it.
B
Add a new command (e.g., "tasks edit")1. Create cmd/edit.go — copy the pattern from done.go. 2. Register it with rootCmd.AddCommand(editCmd) in init(). 3. Add Store.Update() in store.go.
C
Switch from CSV to a databaseOnly store.go needs to change. The cmd/ files call the same Store interface. This is the payoff of the layered architecture.
D
Change the default file locationOnly root.go resolveFile() needs to change. One function, one responsibility.

Final challenge

Scenario — Module 5

You want to add tagging: "tasks add --tag work 'Fix the bug'". Tags are stored with the task and shown in the list. Which files need to change?

A user says "tasks list" feels slow when the CSV has 10,000 tasks. Where is the bottleneck — and what would you suggest?

🚀
You built something worth understanding

This codebase has real engineering decisions in it: file locking, layered architecture, the dirty flag pattern, deferred cleanup. These aren't beginner tricks — they're patterns used in production software. You know what they are, why they exist, and when to ask for them.

Course complete 🎉

You now know this codebase end to end. Go build something great.