Index Ai Academy
Phase 7 · Software engineering · Building software

Capstone: a command-line app

Design, build and test a complete program with a database.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The brief
  2. Exit codes and output streams
  3. Parsing arguments with argparse
  4. The repository
  5. Testing a CLI by calling main
  6. Shipping it
  7. 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:

  1. tasks add "Write report" --priority high saves a task and prints its id.
  2. Priority is low, normal (the default) or high. Anything else is a usage error.
  3. tasks list shows unfinished tasks, high priority first, then by id. --all includes done ones.
  4. tasks done 3 marks task 3 done. An unknown id is an error.
  5. An empty title is an error.
  6. Exit code 0 means success, 1 means the command failed, 2 means it was typed wrong.
  7. 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.

Quick check
What does this print?
def main():
    return 0
code = main()
print("ok" if code == 0 else "failed")
  1. None
  2. ok
  3. failed
  4. 0
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.

Try it now

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"]))
It runs right here in your browser, with nothing to install.

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.

Quick check
A script runs tasks done 42 and task 42 doesn't exist. What should the tool do?
  1. Create task 42 and mark it done
  2. Crash with a traceback
  3. Print an error to stderr and exit with a non-zero code such as 1
  4. Print "Done: 42" and exit with 0 so the script keeps going
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.

Quick check
Why does the capstone's main() take the arguments as a parameter, instead of reading sys.argv inside?
  1. Tests can call it with any arguments and check the exit code and output directly
  2. It makes the tool run faster for users
  3. sys.argv is slow to read
  4. argparse can't read sys.argv
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.

Worked example

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])
In the lesson you can change this and run it.

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

  • --json output on list, so other programs and AI agents can read the result reliably.
  • Due dates, parsed with datetime.date.fromisoformat and 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.
Quick check
Which exit code should a command-line tool return when it succeeds?
  1. 200
  2. 0
  3. -1
  4. 1
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

Create a free account

Try a quiz question

What does an exit code of 0 mean?

  1. The program succeeded
  2. The program printed nothing
  3. The program crashed
  4. The program is still running

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson