DEV Community

Alex Spinov
Alex Spinov

Posted on

Build a CLI Tool in Python That People Actually Want to Use

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That single track() call adds a progress bar. That's it.

What Makes a CLI Great

  1. Colored output — errors in red, success in green, info in blue
  2. Progress indicators — show something is happening
  3. Helpful errors — "File not found" not "FileNotFoundError: [Errno 2]"
  4. Sensible defaults — it should work with zero flags
  5. --help that 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)