Capstone: a command-line app
Design, build and test a complete program with a database.
Create a free account Try part of it here
Why it matters
This capstone brings the phase together: data structures, SQL, design, types, testing and project structure, all in one program you could ship. Command-line tools are also how much of the software world fits together. Scripts call them, CI runs them, and AI agents use them as tools. A well-built CLI with clear output and exit codes is something both people and agents can drive.
What you'll be able to do
- Turn a short brief into requirements, a data model and a layered design
- Build a command-line interface with argparse, exit codes and clear errors
- Store data with a repository class over SQLite
- Test a whole program by calling its entry point with arguments
What it covers
- The brief
- Exit codes and output streams
- Parsing arguments with argparse
- The repository
- Testing a CLI by calling main
- Shipping it
- Habits that separate a tool from a script
The brief
"I want a to-do tool for my terminal. Add tasks with a priority, list what's left (most important first), and mark tasks done. It should remember tasks between runs."
From brief to requirements
A contractor turns "a nice kitchen" into a list of things you can check off. Before code, turn the brief into checkable statements too. Each one will become a test:
tasks add "Write report" --priority highsaves a task and prints its id.- Priority is
low,normal(the default) orhigh. Anything else is a usage error. tasks listshows unfinished tasks, high priority first, then by id.--allincludes done ones.tasks done 3marks task 3 done. An unknown id is an error.- An empty title is an error.
- Exit code 0 means success, 1 means the command failed, 2 means it was typed wrong.
- Tasks survive between runs, so they're stored in a SQLite file.
The design
Three layers, as in the design lesson:
argv ──▶ main() interface: parse arguments, print, choose the exit code
│
▼
TaskRepo data: every SQL statement lives here
│
▼
SQLite file
The program is small, so the business rules (priorities and ordering) sit in the repository. If it grew (due dates, reminders, sharing), a service layer between the two would take them.
Exit codes and output streams
A referee's whistle tells everyone what happened without a speech. Every program that ends hands back a number, its exit code. 0 means success and anything else means failure. Shells, scripts and CI read it: tasks done 9 && echo ok prints "ok" only if the command succeeded. Agents read it too, to learn whether a tool call worked.
Programs also have two output streams, like an office with one tray for finished work and another for problems. Standard output (stdout) is for results. Standard error (stderr) is for errors and warnings. Keeping them apart means tasks list > today.txt saves the list without error messages mixed in.
In plain words: people read your words, and programs read your exit code. Get both right.
def main(): return 0 code = main() print("ok" if code == 0 else "failed")
Show the answer
ok
main returns 0, so the test passes and it prints ok. An exit code of 0 means success, and that's what scripts check.
Follow these rules and your tool works with every other command-line program.
Parsing arguments with argparse
A good receptionist reads your form, checks that the boxes make sense and sends you back if they don't. argparse does that for the words after a command. It turns them into named values, checks them, and writes --help for free. Subcommands such as add, list and done each get their own small parser:
import argparse parser = argparse.ArgumentParser(prog="tasks", description="A to-do list for your terminal.") sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add", help="add a task") add.add_argument("title") add.add_argument("--priority", choices=["low", "normal", "high"], default="normal") done = sub.add_parser("done", help="mark a task done") done.add_argument("id", type=int) print(parser.parse_args(["add", "Write report", "--priority", "high"])) print(parser.parse_args(["done", "3"]))
Notice type=int: "3" arrives as text and argparse converts it, rejecting "three". That's parsing at the edge, again. When the arguments are wrong, argparse prints a usage message to stderr and raises SystemExit(2). In a real terminal that ends the program with exit code 2.
tasks done 42 and task 42 doesn't exist. What should the tool do?Show the answer
Print an error to stderr and exit with a non-zero code such as 1
A clear message for people on stderr, and a non-zero exit code so scripts and agents can tell it failed.
The repository
All SQL lives in one class, the records clerk from the design lesson. Everything else calls its methods and never sees a query. The ordering rule (high first) is one SQL expression:
ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, id
CASE turns each priority into a number for sorting, and id breaks ties so the order is always the same.
Testable from the start
Here's the most important design decision in the whole program. main takes the argument list and the repository as parameters:
def main(argv: list[str], repo: TaskRepo) -> int: ... if __name__ == "__main__": import sys, sqlite3 repo = TaskRepo(sqlite3.connect("tasks.db")) sys.exit(main(sys.argv[1:], repo))
The real program passes sys.argv and a database file. Tests pass a list such as ["add", "Buy milk"] and an in-memory database, then check what was printed and what code came back. It's the crash-test dummy again: the whole program can be tested without a terminal and without touching the disk.
main() take the arguments as a parameter, instead of reading sys.argv inside?Show the answer
Tests can call it with any arguments and check the exit code and output directly
Passing argv in keeps main() testable without starting a new process for every case.
Testing a CLI by calling main
A tiny CLI with one command, tested the way the exercise will test yours: capture what it prints and check the exit code.
import argparse, contextlib, io def main(argv): parser = argparse.ArgumentParser(prog="greet") parser.add_argument("name") parser.add_argument("--shout", action="store_true") try: args = parser.parse_args(argv) except SystemExit as exc: return exc.code # 2 for a usage error text = f"Hello, {args.name}!" print(text.upper() if args.shout else text) return 0 def run(argv): out, err = io.StringIO(), io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): code = main(argv) return code, out.getvalue().strip(), err.getvalue().strip() print(run(["Ada"])) print(run(["Ada", "--shout"])) code, _, err = run([]) print(code, "|", err.splitlines()[-1])
run is a small test helper: it captures stdout and stderr and returns them with the exit code. With no name, argparse writes its usage error to stderr and main returns 2. Each test is then one line: run a command, compare three values.
Shipping it
To make tasks a real command, add it to pyproject.toml:
[project.scripts] tasks = "tasks.cli:main_entry"
where main_entry() builds the real repository and calls sys.exit(main(sys.argv[1:], repo)). Then uv tool install . puts the command on your path. Add a README with install, usage and test commands, a CI workflow running ruff, mypy and the tests, and it's a project anyone can pick up.
Ideas for going further
--jsonoutput onlist, so other programs and AI agents can read the result reliably.- Due dates, parsed with
datetime.date.fromisoformatand stored as ISO text. tasks edit 3 --title ..., with a test written before the code (TDD).- A service layer once there are rules beyond ordering.
Show the answer
0
0 means success to shells and CI. Any other number signals an error, so scripts can stop on failure.
Habits that separate a tool from a script
Errors go to stderr, with a non-zero exit code. If your tool prints "file not found" to stdout and exits with 0, then a script running it believes it worked, and carries on with bad data. Other programs can't read your words. They read your exit code.
Keep sys.argv at the very top. Have main(argv) take the arguments as a list, and pass them in from one place. Then a test can call main(["add", "buy milk"]) directly, with no subprocess at all. If argument reading is scattered through the program, you can't test any of it easily.
Keep SQL in one class. When queries are spread through the command-line code, changing a table means hunting through everything. With one repository class, the rest of the program never knows that SQL exists, and you could swap it for something else.
Write the requirements down first. A few lines saying what the tool must do, and what happens on bad input, will save you from discovering those questions as bugs. "What happens if the task doesn't exist?" is much cheaper to answer before you've written the code.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does an exit code of 0 mean?