# Build a CLI To-Do App (Python)

> Build a command-line to-do app in Python - add, list, complete, and save tasks to a file - runnable in your browser, then ready to run for real.


---

# Build a CLI To-Do App (Python)

We're going to build a to-do app this weekend. Not a toy that prints "Hello" and quits - a real one. You'll add tasks, list them, mark them done, delete them, and save the whole thing to a file so it survives between runs. By the end you'll type `python todo.py add "buy milk"` in a terminal and watch it work.

Here's the part that makes this fun: **every code block in this project runs right here in your browser.** You don't need Python installed to follow along. Click run, watch the output, change a line, run it again. When you're ready for the real thing, the last phase shows you exactly how to drop the same code onto your own machine.

## What you'll build

A single Python file - `todo.py` - that you drive from the command line:

```
python todo.py add "write the report"
python todo.py list
python todo.py done 1
```

Tasks live in a plain JSON file next to the script. Close your terminal, come back tomorrow, your list is still there.

## The stack

Nothing to install. We use three things, all built into Python:

| Piece | What it does |
|-------|--------------|
| `list` + `dict` | hold tasks in memory while the program runs |
| `json` module | save and load tasks as a text file |
| `sys.argv` | read the command word you typed (`add`, `list`, `done`) |

No frameworks. No pip. No third-party packages. The standard library does all of it, and learning to reach for it first is a habit worth building early.

## What you'll learn

- How to model data as a list of dictionaries - the bread and butter of Python programs.
- How to read and write files without losing your data.
- How JSON turns Python objects into text and back again.
- How a command-line tool decides what to do based on the words you type.

Each of these is a skill you'll reuse in nearly everything you write next.

## How the project flows

```mermaid
graph LR
  A[Phase 1<br/>tasks in memory] --> B[Phase 2<br/>save to file]
  B --> C[Phase 3<br/>complete & delete]
  C --> D[Phase 4<br/>a real CLI]
```

Four phases, each one a working step:

1. **Tasks in Memory** - represent tasks and add them to a growing list.
2. **Saving to a File** - write tasks to JSON and read them back.
3. **Complete, Delete, and Filter** - mark done, remove, and split open from finished.
4. **A Real CLI** - dispatch on a command word and run it as a script on your machine.

## Rough time

Plan for two or three relaxed hours. Each phase is ~30–45 minutes if you run the code and tinker. There's no rush - the point is to understand each piece before you stack the next one on top.

## Who this is for

You've seen a little Python - you know what a variable and a function are - and you want to build something whole instead of more disconnected exercises. That's exactly the right place to start. Let's build it.


---

# Tasks in Memory

Before a to-do app can save anything or take commands, it needs one thing: a way to hold a task in the program's memory. Get the shape of the data right and everything after this gets shorter. So that's where we start - no files, no commands yet, only tasks living in a list while the program runs.

## What is a task, really?

A task isn't one value. It has a few parts: the text of the thing to do, whether it's finished, and an id so we can point at it later ("mark task 2 done"). When you have a bundle of named values like that, a Python **dictionary** is the natural fit.

Here's one task as a dict:

```python runnable
task = {"id": 1, "text": "buy milk", "done": False}
print(task)
print("The text is:", task["text"])
print("Done yet?", task["done"])
```

Run that. You get the whole dict, then two values pulled out by name. The keys - `id`, `text`, `done` - are how we reach inside. That's the entire data model for one task. No class, no library. A dict is plenty.

## Many tasks: a list of dicts

One task is a dict. A to-do **list** is, fittingly, a Python list of those dicts:

Before you run this one, guess how many lines it prints. Then check.

```python runnable
tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": False},
    {"id": 3, "text": "water the plants", "done": True},
]

print("You have", len(tasks), "tasks.")
for task in tasks:
    print(task["id"], "-", task["text"])
```

A list of dictionaries is one of the most common shapes in all of Python. Rows from a database, items in a shopping cart, results from an API - they almost always arrive looking like this. Learn it here and you'll recognize it everywhere.

## Adding a task

Right now we typed the tasks by hand. The app needs to *add* them on demand. We'll write a function that takes the current list and the new text, and appends a fresh dict to the end.

The one wrinkle is the id. Each task needs a number nobody else has, and the first task added to an empty list should get id 1.

**Your turn.** This function is the point of the phase, so have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
def add_task(tasks, text):
    # Append one new task dict to `tasks`. It needs three keys:
    #   "id"   - a number no other task in the list has
    #   "text" - the text passed in
    #   "done" - False, because a new task isn't finished
    # Adding to an empty list should produce id 1.
    pass


# --- checks: fix your function until this prints "All good." ---
tasks = []
add_task(tasks, "buy milk")
assert tasks == [{"id": 1, "text": "buy milk", "done": False}], f"after one add, got: {tasks}"

add_task(tasks, "call the bank")
assert tasks[1]["id"] == 2, f"the second task should get id 2, got: {tasks[1]}"

ids = [task["id"] for task in tasks]
assert len(ids) == len(set(ids)), f"ids must be unique, got: {ids}"
print("All good.")
```

Stuck on the id? Ask what the ids already in the list can tell you.

### One way to write it

```python runnable
def add_task(tasks, text):
    if tasks:
        new_id = max(task["id"] for task in tasks) + 1
    else:
        new_id = 1
    tasks.append({"id": new_id, "text": text, "done": False})

tasks = []
add_task(tasks, "buy milk")
add_task(tasks, "call the bank")
add_task(tasks, "water the plants")

for task in tasks:
    print(task["id"], "-", task["text"])
```

Walk through it. We start with an empty list. The first `add_task` sees no tasks, so `new_id` is 1. The next sees a max id of 1, so it picks 2. Then 3. The list grew from nothing to three tasks, each with its own id, and we never had to track a counter ourselves.

If you reached for `len(tasks) + 1`, that is a reasonable first instinct and it passes every check above. It breaks in Phase 3, when we add deleting. Delete task 2 from a list of three and `len` is 2, so `len + 1` hands the next task an id of 3 - which already exists. Reading the actual max id keeps every id unique no matter what you've removed. It's a small choice now that saves a real bug later.

## Listing what you have

Adding is half of it. The other half is showing the list back in a way a human wants to read. Let's make a `list_tasks` function that prints each task with its id and a marker for whether it's done.

Two tasks go in and one gets marked done. Before you run it: which line comes back with the `x`?

```python runnable
def add_task(tasks, text):
    new_id = max((task["id"] for task in tasks), default=0) + 1
    tasks.append({"id": new_id, "text": text, "done": False})

def list_tasks(tasks):
    if not tasks:
        print("No tasks yet. Add one!")
        return
    for task in tasks:
        mark = "x" if task["done"] else " "
        print(f"[{mark}] {task['id']}. {task['text']}")

tasks = []
add_task(tasks, "buy milk")
add_task(tasks, "call the bank")
tasks[0]["done"] = True   # pretend we finished the first one

list_tasks(tasks)
```

A couple of things to notice. We tightened `add_task` using `max(..., default=0)` - the `default` kicks in when the list is empty, so the `if/else` disappears and the function is one line of logic. Same behavior, less code.

In `list_tasks`, the `mark` line is a small conditional: `"x"` if the task is done, a space if not. The `f"..."` string drops the values straight into the text. The result reads like an actual checklist:

```
[x] 1. buy milk
[ ] 2. call the bank
```

That bracket-and-number format is something you can scan in a real terminal. We set `tasks[0]["done"] = True` by hand here only to show the marker working - in Phase 3 we'll write a proper function for it.

## Where we are

You now have the spine of the app: a task is a dict, the list is a list of dicts, `add_task` grows it, and `list_tasks` shows it. Everything from here builds on this shape.

The catch - and you may have felt it - is that the moment the program ends, the list vanishes. Run it again and you're back to empty. That's the next problem to solve: making your tasks stick around. On to saving them to a file.


---

# Saving to a File

Last phase ended on a sour note: the moment the program stops, your tasks are gone. Memory is temporary by design. To make a to-do app worth using, the list has to outlive a single run - you add a task today and it's still there tomorrow. That means writing it to a file and reading it back. Let's do exactly that.

## Why JSON

Our task list is a list of dicts. We need to turn that into text we can save, then turn the text back into a list of dicts later. Python has a module built for precisely this: `json`.

JSON is a text format that looks almost identical to Python lists and dicts. That's not a coincidence - it was designed to carry data like ours. The `json` module gives us two pairs of functions:

| Function | Direction | What it does |
|----------|-----------|--------------|
| `json.dumps` | object → text | turns a list/dict into a JSON string |
| `json.loads` | text → object | turns a JSON string back into a list/dict |
| `json.dump` | object → file | writes a list/dict straight to an open file |
| `json.load` | text → file | reads a list/dict straight from an open file |

The ones without the `s` work with files; the ones with the `s` work with strings (`s` for *string*). We'll use both.

## Seeing the conversion

Before touching files, let's watch the round trip in pure memory. Take a list of tasks, turn it into a JSON string, then turn that string back into Python.

Before you run this, guess whether `restored[0]["done"]` prints `True` or `False`. Then check.

```python runnable
import json

tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": True},
]

text = json.dumps(tasks, indent=2)
print("As JSON text:")
print(text)

restored = json.loads(text)
print("\nBack to Python:")
print(restored[0]["text"], "- done?", restored[0]["done"])
```

Run it. The middle is a clean JSON string - `indent=2` makes it readable with line breaks and spacing instead of one long line. Then `json.loads` reads that string and hands you back a real Python list you can index into. Out and back, no data lost. `False` came back as `False`, the text came back as text. That round trip is the whole idea behind saving.

## Writing to a file and reading it back

Now the real thing. In your browser, we'll use a temporary file so the code runs in isolation - and the file path stays the same when you move to your own machine.

```python runnable
import json
import tempfile, os

# A file path. On your machine this would simply be "tasks.json".
path = os.path.join(tempfile.gettempdir(), "tasks.json")

tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": True},
]

# Save: open the file for writing, dump the list into it.
with open(path, "w") as f:
    json.dump(tasks, f, indent=2)
print("Saved", len(tasks), "tasks to", path)

# Load: open the file for reading, load the list back out.
with open(path, "r") as f:
    loaded = json.load(f)

print("Loaded back:")
for task in loaded:
    print(task["id"], "-", task["text"])
```

The `with open(...)` block opens the file and closes it automatically when the block ends, even if something goes wrong inside - that's why we use `with` rather than opening and closing by hand. The `"w"` means write (and replace whatever was there); `"r"` means read.

This block proves the loop that matters: data went to disk, the program could have ended right there, and we still read every task back. That's persistence.

## The first-run problem

There's a trap waiting. The very first time someone runs the app, `tasks.json` doesn't exist yet. Try to open a missing file for reading and Python raises `FileNotFoundError` and crashes. Not the welcome we want.

The fix is to expect it: if the file isn't there, start with an empty list instead of crashing.

**Your turn.** Write `load_tasks` and `save_tasks`. `save_tasks` writes `tasks` to `path` as JSON. `load_tasks` reads the list back from `path` - but if `path` doesn't exist yet, it returns `[]` instead of crashing. Fill them in and run the checks underneath. My version is in the next block whenever you want it.

```python runnable
import json
import tempfile, os

path = os.path.join(tempfile.gettempdir(), "todo_stub.json")
if os.path.exists(path):
    os.remove(path)

def load_tasks(path):
    # Read the JSON list of tasks from `path` and return it.
    # If `path` doesn't exist yet, return [] instead of crashing.
    pass

def save_tasks(path, tasks):
    # Write `tasks` to `path` as JSON.
    pass


# --- checks: fix your functions until this prints "All good." ---
first = load_tasks(path)
assert first == [], f"loading a missing file should give [], got: {first}"

save_tasks(path, [{"id": 1, "text": "buy milk", "done": False}])
second = load_tasks(path)
assert second == [{"id": 1, "text": "buy milk", "done": False}], f"got: {second}"
print("All good.")
```

Stuck on the missing-file case? Python raises a specific exception when `open` can't find the file. Catch it.

### One way to write it

```python runnable
import json
import tempfile, os

path = os.path.join(tempfile.gettempdir(), "todo_demo.json")

def load_tasks(path):
    try:
        with open(path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        return []

def save_tasks(path, tasks):
    with open(path, "w") as f:
        json.dump(tasks, f, indent=2)

# Make sure we're starting fresh for this demo.
if os.path.exists(path):
    os.remove(path)

# First run: no file yet, so we get an empty list - no crash.
tasks = load_tasks(path)
print("First run, tasks:", tasks)

# Add one and save.
tasks.append({"id": 1, "text": "buy milk", "done": False})
save_tasks(path, tasks)

# Second run: load again, the task is there.
tasks_again = load_tasks(path)
print("Second run, tasks:", tasks_again)
```

Look at the two prints. The first is `[]` - an empty list, because the file didn't exist and `load_tasks` caught the `FileNotFoundError` and returned `[]` instead of crashing. Then we add a task, save, and "run again" by calling `load_tasks` a second time. This time the task comes back. That's the full life cycle of saved data, handled cleanly.

The `try/except` here is the kind of error handling worth keeping. It's not guarding against something impossible - a missing file on first run is *guaranteed* to happen. Catching it turns a crash into a sensible default.

## Where we are

You've got `load_tasks` and `save_tasks`, and together they make the list permanent. Add a task, save, quit, come back, load - it's still there. These two functions are the storage layer of the app, and we won't change them again. On your own machine you'd point `path` at `"tasks.json"` and it would behave exactly as you saw here.

Next we make the list do more than grow: marking tasks done, deleting them, and filtering open from finished.


---

# Complete, Delete, and Filter

A to-do app you can only add to isn't a to-do app - it's a notepad that fills up forever. The whole point is finishing things and clearing them out. This phase gives the list its verbs: mark a task done, delete one, and show open and finished tasks apart from each other. Three small functions, each one a feature you'd actually use.

## Finding a task by id

Every operation here starts the same way: "find the task with this id." Marking done, deleting - both need to locate the right task first. So let's write that once.

Tasks are a list of dicts, and we want the one whose `id` matches. A loop does it: walk the list, return the task when the id matches, return `None` if we reach the end without finding it.

The second call below asks for an id that isn't in the list. Guess what it prints before you run it.

```python runnable
def find_task(tasks, task_id):
    for task in tasks:
        if task["id"] == task_id:
            return task
    return None

tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": False},
]

print(find_task(tasks, 2))
print(find_task(tasks, 99))   # no such task
```

The first call finds task 2 and returns the whole dict. The second asks for id 99, which doesn't exist, so the loop finishes and we return `None`. Returning `None` for "not found" is a common Python convention - the caller checks for it and can show a helpful message instead of crashing.

## Marking a task done

Now `complete_task`. It finds the task and flips its `done` flag to `True`. If there's no such task, it says so and changes nothing.

Here's the part that surprises people new to Python: when `find_task` returns the dict, it returns the **same** dict that's sitting in the list - not a copy. Change it and the list sees the change. That's exactly what we want.

```python runnable
def find_task(tasks, task_id):
    for task in tasks:
        if task["id"] == task_id:
            return task
    return None

def complete_task(tasks, task_id):
    task = find_task(tasks, task_id)
    if task is None:
        print(f"No task with id {task_id}")
        return
    task["done"] = True
    print(f"Marked done: {task['text']}")

tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": False},
]

complete_task(tasks, 1)
complete_task(tasks, 99)   # doesn't exist

print("\nFull list now:")
for task in tasks:
    print(task["id"], task["text"], "->", task["done"])
```

Run it. Task 1 gets marked done and the message confirms it. The call for id 99 prints "No task with id 99" and leaves everything alone. Then the full list shows task 1 with `done` now `True` - proof that editing the dict we found really did update the list. We never had to reach back into `tasks` by index. Finding the dict and changing it was enough.

## Deleting a task

Deleting is the one place we *don't* edit in place - we build a new list with the unwanted task left out, and hand that new list back to the caller.

**Your turn.** Write `delete_task(tasks, task_id)`. It should:
- return a new list containing every task from `tasks` except the one whose id is `task_id`
- print `f"No task with id {task_id}"` if nothing had that id
- print `f"Deleted task {task_id}"` if something did

Fill it in and run the checks. My version is in the next block whenever you want it.

```python runnable
def delete_task(tasks, task_id):
    # Return a new list containing every task from `tasks` except the
    # one whose id is `task_id`.
    # Print f"No task with id {task_id}" if no task had that id.
    # Print f"Deleted task {task_id}" if one did.
    pass


# --- checks: fix your function until this prints "All good." ---
tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": False},
    {"id": 3, "text": "water the plants", "done": False},
]

result = delete_task(tasks, 2)
assert result is not None, "delete_task must return the new list"
ids = [task["id"] for task in result]
assert ids == [1, 3], f"expected ids [1, 3] left, got: {ids}"

result2 = delete_task(result, 99)
assert [task["id"] for task in result2] == [1, 3], "deleting a missing id should change nothing"
print("All good.")
```

Stuck? Think about which tasks you want to *keep*, not which one to remove - a list comprehension is built for exactly that.

### One way to write it

```python runnable
def delete_task(tasks, task_id):
    before = len(tasks)
    tasks = [task for task in tasks if task["id"] != task_id]
    if len(tasks) == before:
        print(f"No task with id {task_id}")
    else:
        print(f"Deleted task {task_id}")
    return tasks

tasks = [
    {"id": 1, "text": "buy milk", "done": False},
    {"id": 2, "text": "call the bank", "done": False},
    {"id": 3, "text": "water the plants", "done": False},
]

tasks = delete_task(tasks, 2)

print("\nRemaining:")
for task in tasks:
    print(task["id"], "-", task["text"])
```

Read the comprehension out loud: "keep each task where the id is not 2." Task 2 is the only one excluded, so the result has 1 and 3. We compare the length before and after - if nothing got removed, the id wasn't there, and we say so.

One important detail: `delete_task` **returns** the new list, and the caller writes `tasks = delete_task(...)`. Because we built a fresh list rather than editing the old one, we have to hand it back and reassign. Forget the `tasks =` and your deletion quietly does nothing. This is a real gotcha - when a function replaces a list instead of mutating it, you must capture what it returns.

## Filtering: open vs done

Last piece. Once you've finished some tasks, you want to see what's left without the clutter - and sometimes review what you've completed. That's filtering, and it's the same comprehension trick keyed on `done`.

Two of the four tasks below are already done. Guess how many lines print under "Still to do" before you run it.

```python runnable
def open_tasks(tasks):
    return [task for task in tasks if not task["done"]]

def done_tasks(tasks):
    return [task for task in tasks if task["done"]]

tasks = [
    {"id": 1, "text": "buy milk", "done": True},
    {"id": 2, "text": "call the bank", "done": False},
    {"id": 3, "text": "water the plants", "done": False},
    {"id": 4, "text": "pay rent", "done": True},
]

print("Still to do:")
for task in open_tasks(tasks):
    print(" -", task["text"])

print("\nDone:")
for task in done_tasks(tasks):
    print(" -", task["text"])
```

Two one-line functions, mirror images of each other: `open_tasks` keeps the ones that aren't done, `done_tasks` keeps the ones that are. The output splits your list cleanly into "still to do" and "finished." This is the same list-comprehension pattern as delete - once you see how filtering works, you reach for it constantly.

## Where we are

The list now has all its verbs. You can add (Phase 1), save and load (Phase 2), and as of now complete, delete, and filter. Every behavior the app needs exists as a small, tested-by-eye function.

What's missing is the front door. Right now we call these functions by hand inside the code. A real tool reads what you typed - `add`, `list`, `done` - and runs the matching function. That's the final phase: turning this pile of functions into a command you run from your terminal.


---

# A Real CLI

You've got every piece: adding, saving, loading, completing, deleting, filtering. They work - but only when *you* call them inside the code. A real tool flips that around. You type a command in your terminal, and the program figures out which function to run. That's the last piece, and once it clicks you'll have a to-do app you actually use.

## How a command line tool thinks

When you type `python todo.py add "buy milk"`, Python hands your script the words you typed as a list called `sys.argv`.

Before you run this, guess what `sys.argv[2:]` prints - one string, or a list?

```python runnable
import sys

# In a real terminal, sys.argv holds the words you typed.
# Here we set it by hand to show the shape.
sys.argv = ["todo.py", "add", "buy milk"]

print("Whole argv:", sys.argv)
print("Script name:", sys.argv[0])
print("Command word:", sys.argv[1])
print("The rest:", sys.argv[2:])
```

`sys.argv[0]` is always the script name - we ignore it. `sys.argv[1]` is the **command word**: `add`, `list`, or `done`. Everything after that is the command's argument: the task text, or the id. Read those three slots and you know what the user wants. That's the whole idea behind a CLI - there's no magic underneath.

> The `argparse` module in the standard library does this for you with help text and validation, and for a bigger tool you'd reach for it. We're dispatching by hand first so you can see exactly what it's doing. Same shape, no mystery.

## Dispatching on the command word

"Dispatch" means: look at the command word, run the matching function.

**Your turn.** Write `dispatch(argv)` using the stand-in functions below:
- if `argv` has fewer than 2 elements, print `"Usage: todo.py [add|list|done] ..."` and return
- if `argv[1]` is `"add"`, call `do_add(argv[2])`
- if `argv[1]` is `"list"`, call `do_list()`
- if `argv[1]` is `"done"`, call `do_done(int(argv[2]))` (convert the id to an int - `argv` is all text)
- otherwise, print `f"Unknown command: {argv[1]}"`

Fill it in and run the checks. My version is in the next block whenever you want it.

```python runnable
import sys

def do_add(text):   print(f"ADD: {text}")
def do_list():      print("LIST: showing all tasks")
def do_done(task_id): print(f"DONE: completing task {task_id}")

def dispatch(argv):
    # See the spec above. Route argv[1] to the matching do_* function.
    pass


# --- checks: fix your function until this prints "All good." ---
import io, contextlib

def run(argv):
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        dispatch(argv)
    return buf.getvalue().strip()

assert run(["todo.py", "add", "buy milk"]) == "ADD: buy milk", run(["todo.py", "add", "buy milk"])
assert run(["todo.py", "list"]) == "LIST: showing all tasks"
assert run(["todo.py", "done", "3"]) == "DONE: completing task 3"
assert run(["todo.py", "fly"]) == "Unknown command: fly"
assert run(["todo.py"]) == "Usage: todo.py [add|list|done] ..."
print("All good.")
```

Stuck? A chain of `if`/`elif` on `argv[1]` handles a multi-way choice like this one just fine.

### One way to write it

```python runnable
import sys

def do_add(text):   print(f"ADD: {text}")
def do_list():      print("LIST: showing all tasks")
def do_done(task_id): print(f"DONE: completing task {task_id}")

def dispatch(argv):
    if len(argv) < 2:
        print("Usage: todo.py [add|list|done] ...")
        return
    command = argv[1]
    if command == "add":
        do_add(argv[2])
    elif command == "list":
        do_list()
    elif command == "done":
        do_done(int(argv[2]))
    else:
        print(f"Unknown command: {command}")

# Try a few "commands":
dispatch(["todo.py", "add", "buy milk"])
dispatch(["todo.py", "list"])
dispatch(["todo.py", "done", "3"])
dispatch(["todo.py", "fly"])
dispatch(["todo.py"])
```

Each call routes to the right place. Notice `int(argv[2])` for the `done` command - everything in `argv` arrives as text, so `"3"` is a string until we convert it to the number 3 that `complete_task` expects. The last two calls show the guards earning their keep: an unknown command and a missing command word both get a clear message instead of a crash.

## The whole app, assembled

Time to put every function from the project together with the dispatcher. This is `todo.py` in full - the real thing. It runs here in your browser against a temporary file so you can see it end to end, then we'll show how to run it on your own machine.

```python runnable
import sys, json, os, tempfile

# On your machine this is simply "tasks.json".
PATH = os.path.join(tempfile.gettempdir(), "todo_app.json")

def load_tasks():
    try:
        with open(PATH, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        return []

def save_tasks(tasks):
    with open(PATH, "w") as f:
        json.dump(tasks, f, indent=2)

def add_task(tasks, text):
    new_id = max((t["id"] for t in tasks), default=0) + 1
    tasks.append({"id": new_id, "text": text, "done": False})
    print(f"Added: {text}")

def list_tasks(tasks):
    if not tasks:
        print("No tasks yet. Add one!")
        return
    for t in tasks:
        mark = "x" if t["done"] else " "
        print(f"[{mark}] {t['id']}. {t['text']}")

def complete_task(tasks, task_id):
    for t in tasks:
        if t["id"] == task_id:
            t["done"] = True
            print(f"Marked done: {t['text']}")
            return
    print(f"No task with id {task_id}")

def main(argv):
    tasks = load_tasks()
    if len(argv) < 2:
        print("Usage: todo.py [add <text>|list|done <id>]")
        return
    command = argv[1]
    if command == "add":
        add_task(tasks, argv[2])
    elif command == "list":
        list_tasks(tasks)
    elif command == "done":
        complete_task(tasks, int(argv[2]))
    else:
        print(f"Unknown command: {command}")
    save_tasks(tasks)

# Start clean so this demo is repeatable.
if os.path.exists(PATH):
    os.remove(PATH)

# Simulate a session in your terminal:
main(["todo.py", "add", "buy milk"])
main(["todo.py", "add", "call the bank"])
main(["todo.py", "done", "1"])
print("\n--- todo.py list ---")
main(["todo.py", "list"])
```

Read the output top to bottom and you're watching real usage. Two tasks added, one marked done, then the list shows `[x] 1` and `[ ] 2`. And here's the payoff: every `main(...)` call loads from the file at the start and saves at the end. Each "command" is an independent run that picks up exactly where the last left off - which is precisely how it behaves when you type these in a terminal one at a time. The data persists across runs because it lives on disk between them.

## Running it for real on your machine

Everything above ran in your browser. To run it for real takes about two minutes.

**1. Install Python** if you don't have it. Check with:

```bash
python --version
```

If that prints a version (3.8 or newer), you're set. If not, grab it from python.org.

**2. Save the code.** Create a file named `todo.py` and paste in everything from the assembled block above - but make two changes so it reads from a normal file and runs from the real command line:

Change the path line to:

```python
PATH = "tasks.json"
```

And replace the demo lines at the bottom (the `os.remove` and the `main(...)` calls) with this single line, which feeds Python's real arguments into `main`:

```python
if __name__ == "__main__":
    main(sys.argv)
```

That `if __name__ == "__main__":` line means "run this when the file is executed directly." `sys.argv` now holds whatever you typed in the terminal - the real version of the fake lists we used above.

**3. Use it.** From the folder with `todo.py`:

```bash
python todo.py add "write the report"
python todo.py add "buy groceries"
python todo.py list
python todo.py done 1
python todo.py list
```

You'll see a `tasks.json` file appear next to the script. Open it in any text editor - it's your tasks in plain JSON, exactly the format from Phase 2. Close the terminal, come back tomorrow, run `python todo.py list`, and your tasks are still there.

## What you built

A working command-line to-do app, start to finish:

```mermaid
graph LR
  A[you type a command] --> B[load tasks.json]
  B --> C[run add / list / done]
  C --> D[save tasks.json]
```

You modeled data as a list of dicts, made it permanent with JSON, gave it the verbs to complete and delete, and wrapped it in a command line you drive yourself. Nothing was installed beyond Python - the standard library carried the whole project.

From here, the obvious next moves are yours to take: add a `delete` command (you already wrote the function in Phase 3), wire in the open/done filters as a `list open` flag, or swap the hand-rolled dispatcher for `argparse` to get help text for free. You've got a real foundation now. Go finish your list.
