Python Automation #7: Building Your Own Command — Packaging a CLI with typer and rich
One or two scripts are no problem. But by the time you’ve followed along through part 6, you’ve accumulated scripts for folder organizing, Excel reports, scraping, and email — and a new problem appears: “how did I run that one again?” One script takes three arguments; another only works from a specific folder. In this final post of the series, we gather those scattered scripts into a single CLI tool, callable with one word from anywhere in the terminal.
From argparse to typer #
In part 1 we parsed arguments with argparse from the standard library. It works fine, but it’s a style where every argument adds another declaration line like parser.add_argument("folder"), so the declarations grow verbose as the interface grows. typer solves the same problem with type hints. The function signature is the CLI definition.
from pathlib import Path
import typer
def organize(folder: Path, dry_run: bool = False):
"""Organize the downloads folder by file extension."""
print(f"Organizing {folder} (dry_run={dry_run})")
if __name__ == "__main__":
typer.run(organize)There’s no declaration code anywhere, yet typer reads everything from the signature. folder: Path becomes a required argument with the input automatically converted to a Path object, dry_run: bool = False becomes a --dry-run flag, and the docstring becomes the help text.
Install both libraries with uv add typer rich, then run it directly: uv run cli.py ~/Downloads --dry-run.
Grouping with subcommands #
This is where the real value starts. Register multiple functions on a typer.Typer() object and you get a tool with subcommands, just like git. Let’s move the scripts from parts 1–6 into one function each, collect them in a single tool, and call them as subcommands.
from pathlib import Path
import typer
app = typer.Typer(help="My automation toolbox")
@app.command()
def organize(folder: Path, dry_run: bool = False):
"""Organize the downloads folder by file extension."""
...
@app.command()
def scrape(url: str, output: Path = Path("result.csv")):
"""Collect board posts and save them as CSV."""
...
@app.command()
def report(month: str, send: bool = False):
"""Build the monthly Excel report; with --send, email it."""
...
if __name__ == "__main__":
app()uv run mytools/cli.py organize ~/Downloads --dry-run
uv run mytools/cli.py scrape https://example.com/board
uv run mytools/cli.py report 2026-07 --sendNow there’s a command structure. Instead of remembering seven script files, you remember one tool and a few subcommand names.
–help for free #
Once everything is under typer, you never write help text separately.
My automation toolbox
Commands:
organize Organize the downloads folder by file extension.
scrape Collect board posts and save them as CSV.
report Build the monthly Excel report; with --send, email it.The value of this screen is proven by your future self three months from now. In the loose-scripts era, you had to open the code and reread the arguments; now one --help and the tool itself tells you what exists and how to use it. Each subcommand gets its own organize --help too.
Prettier output with rich #
rich, installed alongside typer, handles terminal output. For automation tools, the most useful pieces are progress bars and tables. Let’s rework the body of the organize command:
from rich.console import Console
from rich.progress import track
from rich.table import Table
console = Console()
counts: dict[str, int] = {}
for file in track(list(folder.iterdir()), description="Organizing..."):
ext = file.suffix.lstrip(".") or "other"
counts[ext] = counts.get(ext, 0) + 1 # the move logic stays as in part 1
table = Table(title="Results")
table.add_column("Extension")
table.add_column("Count", justify="right")
for ext, count in sorted(counts.items()):
table.add_row(ext, str(count))
console.print(table)Wrapping the loop in track() is all it takes to get a progress bar, and Table prints the results as a neatly aligned table. The screen no longer looks frozen while hundreds of files move, and when it’s done you can see at a glance what was processed and how much. Adding color is a one-liner too: console.print("[red]failed[/red]").
Installing it as a command available anywhere #
Right now you have to call it from the project folder with uv run mytools/cli.py ..., but a real tool should launch as the single word mytools from any directory. The link is [project.scripts] in pyproject.toml.
[project]
name = "mytools"
version = "0.1.0"
dependencies = ["typer", "rich"]
[project.scripts]
mytools = "mytools.cli:app"
[build-system]
requires = ["uv_build"]
build-backend = "uv_build"mytools = "mytools.cli:app" declares: “when the command mytools comes in, run the app object in mytools/cli.py.” Now install it as a tool.
uv tool install .
mytools organize ~/Downloadsuv tool install installs the package into its own isolated environment and exposes only the command on PATH. It doesn’t mix with other projects’ virtual environments, and it’s callable from any folder. In an environment without uv, pipx install . plays the same role.
Sharing with a team isn’t hard either. With nothing but a git repository — no internal PyPI server — a colleague installs the same tool with one line, uv tool install git+https://github.com/our-team/mytools.git, and updates with uv tool upgrade mytools. Compared to passing script files around in chat, version management and dependency installation are all handled automatically.
Closing the series #
Let’s look back at the seven parts, one line each.
- #1 Moved a manual chore into code with a first file-organizing script
- #2 Ended repetitive Excel work with openpyxl
- #3 Collected static pages with httpx and BeautifulSoup
- #4 Handled JavaScript-rendered pages with Playwright
- #5 Made results arrive by email and messenger notifications
- #6 Put it all on a scheduler to run without a human
- #7 Packaged everything into one CLI tool, one command
Looking back, the tools were only assistants — automation really starts with an eye for spotting repetition. The moment you notice you’ve been organizing the same folder every week, or copying the same table every month, is the starting point. Move those repetitions into code one by one, and before long you have a toolbox of your own, like the one in this post. And as the toolbox grows, a new question arises: “I changed this — did I break another command?” That’s where testing comes in, and the Python Testing series answers it with pytest. If you plan to use your automation tools for the long haul, that’s the recommended next stop.