Subcommands and Help
You already know commands like git add and git commit. The word after git is a subcommand: it tells the tool which operation to run. This lesson teaches you how to build the same structure in your own CLI, so smartnotes add and smartnotes search each have their own arguments and help text.
argparse subparsers via add_subparsers() and add_parser(). Each subcommand gets its own argument set. Dispatch uses set_defaults(func=handler) plus args.func(args). This lesson builds add, search, and list subcommands for SmartNotes with per-subcommand help.
In Lesson 2, James built a parser for smartnotes add <title> <body>. Now he needs search, list, and export too. His first instinct is to cram everything into one parser:
# Do NOT do this. It gets ugly fast.
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["add", "search", "list", "export"])
parser.add_argument("title", nargs="?")
parser.add_argument("body", nargs="?")
parser.add_argument("--tag")
parser.add_argument("--format")
"The title argument makes sense for add," James says, "but list does not need a title. And --format only applies to export. Everything is tangled together."
"That is why argparse has subparsers," Emma says. "Each subcommand gets its own parser with its own arguments. Like a warehouse with separate receiving docks: one for inbound, one for outbound, one for returns. Each dock has its own paperwork."
Why Subcommands?
Think about git. It is one tool with dozens of operations:
git add file.py
git commit -m "Fix bug"
git push origin main
Each subcommand (add, commit, push) has completely different arguments. commit needs -m for a message. push needs a remote and a branch. They share nothing except the git prefix.
The alternative is separate tools: git-add, git-commit, git-push. That works, but users would need to discover and remember many different programs. One tool with subcommands is easier to learn and easier to document.
SmartNotes needs the same pattern. Four operations, one entry point:
| Subcommand | Purpose | Arguments |
|---|---|---|
add | Create a note | title, body, --tag |
search | Find notes | query |
list | Show all notes | --tag (optional filter) |
export | Save to file | --format |
add_subparsers()
Start with the top-level parser, then create a subparser group:
import argparse
parser = argparse.ArgumentParser(
prog="smartnotes",
description="A note-taking tool for the command line.",
)
subparsers = parser.add_subparsers(
title="commands",
dest="command",
help="Available commands",
)
add_subparsers() returns an object that acts as a factory for subcommand parsers. The dest="command" argument stores the chosen subcommand name in args.command.
Now register each subcommand with add_parser():
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="smartnotes",
description="A note-taking tool for the command line.",
)
subparsers = parser.add_subparsers(
title="commands",
dest="command",
help="Available commands",
)
# --- add ---
add_parser = subparsers.add_parser("add", help="Create a new note")
add_parser.add_argument("title", help="Note title")
add_parser.add_argument("body", help="Note body text")
add_parser.add_argument("--tag", action="append", default=[], help="Add a tag (repeatable)")
# --- search ---
search_parser = subparsers.add_parser("search", help="Search notes by keyword")
search_parser.add_argument("query", help="Search term")
# --- list ---
subparsers.add_parser("list", help="List all notes")
return parser
Each add_parser() call creates a new ArgumentParser scoped to that subcommand. The add subcommand has title, body, and --tag. The search subcommand has query. The list subcommand has no extra arguments at all. They are completely independent.
Dispatching to Functions
You need a handler function for each subcommand. The naive approach uses an if/elif chain:
# Works, but does not scale.
args = parser.parse_args()
if args.command == "add":
handle_add(args)
elif args.command == "search":
handle_search(args)
elif args.command == "list":
handle_list(args)
argparse has a cleaner pattern: set_defaults(func=...). Attach a handler function directly to each subcommand parser:
# --- add ---
add_parser = subparsers.add_parser("add", help="Create a new note")
add_parser.add_argument("title", help="Note title")
add_parser.add_argument("body", help="Note body text")
add_parser.add_argument("--tag", action="append", default=[], help="Add a tag (repeatable)")
add_parser.set_defaults(func=handle_add)
# --- search ---
search_parser = subparsers.add_parser("search", help="Search notes by keyword")
search_parser.add_argument("query", help="Search term")
search_parser.set_defaults(func=handle_search)
# --- list ---
list_parser = subparsers.add_parser("list", help="List all notes")
list_parser.set_defaults(func=handle_list)
Now dispatch is one line:
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
When a user types smartnotes add "My Title" "My body", argparse:
- Matches
addto the add subparser - Parses
"My Title"and"My body"astitleandbody - Attaches
handle_addasargs.func(fromset_defaults) - Your code calls
args.func(args), which callshandle_add(args)
If the user types smartnotes with no subcommand, args has no func attribute, so the else branch prints help instead.
Here are the handler functions:
def handle_add(args: argparse.Namespace) -> None:
print(f"Adding note: {args.title}")
print(f" Body: {args.body}")
if args.tag:
print(f" Tags: {', '.join(args.tag)}")
def handle_search(args: argparse.Namespace) -> None:
print(f"Searching for: {args.query}")
def handle_list(args: argparse.Namespace) -> None:
print("Listing all notes...")
Output (running add):
python cli.py add "Python Tips" "Learn decorators" --tag python --tag learning
Adding note: Python Tips
Body: Learn decorators
Tags: python, learning
Output (running search):
python cli.py search decorators
Searching for: decorators
Output (no subcommand):
python cli.py
usage: smartnotes [-h] {add,search,list} ...
A note-taking tool for the command line.
options:
-h, --help show this help message and exit
commands:
{add,search,list} Available commands
add Create a new note
search Search notes by keyword
list List all notes
Help Text That Writes Itself
One of the best features of subparsers is automatic help generation. Every help= string you provided when registering subcommands and arguments now appears in the output.
The top-level help shows all subcommands:
python cli.py --help
Output:
usage: smartnotes [-h] {add,search,list} ...
A note-taking tool for the command line.
options:
-h, --help show this help message and exit
commands:
{add,search,list} Available commands
add Create a new note
search Search notes by keyword
list List all notes
Each subcommand has its own help page:
python cli.py add --help
Output:
usage: smartnotes add [-h] [--tag TAG] title body
positional arguments:
title Note title
body Note body text
options:
-h, --help show this help message and exit
--tag TAG Add a tag (repeatable)
You did not write this help text. argparse assembled it from the argument names, help strings, and usage patterns you defined. This is why descriptive help= strings matter: they become the documentation.
PRIMM-AI+ Practice: Predict Help Output
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
Given this parser setup:
parser = argparse.ArgumentParser(prog="tasks")
subs = parser.add_subparsers(title="commands", dest="command")
done_parser = subs.add_parser("done", help="Mark a task complete")
done_parser.add_argument("task_id", type=int, help="Task ID number")
subs.add_parser("pending", help="Show incomplete tasks")
Write the output of python tasks.py done --help. How many positional arguments does done show? Does pending --help show any positional arguments?
Check your predictions
tasks done --help:
usage: tasks done [-h] task_id
positional arguments:
task_id Task ID number
options:
-h, --help show this help message and exit
done shows 1 positional argument (task_id). pending --help shows 0 positional arguments because none were registered for that subcommand.
Run
Press Shift+Tab to exit Plan Mode.
Create tasks.py with the parser code above, add set_defaults(func=...) for each subcommand, and test all three help outputs: --help, done --help, pending --help.
Investigate
Add a priority subcommand to tasks.py that takes a task_id and a --level flag with choices ["low", "medium", "high"]. Run python tasks.py priority --help and verify the choices appear in the help text.
If you want to go deeper, run /investigate @tasks.py in Claude Code and ask: "What happens if I call set_defaults(func=...) twice on the same subparser? Does the second call overwrite the first?"
Modify
Add an export subcommand to SmartNotes that accepts --format with choices ["json", "csv", "text"] and a default of "json". Verify smartnotes export --help displays the choices and default.
Make [Mastery Gate]
Using /tdg, write a complete SmartNotes CLI with four subcommands: add, search, list, export. Each subcommand must:
- Have its own arguments (positional and/or optional as appropriate)
- Use
set_defaults(func=handler)for dispatch - Have descriptive
help=strings that appear in--help
Verify by running smartnotes --help and each subcommand's --help.
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 Subparser Features
I have a CLI with add_subparsers. How can I make the
subcommand required instead of optional? What happens
if I add add_subparsers(required=True)? Also show me
how to add aliases so "smartnotes ls" works the same
as "smartnotes list".
What you're learning: Real-world CLIs need polish. The required parameter prevents users from running the tool with no subcommand. Aliases let you add shorthand names, which is how git co can mean git checkout.
Prompt 2: Nested Subcommands
Can subcommands have their own subcommands?
For example: smartnotes export json or smartnotes export csv.
Show me how to nest add_subparsers inside a subparser.
When is nesting appropriate vs using --format as a flag?
What you're learning: Nesting subcommands creates deeper hierarchies. The AI helps you evaluate the tradeoff: flags are simpler for options, nested subcommands are better when each choice has completely different arguments.
Prompt 3: Apply to Your Domain
I want to build a CLI for [your domain: inventory tracking,
student records, recipe manager, etc.]. It needs at least
4 subcommands. Design the parser using add_subparsers with
set_defaults dispatch. Show the full code and the help output.
What you're learning: Transferring the subparser pattern to a new domain tests whether you understand the structure, not just the SmartNotes example. The AI generates a starting point; you evaluate whether the subcommand boundaries make sense.
James tests the CLI. smartnotes --help shows all four subcommands with descriptions. smartnotes add --help shows exactly the arguments for add, nothing more.
"It is like walkie-talkie channels in the warehouse," James says. "Channel 1 is receiving, channel 2 is shipping, channel 3 is inventory. Each channel has its own protocol, but they all go through the same radio."
Emma pauses. "I was going to compare it to function overloading, but your walkie-talkie analogy actually captures the dispatch pattern better. Each channel routes to a different team with different procedures."
"The only problem," James says, "is that when something goes wrong, the CLI just crashes. If I search for a tag that does not exist, I get a Python traceback instead of a helpful error."
"That is exactly the next lesson," Emma says. "Exit codes, stderr, and environment variables. Making your CLI a good citizen of the terminal."