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!