From Library to Command
A CLI tool is a program you run from the terminal by typing its name and some arguments, like smartnotes add "Python Tips" "My first note". This lesson teaches you how Python receives those arguments and why the simplest approach gets painful fast.
You know CLI tools. This chapter builds one with argparse and proper entry points. This lesson covers the raw sys.argv approach first to motivate why argparse exists. The TDG exercise builds a command dispatcher from scratch.
James opens a terminal and runs smartnotes add "Python Tips" "My first note".
Nothing happens. The terminal prints an error. SmartNotes is a Python package. It has models.py, search.py, storage.py, analytics.py. It works perfectly when you import it into a Python file or the REPL. But the terminal does not know what smartnotes means.
"In the warehouse," James says, "we had an inventory database. Fantastic system. But the only way to check stock levels was to open the database application, log in, navigate to the query screen, and type SQL. So nobody used it. They called me on the phone instead."
Emma sets down her coffee. "Same problem. SmartNotes is the database. The terminal is the phone call. Right now there is no connection between them."
"How do I make the connection?"
"You already did it once," Emma says. "In Chapter 20, you told Claude Code to build stdin/stdout tools that piped data between scripts. You understood the philosophy. Now you write the Python yourself."
The Library-to-Tool Gap
Here is how you use SmartNotes today:
from smartnotes.models import Note
from smartnotes.storage import save_note
note = Note(title="Python Tips", body="My first note", word_count=3)
save_note(note)
That works. But asking a colleague to open a Python REPL, type these imports, and call functions is not how software works. Real tools look like this:
smartnotes add "Python Tips" "My first note"
smartnotes search "python"
smartnotes list
The gap between "importable library" and "usable command" is what this chapter closes. You need three things:
| What | Why | Where you learn it |
|---|---|---|
| Read arguments from the terminal | Know what the user typed | This lesson (sys.argv) |
| Parse arguments safely | Validate types, show help text | Lesson 2 (argparse) |
| Register an entry point | Let the OS find your program | Lesson 6 ([project.scripts]) |
This lesson tackles the first item: reading what the user typed.
sys.argv: The Raw Approach
Create a file called cli_test.py with this code:
import sys
print(sys.argv)
print(type(sys.argv))
print(len(sys.argv))
Run it from the terminal with some arguments:
uv run python cli_test.py add "Python Tips" "My first note"
Output:
['cli_test.py', 'add', 'Python Tips', 'My first note']
<class 'list'>
4
sys.argv is a list of strings. Every word you typed after python becomes an element. The first element is always the script name. The rest are the arguments you passed.
| Index | Value | What it represents |
|---|---|---|
sys.argv[0] | 'cli_test.py' | The script name |
sys.argv[1] | 'add' | The command (first argument) |
sys.argv[2] | 'Python Tips' | The note title (second argument) |
sys.argv[3] | 'My first note' | The note body (third argument) |
Quoted strings like "Python Tips" arrive as a single element. The terminal handles the quoting before Python sees the arguments.
Try running it with no arguments:
uv run python cli_test.py
Output:
['cli_test.py']
<class 'list'>
1
Just the script name. sys.argv always has at least one element.
Why sys.argv Is Painful
Reading sys.argv is straightforward for one command. What about three? Here is a manual dispatcher for SmartNotes:
import sys
from smartnotes.models import Note
from smartnotes.storage import save_note, load_notes
from smartnotes.search import search_notes
def main() -> None:
if len(sys.argv) < 2:
print("Usage: smartnotes <command> [arguments]")
print("Commands: add, search, list")
sys.exit(1)
command: str = sys.argv[1]
if command == "add":
if len(sys.argv) < 4:
print("Usage: smartnotes add <title> <body>")
sys.exit(1)
title: str = sys.argv[2]
body: str = sys.argv[3]
note = Note(title=title, body=body, word_count=len(body.split()))
save_note(note)
print(f"Added note: {title}")
elif command == "search":
if len(sys.argv) < 3:
print("Usage: smartnotes search <keyword>")
sys.exit(1)
keyword: str = sys.argv[2]
results: list[Note] = search_notes(keyword)
for note in results:
print(f" {note.title}")
elif command == "list":
notes: list[Note] = load_notes()
for note in notes:
print(f" {note.title} ({note.word_count} words)")
else:
print(f"Unknown command: {command}")
sys.exit(1)
if __name__ == "__main__":
main()
This works. Run uv run python cli.py add "Python Tips" "My first note" and it adds a note. Run uv run python cli.py list and it prints your notes.
But look at the problems:
| Problem | What happens |
|---|---|
| No help text | User types --help and gets "Unknown command: --help" |
| No type checking | Everything is a string; sys.argv[3] is always str even if you want an integer |
| Wrong argument count gives a confusing error | Forgetting the body prints your custom error, but a typo in the command name gives a generic "Unknown command" |
| Every new command adds another elif branch | Adding export, delete, stats makes this function longer and harder to maintain |
| No optional arguments | How would you add --author "Emma"? Manual parsing of -- flags is error-prone |
Run it with the wrong number of arguments:
uv run python cli.py add "Python Tips"
Output:
Usage: smartnotes add <title> <body>
That is your custom error message. Now try:
uv run python cli.py add
Output:
Usage: smartnotes add <title> <body>
Same message. The user cannot tell whether they forgot the title, the body, or both. A real CLI tool would say: error: the following arguments are required: title, body.
This pain is the motivation for argparse. The standard library gives you a tool that handles help text, type validation, required vs. optional arguments, and error messages automatically. You write the argument definitions; argparse handles everything else.
PRIMM-AI+ Practice: Predict the Arguments
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
What does sys.argv contain for each of these commands? Write your predictions on paper.
# Command A
uv run python cli.py search "hello world"
# Command B
uv run python cli.py
# Command C
uv run python cli.py export --format json notes.txt
Rate your confidence from 1 to 5 for each.
Check your predictions
# Command A
['cli.py', 'search', 'hello world'] # 3 elements
# Command B
['cli.py'] # 1 element (just the script name)
# Command C
['cli.py', 'export', '--format', 'json', 'notes.txt'] # 5 elements
Notice Command C: --format and json are separate elements. sys.argv does not know that --format is a flag and json is its value. They are all just strings in a list. This is exactly why manual parsing is painful.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called argv_explorer.py:
import sys
for i, arg in enumerate(sys.argv):
print(f" sys.argv[{i}] = {arg!r}")
Run each of the three commands above (replacing cli.py with argv_explorer.py) and compare the output to your predictions.
Investigate
Run /investigate @argv_explorer.py in Claude Code and ask: "Why does sys.argv treat --format as just another string? How would I tell the difference between a flag like --format and a positional argument like json?"
The answer leads you to the exact problem argparse solves in Lesson 2.
Modify
Add a fourth command to the manual dispatcher: stats, which prints the total number of notes and the average word count. You need to check len(sys.argv) and add another elif branch. Notice how the function keeps growing.
Make [Mastery Gate]
Write a function dispatch(args: list[str]) -> str that takes a list of arguments (like sys.argv[1:]) and returns a string describing the action. The function should handle three commands: add, search, and list. If the command is unknown, return "Unknown command: {command}". If no command is given, return "No command provided".
In Claude Code, type /tdg to guide you through the cycle:
- Write the stub with types and docstring
- Write 4+ tests (no command, each valid command, unknown command)
- Prompt AI to implement
- Run
uv run ruff check,uv run pyright,uv run pytest - Verify with at least one additional test case you invent yourself
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Explore sys.argv Edge Cases
I am learning about sys.argv. Show me what sys.argv
contains when I run each of these commands:
1. python script.py
2. python script.py "hello world" 'single quotes'
3. python script.py --verbose -n 5 file.txt
For each one, show the complete sys.argv list and
explain any surprises. Then show me one case where
sys.argv behaves differently on Windows vs macOS/Linux.
What you're learning: sys.argv behaves slightly differently across operating systems, especially with quote handling. Understanding these edge cases prepares you for writing tools that work everywhere.
Prompt 2: Compare to Chapter 20
In Chapter 20, I directed Claude Code to build a stdin/stdout
pipeline tool. Now I am writing CLI argument handling myself
using sys.argv. Compare these two approaches:
1. Reading from stdin (sys.stdin)
2. Reading from sys.argv
When should a tool use stdin (piped input) vs command-line
arguments? Give me a concrete example of a tool that uses both.
What you're learning: Unix tools often combine both approaches: arguments configure behavior, stdin provides data. grep "pattern" file.txt uses arguments, while cat file.txt | grep "pattern" uses stdin. Your SmartNotes CLI will eventually support both (Lesson 5).
Prompt 3: Dispatcher Pattern Review
Here is my manual command dispatcher using sys.argv:
[paste your dispatch function from the Mastery Gate]
Review this code. What are the three biggest problems
with this approach? For each problem, describe the
specific failure scenario a user would hit.
What you're learning: Code review is a core TDG skill. You are training yourself to identify fragility in argument handling before users discover it, not after. The problems the AI identifies are exactly what argparse solves in Lesson 2.
James stares at his elif chain. Four commands, and the function is already 40 lines. "In the warehouse, we started with a paper checklist for incoming shipments. Three items? Fine. Thirty items? The checklist was three pages long and people skipped half the fields. We switched to a barcode scanner that enforced every field automatically."
Emma grins. "That scanner is argparse. You describe the fields: this one is required, that one is optional, this one must be a number. argparse enforces the rules and prints help text. No more elif chains."
"But," she adds, tilting her head, "I once tried to use argparse for a tool that only had a single positional argument. It was more code than the sys.argv version. Sometimes the simple approach is fine." She pauses. "The skill is knowing when the pain of manual parsing outweighs the setup cost of a proper parser."
"Three commands was the tipping point for me," James says.
"That is about right."