Skip to main content

Subcommands and Help

If you're new to programming

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.

If you've coded before

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:

SubcommandPurposeArguments
addCreate a notetitle, body, --tag
searchFind notesquery
listShow all notes--tag (optional filter)
exportSave 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:

  1. Matches add to the add subparser
  2. Parses "My Title" and "My body" as title and body
  3. Attaches handle_add as args.func (from set_defaults)
  4. Your code calls args.func(args), which calls handle_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:

  1. Have its own arguments (positional and/or optional as appropriate)
  2. Use set_defaults(func=handler) for dispatch
  3. Have descriptive help= strings that appear in --help

Verify by running smartnotes --help and each subcommand's --help.


Try With AI

Opening Claude Code

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."