Turn tiny scripts into polished CLIs you can install, share, and run from anywhere.
1) Why build a CLI?
- Speed: One command beats 10 clicks.
- Automation: Scriptable in cron, CI, or other tools.
- Shareable: Pack once, install anywhere.
- Composability: Works well with pipes (
|
) and redirects.
2) Prerequisites
- Python 3.9+ recommended
pip
(and optionallypipx
)- Basic terminal familiarity
python -m venv .venv
then source .venv/bin/activate
or .venv\Scripts\activate
on Windows) while developing.3) A 3-minute CLI with argparse
Start simple with the standard library:
# greet/__main__.py
import argparse
def main():
parser = argparse.ArgumentParser(prog="greet", description="Say hello from your CLI.")
parser.add_argument("name", help="Who to greet")
parser.add_argument("--shout", action="store_true", help="Uppercase the greeting")
args = parser.parse_args()
msg = f"Hello, {args.name}!"
if args.shout:
msg = msg.upper()
print(msg)
if __name__ == "__main__":
main()
Run it straight from the module:
python -m greet Remo
python -m greet Remo --shout
Next, we’ll package it so you can just type greet anywhere.
4) Recommended project structure
greet/
├─ greet/
│ ├─ __init__.py
│ └─ __main__.py
├─ pyproject.toml
└─ README.md
5) A real app with Typer (Click) + Rich
Typer (built on Click) gives you subcommands, type hints, smart help, and shell completion with almost no boilerplate. We’ll build a tiny to-do manager called tasker that stores data in ~/.tasker.json
and prints pretty tables using Rich.
# tasker_cli/__main__.py
from __future__ import annotations
import json, os
from pathlib import Path
from typing import List, Dict
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(add_completion=True)
console = Console()
DATA_FILE = Path(os.getenv("TASKER_FILE", Path.home() / ".tasker.json"))
def load_tasks() -> List[Dict]:
if DATA_FILE.exists():
return json.loads(DATA_FILE.read_text())
return []
def save_tasks(tasks: List[Dict]) -> None:
DATA_FILE.write_text(json.dumps(tasks, indent=2))
@app.command()
def add(text: str = typer.Argument(..., help="Task description")):
"""Add a new task."""
tasks = load_tasks()
next_id = (max([t["id"] for t in tasks]) + 1) if tasks else 1
tasks.append({"id": next_id, "text": text, "done": False})
save_tasks(tasks)
console.print(f"✅ Added task [bold]{text}[/] (id={next_id})")
@app.command("list")
def list_tasks(show_done: bool = typer.Option(True, help="Include completed tasks")):
"""List tasks."""
tasks = load_tasks()
table = Table(title="Tasker")
table.add_column("ID", justify="right")
table.add_column("Status")
table.add_column("Task")
for t in tasks:
if not show_done and t["done"]:
continue
status = "✅" if t["done"] else "•"
table.add_row(str(t["id"]), status, t["text"])
console.print(table)
@app.command()
def done(task_id: int = typer.Argument(..., help="Task ID to mark as done")):
"""Mark a task as done."""
tasks = load_tasks()
for t in tasks:
if t["id"] == task_id:
t["done"] = True
save_tasks(tasks)
console.print(f"๐ Marked task {task_id} as done")
return
raise typer.Exit(code=1)
@app.command()
def clear(confirm: bool = typer.Option(False, "--yes", help="Confirm clearing all tasks")):
"""Clear all tasks."""
if not confirm:
console.print("Pass --yes to confirm clearing all tasks."); raise typer.Exit(1)
save_tasks([]); console.print("๐งน Cleared tasks.")
def app_main():
app()
if __name__ == "__main__":
app_main()
Try it locally:
python -m pip install typer[all] rich
python -m tasker_cli add "Buy milk"
python -m tasker_cli list
python -m tasker_cli done 1
python -m tasker_cli list --no-show-done
6) Package & install with entry points
Use a modern pyproject.toml
with PEP 621 metadata and a console script entry. We’ll use hatchling as a lightweight build backend:
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "tasker-cli"
version = "0.1.0"
description = "Tiny to-do manager CLI built with Typer and Rich"
readme = "README.md"
requires-python = ">=3.9"
authors = [{ name = "Your Name" }]
dependencies = ["typer>=0.9", "rich>=13.0"]
[project.scripts]
tasker = "tasker_cli.__main__:app_main"
Install in editable mode while developing:
python -m pip install -e .
# Now just type:
tasker add "Write blog post"
tasker list
[project.scripts]
creates a platform-specific launcher named tasker
that calls tasker_cli.__main__:app_main
. No manual shebangs or PATH hacks needed.7) Testing CLIs
Use pytest
plus Typer’s test runner to simulate commands and capture output:
# tests/test_tasker.py
from pathlib import Path
from typer.testing import CliRunner
import os
from tasker_cli.__main__ import app
runner = CliRunner()
def test_add_and_list(tmp_path: Path, monkeypatch):
monkeypatch.setenv("TASKER_FILE", str(tmp_path / "tasks.json"))
r = runner.invoke(app, ["add", "Buy milk"])
assert r.exit_code == 0
r2 = runner.invoke(app, ["list", "--no-show-done"])
assert "Buy milk" in r2.stdout
python -m pip install pytest
pytest -q
8) Distribute with pipx & PyPI
Install & run globally with pipx
python -m pip install pipx
pipx install .
# or from a git URL:
pipx install git+https://github.com/you/tasker-cli
Publish to PyPI
python -m pip install build twine
python -m build # creates dist/*.tar.gz and *.whl
python -m twine upload dist/* # needs PyPI credentials
After publishing, users can install with:
pipx install tasker-cli
# or:
python -m pip install --user tasker-cli
9) Polish: logging, config, shell completion
Logging
import logging, os
level = os.getenv("TASKER_LOG", "WARNING").upper()
logging.basicConfig(level=getattr(logging, level, logging.WARNING))
logging.getLogger("tasker").info("starting up…")
Configuration
Prefer environment variables (12-factor style). For simple files, read from ~/.config/tasker/config.toml
on Linux/macOS or %APPDATA%\tasker\config.toml
on Windows.
Shell completion
Typer/Click can generate completions:
# Show completion script:
tasker --show-completion
# Install for your shell (Typer adds helpers):
tasker --install-completion
10) Common gotchas
- Windows paths: Use
pathlib.Path
, not hardcoded separators. - Unicode: Always read/write text files with UTF-8 (
Path(...).read_text()/write_text()
). - State location: Default to a user-writable path (
~
), not the current directory. - Long-running tasks: Show progress or logs; don’t print nothing for minutes.
- Exit codes: Return non-zero on failure (
raise typer.Exit(1)
) so scripts can react.
Wrap-up
You’ve seen the full path from a tiny argparse
script to a polished, installable CLI with Typer/Click, Rich tables, tests, and packaging. Start small, wire in entry points, then add ergonomics as you go. Happy hacking!