I've built 30+ CLI tools. Most of them sucked.
Not because the code was bad — because the UX was terrible. No colors. No progress bars. Cryptic error messages. The kind of tools where you type --help and get a wall of text that helps nobody.
Then I discovered a stack that changed everything. Here's how to build CLIs that developers actually enjoy using.
The Stack
- Typer — CLI framework (from the FastAPI creator)
- Rich — Beautiful terminal output
- httpx — Async HTTP client
pip install typer rich httpx
That's it. Three packages. Let's build something real.
What We're Building
A CLI tool that checks if your project's dependencies have known vulnerabilities. Like npm audit but for any language.
$ vulncheck ./requirements.txt
┌─────────────────┬──────────┬────────────────────────┐
│ Package │ Severity │ Fix │
├─────────────────┼──────────┼────────────────────────┤
│ requests 2.28.0 │ HIGH │ Upgrade to >= 2.31.0 │
│ flask 2.2.0 │ MEDIUM │ Upgrade to >= 2.3.2 │
│ pillow 9.0.0 │ CRITICAL │ Upgrade to >= 10.0.1 │
└─────────────────┴──────────┴────────────────────────┘
3 vulnerabilities found (1 critical, 1 high, 1 medium)
Step 1: The Skeleton
import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track
from pathlib import Path
app = typer.Typer(help="Check dependencies for known vulnerabilities")
console = Console()
@app.command()
def check(
filepath: Path = typer.Argument(..., help="Path to requirements file"),
severity: str = typer.Option("all", help="Filter by severity: low/medium/high/critical"),
output: str = typer.Option("table", help="Output format: table/json/csv")
):
"""Scan dependencies for known vulnerabilities."""
if not filepath.exists():
console.print(f"[red]Error:[/red] File {filepath} not found")
raise typer.Exit(1)
console.print(f"[blue]Scanning[/blue] {filepath}...")
packages = parse_requirements(filepath)
vulns = scan_packages(packages)
display_results(vulns, output)
if __name__ == "__main__":
app()
Why Typer? Type hints become CLI arguments automatically. No decorators, no argparse boilerplate.
Step 2: Parse Requirements
import re
def parse_requirements(filepath: Path) -> list[dict]:
packages = []
for line in filepath.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
match = re.match(r'^([a-zA-Z0-9_-]+)([=<>!]+)?(.+)?$', line)
if match:
packages.append({
'name': match.group(1).lower(),
'version': (match.group(3) or 'latest').strip(),
})
return packages
Step 3: Check Vulnerabilities (Free API)
import httpx
async def check_vuln(client, package: dict) -> list[dict]:
url = f"https://pypi.org/pypi/{package['name']}/json"
try:
resp = await client.get(url, timeout=10)
if resp.status_code == 200:
data = resp.json()
vulns = data.get('vulnerabilities', [])
return [{'package': package['name'],
'version': package['version'],
**v} for v in vulns]
except httpx.TimeoutException:
return []
return []
def scan_packages(packages: list[dict]) -> list[dict]:
import asyncio
async def scan_all():
async with httpx.AsyncClient() as client:
tasks = [check_vuln(client, p) for p in packages]
results = await asyncio.gather(*tasks)
return [v for sublist in results for v in sublist]
vulns = asyncio.run(scan_all())
return vulns
Step 4: Beautiful Output with Rich
def display_results(vulns: list[dict], output_format: str):
if not vulns:
console.print("[green]✓ No vulnerabilities found![/green]")
return
if output_format == "table":
table = Table(title="Vulnerability Report")
table.add_column("Package", style="cyan")
table.add_column("Severity", style="bold")
table.add_column("Fix")
severity_colors = {
'critical': 'red',
'high': 'yellow',
'medium': 'blue',
'low': 'green'
}
for v in vulns:
sev = v.get('severity', 'unknown').lower()
color = severity_colors.get(sev, 'white')
table.add_row(
f"{v['package']} {v['version']}",
f"[{color}]{sev.upper()}[/{color}]",
v.get('fix', 'No fix available')
)
console.print(table)
console.print(f"\n[bold]{len(vulns)} vulnerabilities found[/bold]")
elif output_format == "json":
import json
console.print_json(json.dumps(vulns, indent=2))
The Secret Sauce: Progress Bars
Users hate staring at a blank terminal. Rich makes progress trivial:
def scan_with_progress(packages):
results = []
for package in track(packages, description="Scanning..."):
vulns = check_single_package(package)
results.extend(vulns)
return results
That single track() call adds a progress bar. That's it.
What Makes a CLI Great
- Colored output — errors in red, success in green, info in blue
- Progress indicators — show something is happening
- Helpful errors — "File not found" not "FileNotFoundError: [Errno 2]"
- Sensible defaults — it should work with zero flags
-
--helpthat helps — examples, not just parameter lists
Full Source
Grab the complete tool: python-security-tools on GitHub
For production-grade scraping and data tools, check my Apify actors — ready to deploy in seconds.
What CLI tool do you wish existed? I'm always looking for ideas for the next build. 👇
190+ open source repos at github.com/Spinov001
Top comments (0)