Build a Professional CLI Tool in Python — Complete Guide

By Kristy · March 2026 · 12 min read

Table of Contents

Why Build CLI Tools in Python?

CLI tools are the backbone of developer productivity. Whether you're automating deployments, processing data, or managing infrastructure, a well-built CLI saves hours of repetitive work. Python makes this surprisingly easy — and with the right patterns, your tool can feel as polished as any Go or Rust CLI.

What you'll build in this guide:

Step 1: Basic CLI with argparse

Start with Python's built-in argparse — no external dependencies needed:

#!/usr/bin/env python3
"""filemgr — A smart file management CLI tool."""

import argparse
import sys
from pathlib import Path


def create_parser():
    parser = argparse.ArgumentParser(
        prog="filemgr",
        description="Smart file management from the command line",
        epilog="Run '%(prog)s COMMAND --help' for command-specific help.",
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output",
    )
    parser.add_argument(
        "--version",
        action="version",
        version="%(prog)s 1.0.0",
    )
    return parser


def main():
    parser = create_parser()
    args = parser.parse_args()

    if args.verbose:
        print("Verbose mode enabled")

    # No subcommand given
    parser.print_help()
    return 0


if __name__ == "__main__":
    sys.exit(main())
💡 Tip: Always use sys.exit() with a return code. 0 = success, 1 = error, 2 = usage error. This matters for shell scripts and CI/CD pipelines.

Step 2: Subcommands

Real CLI tools use subcommands (git commit, docker build). Here's how:

def create_parser():
    parser = argparse.ArgumentParser(
        prog="filemgr",
        description="Smart file management CLI",
    )
    parser.add_argument("-v", "--verbose", action="store_true")
    parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # 'organize' subcommand
    organize = subparsers.add_parser("organize", help="Organize files by type")
    organize.add_argument("directory", type=Path, help="Directory to organize")
    organize.add_argument(
        "-d", "--dry-run",
        action="store_true",
        help="Show what would be done without doing it",
    )
    organize.add_argument(
        "--pattern",
        choices=["type", "date", "size"],
        default="type",
        help="Organization pattern (default: type)",
    )

    # 'find' subcommand
    find = subparsers.add_parser("find", help="Find files matching criteria")
    find.add_argument("pattern", help="Filename pattern (glob)")
    find.add_argument(
        "-d", "--directory",
        type=Path,
        default=Path("."),
        help="Search directory (default: current)",
    )
    find.add_argument(
        "--min-size",
        type=str,
        help="Minimum file size (e.g., 10MB, 1GB)",
    )
    find.add_argument(
        "--max-age",
        type=int,
        metavar="DAYS",
        help="Maximum file age in days",
    )

    # 'stats' subcommand
    stats = subparsers.add_parser("stats", help="Show directory statistics")
    stats.add_argument("directory", type=Path, nargs="?", default=Path("."))
    stats.add_argument("--json", action="store_true", help="Output as JSON")

    return parser


def main():
    parser = create_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 0

    commands = {
        "organize": cmd_organize,
        "find": cmd_find,
        "stats": cmd_stats,
    }

    return commands[args.command](args)

Step 3: Config File Support

Let users customize behavior without flags every time:

import tomllib  # Python 3.11+ (use tomli for older versions)
from dataclasses import dataclass, field


@dataclass
class Config:
    """Application configuration with sensible defaults."""
    verbose: bool = False
    default_pattern: str = "type"
    ignore_patterns: list[str] = field(
        default_factory=lambda: [".git", "__pycache__", "node_modules"]
    )
    max_depth: int = 10
    color: bool = True

    @classmethod
    def load(cls, path: Path | None = None) -> "Config":
        """Load config from TOML file, falling back to defaults."""
        search_paths = [
            path,
            Path(".filemgr.toml"),
            Path.home() / ".config" / "filemgr" / "config.toml",
        ]

        for p in search_paths:
            if p and p.exists():
                with open(p, "rb") as f:
                    data = tomllib.load(f)
                return cls(**{
                    k: v for k, v in data.items()
                    if k in cls.__dataclass_fields__
                })

        return cls()  # All defaults


# Example .filemgr.toml:
# verbose = false
# default_pattern = "type"
# ignore_patterns = [".git", "__pycache__", "node_modules", ".venv"]
# max_depth = 5
# color = true
💡 Config precedence: CLI flags > local config (.filemgr.toml) > user config (~/.config/filemgr/) > defaults. This is the standard pattern used by tools like git, ruff, and black.

Step 4: Rich Terminal Output

Make your CLI beautiful with the rich library:

from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text

console = Console()


def cmd_stats(args):
    """Show directory statistics with rich formatting."""
    directory = args.directory.resolve()

    if not directory.exists():
        console.print(f"[red]Error:[/red] Directory not found: {directory}")
        return 1

    # Collect stats
    stats = collect_dir_stats(directory)

    if args.json:
        import json
        print(json.dumps(stats, indent=2))
        return 0

    # Rich table output
    table = Table(
        title=f"📁 Directory Stats: {directory.name}",
        show_header=True,
        header_style="bold cyan",
    )
    table.add_column("File Type", style="bold")
    table.add_column("Count", justify="right")
    table.add_column("Total Size", justify="right", style="green")
    table.add_column("Largest", justify="right")

    for ext, info in sorted(stats["by_type"].items()):
        table.add_row(
            ext or "(no extension)",
            str(info["count"]),
            format_size(info["total_size"]),
            format_size(info["largest"]),
        )

    console.print(table)

    # Summary panel
    summary = Text()
    summary.append(f"Total files: ", style="bold")
    summary.append(f"{stats['total_files']}\n")
    summary.append(f"Total size: ", style="bold")
    summary.append(f"{format_size(stats['total_size'])}\n", style="green")
    summary.append(f"Directories: ", style="bold")
    summary.append(f"{stats['total_dirs']}")

    console.print(Panel(summary, title="Summary", border_style="blue"))
    return 0


def collect_dir_stats(directory: Path) -> dict:
    """Walk directory and collect file statistics."""
    stats = {
        "total_files": 0,
        "total_size": 0,
        "total_dirs": 0,
        "by_type": {},
    }

    for item in directory.rglob("*"):
        if item.is_dir():
            stats["total_dirs"] += 1
            continue

        stats["total_files"] += 1
        size = item.stat().st_size
        stats["total_size"] += size

        ext = item.suffix.lower() or "(none)"
        if ext not in stats["by_type"]:
            stats["by_type"][ext] = {
                "count": 0,
                "total_size": 0,
                "largest": 0,
            }

        type_stats = stats["by_type"][ext]
        type_stats["count"] += 1
        type_stats["total_size"] += size
        type_stats["largest"] = max(type_stats["largest"], size)

    return stats


def format_size(size: int) -> str:
    """Format bytes to human-readable size."""
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if size < 1024:
            return f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} PB"

Step 5: Progress Bars & Spinners

For long-running operations, show progress:

from rich.progress import (
    Progress, SpinnerColumn, TextColumn,
    BarColumn, TaskProgressColumn, TimeRemainingColumn,
)


def cmd_organize(args):
    """Organize files with progress tracking."""
    directory = args.directory.resolve()
    files = list(directory.iterdir())

    if not files:
        console.print("[yellow]No files to organize.[/yellow]")
        return 0

    # Progress bar for file operations
    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TaskProgressColumn(),
        TimeRemainingColumn(),
        console=console,
    ) as progress:
        task = progress.add_task("Organizing files...", total=len(files))

        moved = 0
        skipped = 0

        for file_path in files:
            if file_path.is_dir():
                progress.advance(task)
                skipped += 1
                continue

            dest_dir = get_destination(file_path, args.pattern)

            if args.dry_run:
                console.print(
                    f"  [dim]Would move:[/dim] {file_path.name} → {dest_dir}/",
                    highlight=False,
                )
            else:
                dest_dir.mkdir(exist_ok=True)
                file_path.rename(dest_dir / file_path.name)
                moved += 1

            progress.advance(task)

    # Summary
    action = "Would move" if args.dry_run else "Moved"
    console.print(
        f"\n[green]✓[/green] {action} {moved} files, skipped {skipped}"
    )
    return 0


def get_destination(file_path: Path, pattern: str) -> Path:
    """Determine destination directory based on pattern."""
    parent = file_path.parent

    if pattern == "type":
        ext = file_path.suffix.lower().lstrip(".")
        categories = {
            "images": {"jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"},
            "documents": {"pdf", "doc", "docx", "txt", "md", "rtf", "odt"},
            "code": {"py", "js", "ts", "go", "rs", "java", "c", "cpp", "h"},
            "data": {"csv", "json", "xml", "yaml", "yml", "toml", "sql"},
            "archives": {"zip", "tar", "gz", "bz2", "7z", "rar"},
            "media": {"mp4", "mp3", "wav", "avi", "mkv", "flac", "ogg"},
        }
        for category, extensions in categories.items():
            if ext in extensions:
                return parent / category
        return parent / "other"

    elif pattern == "date":
        from datetime import datetime
        mtime = file_path.stat().st_mtime
        date = datetime.fromtimestamp(mtime)
        return parent / date.strftime("%Y-%m")

    elif pattern == "size":
        size = file_path.stat().st_size
        if size < 1024 * 100:        # < 100KB
            return parent / "small"
        elif size < 1024 * 1024 * 10:  # < 10MB
            return parent / "medium"
        else:
            return parent / "large"

Step 6: Interactive Prompts

Sometimes you need user confirmation or input:

from rich.prompt import Prompt, Confirm


def cmd_cleanup(args):
    """Remove empty directories and duplicates."""
    directory = args.directory.resolve()

    # Find empty directories
    empty_dirs = [
        d for d in directory.rglob("*")
        if d.is_dir() and not any(d.iterdir())
    ]

    if not empty_dirs:
        console.print("[green]No empty directories found.[/green]")
        return 0

    console.print(f"\nFound [bold]{len(empty_dirs)}[/bold] empty directories:")
    for d in empty_dirs[:10]:
        console.print(f"  [dim]{d.relative_to(directory)}[/dim]")

    if len(empty_dirs) > 10:
        console.print(f"  ... and {len(empty_dirs) - 10} more")

    if not args.force:
        if not Confirm.ask("\nDelete these directories?", default=False):
            console.print("[yellow]Cancelled.[/yellow]")
            return 0

    for d in empty_dirs:
        d.rmdir()

    console.print(f"[green]✓ Removed {len(empty_dirs)} empty directories[/green]")
    return 0

Step 7: Error Handling

A professional CLI handles errors gracefully:

import traceback


class CLIError(Exception):
    """Base exception for CLI errors."""
    def __init__(self, message: str, exit_code: int = 1):
        self.message = message
        self.exit_code = exit_code
        super().__init__(message)


def main():
    parser = create_parser()
    args = parser.parse_args()

    try:
        if not args.command:
            parser.print_help()
            return 0

        config = Config.load()

        # Merge CLI flags with config
        if args.verbose:
            config.verbose = True

        commands = {
            "organize": cmd_organize,
            "find": cmd_find,
            "stats": cmd_stats,
        }

        return commands[args.command](args)

    except CLIError as e:
        console.print(f"[red]Error:[/red] {e.message}")
        return e.exit_code

    except KeyboardInterrupt:
        console.print("\n[yellow]Interrupted.[/yellow]")
        return 130  # Standard exit code for SIGINT

    except PermissionError as e:
        console.print(f"[red]Permission denied:[/red] {e.filename}")
        return 1

    except Exception as e:
        if args.verbose:
            console.print_exception()
        else:
            console.print(f"[red]Unexpected error:[/red] {e}")
            console.print("[dim]Run with --verbose for full traceback[/dim]")
        return 1
⚠️ Exit codes matter: Use 0 for success, 1 for general errors, 2 for usage errors, and 130 for keyboard interrupt. CI/CD systems and shell scripts rely on these.

Step 8: Testing Your CLI

Test CLI tools by capturing stdout and checking exit codes:

# test_cli.py
import subprocess
import json
from pathlib import Path
import pytest


def run_cli(*args) -> subprocess.CompletedProcess:
    """Helper to run the CLI and capture output."""
    return subprocess.run(
        ["python", "-m", "filemgr", *args],
        capture_output=True,
        text=True,
    )


class TestStats:
    def test_stats_current_dir(self):
        result = run_cli("stats", ".")
        assert result.returncode == 0
        assert "Total files" in result.stdout or "total_files" in result.stdout

    def test_stats_json(self):
        result = run_cli("stats", ".", "--json")
        assert result.returncode == 0
        data = json.loads(result.stdout)
        assert "total_files" in data
        assert "total_size" in data

    def test_stats_nonexistent(self):
        result = run_cli("stats", "/nonexistent/path")
        assert result.returncode == 1


class TestOrganize:
    def test_dry_run(self, tmp_path):
        # Create test files
        (tmp_path / "photo.jpg").touch()
        (tmp_path / "doc.pdf").touch()
        (tmp_path / "script.py").touch()

        result = run_cli("organize", str(tmp_path), "--dry-run")
        assert result.returncode == 0
        assert "Would move" in result.stdout

        # Files should NOT have moved
        assert (tmp_path / "photo.jpg").exists()

    def test_organize_by_type(self, tmp_path):
        (tmp_path / "photo.jpg").touch()
        (tmp_path / "doc.pdf").touch()

        result = run_cli("organize", str(tmp_path))
        assert result.returncode == 0

        assert (tmp_path / "images" / "photo.jpg").exists()
        assert (tmp_path / "documents" / "doc.pdf").exists()


class TestVersion:
    def test_version(self):
        result = run_cli("--version")
        assert "1.0.0" in result.stdout


# Run with: pytest test_cli.py -v

Step 9: Packaging with pyproject.toml

Make your tool installable via pip install:

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "filemgr"
version = "1.0.0"
description = "Smart file management from the command line"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
    { name = "Your Name", email = "you@example.com" },
]
dependencies = [
    "rich>=13.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "ruff>=0.1",
]

[project.scripts]
filemgr = "filemgr.cli:main"

[tool.ruff]
line-length = 88
target-version = "py311"

Project structure:

filemgr/
├── pyproject.toml
├── README.md
├── src/
│   └── filemgr/
│       ├── __init__.py
│       ├── cli.py        # Parser + main()
│       ├── commands/
│       │   ├── __init__.py
│       │   ├── organize.py
│       │   ├── find.py
│       │   └── stats.py
│       ├── config.py
│       └── utils.py
└── tests/
    ├── conftest.py
    └── test_cli.py

Install and use:

# Install in development mode
pip install -e ".[dev]"

# Now you can run it directly
filemgr stats .
filemgr organize ~/Downloads --dry-run
filemgr find "*.py" --min-size 10KB

# Build for distribution
python -m build
pip install dist/filemgr-1.0.0-py3-none-any.whl

Complete Example: File Manager CLI

Here's everything wired together in a single file you can start with:

#!/usr/bin/env python3
"""filemgr — Smart file management CLI.

Usage:
    filemgr organize <directory> [--dry-run] [--pattern type|date|size]
    filemgr find <pattern> [-d directory] [--min-size SIZE] [--max-age DAYS]
    filemgr stats [directory] [--json]
    filemgr --version
"""

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path

try:
    from rich.console import Console
    from rich.table import Table
    from rich.panel import Panel
    from rich.progress import Progress, SpinnerColumn
    RICH = True
except ImportError:
    RICH = False

__version__ = "1.0.0"

console = Console() if RICH else None


def print_msg(msg: str, style: str = ""):
    """Print with rich if available, plain otherwise."""
    if RICH:
        console.print(f"[{style}]{msg}[/{style}]" if style else msg)
    else:
        print(msg)


def format_size(size: int) -> str:
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if size < 1024:
            return f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} PB"


def parse_size(size_str: str) -> int:
    """Parse human-readable size like '10MB' to bytes."""
    units = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3}
    size_str = size_str.strip().upper()
    for unit, multiplier in sorted(units.items(), key=lambda x: -len(x[0])):
        if size_str.endswith(unit):
            return int(float(size_str[:-len(unit)]) * multiplier)
    return int(size_str)


def cmd_stats(args) -> int:
    directory = Path(args.directory).resolve()
    if not directory.exists():
        print_msg(f"Error: {directory} not found", "red")
        return 1

    by_type = {}
    total_files = total_size = total_dirs = 0

    for item in directory.rglob("*"):
        if item.is_dir():
            total_dirs += 1
            continue
        total_files += 1
        size = item.stat().st_size
        total_size += size
        ext = item.suffix.lower() or "(none)"
        entry = by_type.setdefault(ext, {"count": 0, "size": 0, "largest": 0})
        entry["count"] += 1
        entry["size"] += size
        entry["largest"] = max(entry["largest"], size)

    if getattr(args, "json", False):
        print(json.dumps({
            "total_files": total_files,
            "total_size": total_size,
            "total_dirs": total_dirs,
            "by_type": by_type,
        }, indent=2))
    elif RICH:
        table = Table(title=f"📁 {directory.name}")
        table.add_column("Type", style="bold")
        table.add_column("Count", justify="right")
        table.add_column("Size", justify="right", style="green")
        for ext, info in sorted(by_type.items(), key=lambda x: -x[1]["size"]):
            table.add_row(ext, str(info["count"]), format_size(info["size"]))
        console.print(table)
        console.print(f"\n[bold]{total_files}[/bold] files, "
                      f"[green]{format_size(total_size)}[/green], "
                      f"{total_dirs} dirs")
    else:
        print(f"Files: {total_files}, Size: {format_size(total_size)}, "
              f"Dirs: {total_dirs}")
    return 0


def cmd_find(args) -> int:
    directory = Path(args.directory).resolve()
    min_size = parse_size(args.min_size) if args.min_size else 0
    max_age_ts = (
        datetime.now().timestamp() - (args.max_age * 86400)
        if args.max_age else 0
    )
    found = 0
    for f in directory.rglob(args.pattern):
        if f.is_dir():
            continue
        stat = f.stat()
        if stat.st_size < min_size:
            continue
        if max_age_ts and stat.st_mtime < max_age_ts:
            continue
        rel = f.relative_to(directory)
        print_msg(f"  {rel}  ({format_size(stat.st_size)})")
        found += 1
    print_msg(f"\n{found} file(s) found", "bold")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(prog="filemgr")
    parser.add_argument("-v", "--verbose", action="store_true")
    parser.add_argument("--version", action="version", version=__version__)
    sub = parser.add_subparsers(dest="command")

    org = sub.add_parser("organize")
    org.add_argument("directory", type=Path)
    org.add_argument("-d", "--dry-run", action="store_true")
    org.add_argument("--pattern", choices=["type", "date", "size"], default="type")

    fnd = sub.add_parser("find")
    fnd.add_argument("pattern")
    fnd.add_argument("-d", "--directory", default=".")
    fnd.add_argument("--min-size", type=str)
    fnd.add_argument("--max-age", type=int)

    sts = sub.add_parser("stats")
    sts.add_argument("directory", nargs="?", default=".")
    sts.add_argument("--json", action="store_true")

    args = parser.parse_args()
    try:
        if not args.command:
            parser.print_help()
            return 0
        return {"stats": cmd_stats, "find": cmd_find}[args.command](args)
    except KeyboardInterrupt:
        print("\nInterrupted.")
        return 130
    except Exception as e:
        print_msg(f"Error: {e}", "red")
        if args.verbose:
            import traceback; traceback.print_exc()
        return 1


if __name__ == "__main__":
    sys.exit(main())

🚀 Want 50+ Production-Ready Python Scripts?

CLI tools, scrapers, data processors, automation workflows, AI integrations — all tested and ready to use.

Get the Full Toolkit — $19