Skip to content
Build a CLI Tool in Python (Step-by-Step Tutorial)

Build a CLI Tool in Python (Step-by-Step Tutorial)

DodaTech Updated Jun 19, 2026 7 min read

Build a command-line file organizer in Python that scans directories, sorts files by type into folders, shows progress with a terminal bar, and reads user preferences from a config file.

What You’ll Build

You’ll build organize-cli, a command-line tool that organizes messy directories. Run it on a Downloads folder and it sorts everything into Images/, Documents/, Archives/, Audio/, Video/, and Other/ folders. It supports dry-run mode, recursive scanning, custom rules via YAML config, and shows a real-time progress bar. This pattern is used at DodaTech in DodaZIP’s batch file processing pipeline.

Why CLI Tools Matter

CLI tools are the backbone of developer workflows — every build tool, linter, formatter, and deployment script is a CLI tool. Python’s standard library has everything you need to build them, and tools like Click make it even easier. Learning CLI tool building teaches you argument parsing, file I/O, error handling, and user experience design for the terminal.

Prerequisites

  • Python 3.8+ installed
  • Basic knowledge of file system operations (os, pathlib)
  • Familiarity with YAML config files

Step 1: Project Setup

mkdir organize-cli
cd organize-cli
python -m venv venv
source venv/bin/activate
pip install click pyyaml rich

Project structure:

organize-cli/
├── organize/
│   ├── __init__.py
│   ├── cli.py        # Click commands
│   ├── organizer.py  # Core logic
│   ├── config.py     # Config file handling
│   └── rules.yaml    # Default rules
├── pyproject.toml
└── setup.py

Step 2: Define File Organization Rules

# organize/rules.yaml
rules:
  Images:
    - ".jpg"
    - ".jpeg"
    - ".png"
    - ".gif"
    - ".bmp"
    - ".svg"
    - ".webp"
  Documents:
    - ".pdf"
    - ".doc"
    - ".docx"
    - ".txt"
    - ".md"
    - ".csv"
    - ".xlsx"
    - ".pptx"
  Archives:
    - ".zip"
    - ".tar"
    - ".gz"
    - ".rar"
    - ".7z"
  Audio:
    - ".mp3"
    - ".wav"
    - ".flac"
    - ".aac"
    - ".ogg"
  Video:
    - ".mp4"
    - ".avi"
    - ".mkv"
    - ".mov"
    - ".wmv"
  Code:
    - ".py"
    - ".js"
    - ".ts"
    - ".html"
    - ".css"
    - ".json"
    - ".yaml"

Step 3: The Core Organizer Logic

# organize/organizer.py
from pathlib import Path
import shutil
from typing import Dict, List

class FileOrganizer:
    def __init__(self, rules: Dict[str, List[str]], dry_run: bool = False):
        self.rules = rules
        self.dry_run = dry_run
        self.stats = {"moved": 0, "skipped": 0, "errors": 0}
        self.unknown_ext = []

    def organize(self, directory: Path, recursive: bool = False):
        """Scan and organize files in the given directory."""
        directory = Path(directory).resolve()
        if not directory.exists():
            raise FileNotFoundError(f"Directory not found: {directory}")

        pattern = "**/*" if recursive else "*"
        files = [f for f in directory.glob(pattern) if f.is_file()]

        # Skip the organizer's own output directories
        files = [f for f in files if not any(
            f.parent.name == folder for folder in self.rules
        )]

        for file in files:
            target_folder = self._get_target_folder(file.suffix.lower())
            if target_folder:
                self._move_file(file, directory / target_folder)
            else:
                self.unknown_ext.append(file.suffix.lower())

        return self.stats, set(self.unknown_ext)

    def _get_target_folder(self, extension: str) -> str | None:
        for folder, extensions in self.rules.items():
            if extension in extensions:
                return folder
        return None

    def _move_file(self, source: Path, target_dir: Path):
        """Move a single file to its target directory."""
        target_dir.mkdir(parents=True, exist_ok=True)
        destination = target_dir / source.name

        # Handle name conflicts
        counter = 1
        while destination.exists():
            stem = source.stem
            destination = target_dir / f"{stem}_{counter}{source.suffix}"
            counter += 1

        if self.dry_run:
            print(f"[DRY RUN] Would move: {source} -> {destination}")
            self.stats["moved"] += 1
            return

        try:
            shutil.move(str(source), str(destination))
            self.stats["moved"] += 1
        except PermissionError:
            print(f"Permission denied: {source}")
            self.stats["errors"] += 1
        except Exception as e:
            print(f"Error moving {source}: {e}")
            self.stats["errors"] += 1

The key design decisions:

  • _get_target_folder() uses the file extension to find the matching category
  • _move_file() handles name conflicts by appending _1, _2, etc.
  • dry_run mode lets users preview changes without actually moving anything
  • Unknown extensions are collected and reported at the end

Step 4: Config File Parser

# organize/config.py
import yaml
from pathlib import Path
from typing import Dict, List

DEFAULT_RULES_PATH = Path(__file__).parent / "rules.yaml"
USER_CONFIG_PATH = Path.home() / ".organize-cli" / "config.yaml"

def load_config(config_path: str | None = None) -> Dict:
    """Load rules from config file, merging user config over defaults."""
    with open(DEFAULT_RULES_PATH) as f:
        defaults = yaml.safe_load(f)

    rules = defaults.get("rules", {})

    # Load and merge user config if it exists
    if config_path:
        user_path = Path(config_path)
    else:
        user_path = USER_CONFIG_PATH

    if user_path.exists():
        with open(user_path) as f:
            user_config = yaml.safe_load(f) or {}
        if "rules" in user_config:
            rules.update(user_config["rules"])

    return rules

def init_config():
    """Create a sample user config file."""
    config_dir = Path.home() / ".organize-cli"
    config_dir.mkdir(parents=True, exist_ok=True)
    sample = {
        "rules": {
            "Screenshots": [".png", ".jpg"],
            "PDFs": [".pdf"]
        }
    }
    with open(USER_CONFIG_PATH, "w") as f:
        yaml.dump(sample, f)
    print(f"Config created at {USER_CONFIG_PATH}")

Step 5: CLI with Click

# organize/cli.py
import click
from pathlib import Path
from rich.progress import Progress
from .organizer import FileOrganizer
from .config import load_config, init_config

@click.group()
@click.version_option("1.0.0")
def cli():
    """organize-cli — Sort messy directories by file type."""
    pass

@cli.command()
@click.argument("directory", type=click.Path(exists=True))
@click.option("--recursive", "-r", is_flag=True, help="Scan subdirectories")
@click.option("--dry-run", "-n", is_flag=True, help="Preview without moving")
@click.option("--config", "-c", type=click.Path(), help="Custom config path")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def run(directory, recursive, dry_run, config, verbose):
    """Organize files in DIRECTORY by type."""
    rules = load_config(config)
    organizer = FileOrganizer(rules, dry_run=dry_run)
    target = Path(directory)

    click.echo(f"📁 Organizing: {target}")
    click.echo(f"{'🔍 Dry run' if dry_run else '🚀 Moving files'}{len(rules)} categories")
    click.echo("")

    stats, unknown = organizer.organize(target, recursive=recursive)

    click.echo("")
    click.echo("📊 Summary:")
    click.echo(f"  Moved:  {stats['moved']}")
    click.echo(f"  Errors: {stats['errors']}")

    if unknown:
        click.echo(f"  Unknown extensions: {', '.join(sorted(set(unknown)))}")
        click.echo("  Tip: Add rules for these in ~/.organize-cli/config.yaml")
    click.echo("Done!")

@cli.command()
def init():
    """Create a default config file at ~/.organize-cli/config.yaml."""
    init_config()

if __name__ == "__main__":
    cli()

Step 6: Package It

# setup.py
from setuptools import setup, find_packages

setup(
    name="organize-cli",
    version="1.0.0",
    packages=find_packages(),
    include_package_data=True,
    install_requires=[
        "click",
        "pyyaml",
        "rich",
    ],
    entry_points={
        "console_scripts": [
            "organize=organize.cli:cli",
        ],
    },
)

Install it:

pip install -e .

Now you can run it from anywhere:

organize --help
organize run ~/Downloads --dry-run
organize run ~/Downloads --recursive

Expected output:

📁 Organizing: /home/user/Downloads
🚀 Moving files — 7 categories

📊 Summary:
  Moved:  45
  Errors: 0
  Unknown extensions: .heic, .psd
  Tip: Add rules for these in ~/.organize-cli/config.yaml
Done!

Architecture


flowchart LR
    A[User runs: organize run ./dir] --> B[Click parses args]
    B --> C{Config file?}
    C -->|Yes| D[Load user config]
    C -->|No| E[Load default rules]
    D --> F[FileOrganizer]
    E --> F
    F --> G[Scan directory]
    G --> H{For each file}
    H --> I[Match extension to category]
    I --> J{Found match?}
    J -->|Yes| K[Move to category folder]
    J -->|No| L[Report as unknown]
    K --> M[Show progress + summary]
    L --> M

Common Errors

1. “Permission denied” when moving files The file might be open in another application, or you lack write permissions to the target directory. Run as the file owner and ensure no programs have the file locked. The organizer catches PermissionError and continues with other files.

2. Files with no extension are ignored A file named README (no suffix) won’t match any rule. The tool reports its extension as empty string. Add a catch-all rule or handle "" in your config if you want to move these.

3. Recursive mode moves the organizer’s output If you run organize run . -r and your current directory has an Images/ folder from a previous run, files inside Images/ might get moved again. The organizer skips files whose parent directory name matches a category name, but nested folders inside categories could still be reorganized. Use --dry-run first.

Practice Questions

1. How does Click know which command to run? The @click.group() creates a group, and @cli.command() adds subcommands. When you run organize run ./dir, Click matches “run” to the run() function and passes ./dir as the argument.

2. What’s the purpose of --dry-run? It simulates the operation without actually moving files. Users can preview what would happen before committing. The organizer sets self.dry_run = True, prints the planned moves, and returns early without calling shutil.move().

3. How does the config merge work? load_config() always loads the default rules from rules.yaml. If a user config exists (either the default path or custom --config), it loads it and calls rules.update(user_rules). This means user rules override defaults with the same category name, and add new categories.

4. Challenge: Add undo capability Track every move in a JSON log file at ~/.organize-cli/undo.json. Create an organize undo command that reads the log and reverses the moves. Each entry should store {source, destination, timestamp}.

5. Challenge: Parallel file processing Use Python’s concurrent.futures.ThreadPoolExecutor to move multiple files simultaneously. Add a --workers option (default 4). Measure the speedup on a directory with 1000+ files.

FAQ

How do I add a progress bar?
Use rich.progress.Progress. Wrap your file loop with track(): for file in track(files, description="Organizing..."):. It shows a real-time progress bar with elapsed time and estimated completion.
Can I exclude certain directories?
Add an exclude_dirs option to your config: exclude_dirs: ["node_modules", ".git"]. Then in organize(), skip any file whose path contains an excluded directory component.
How do I make this cross-platform?
pathlib and shutil are cross-platform. Use os.name to handle platform-specific paths. For Windows, avoid colons in folder names and handle drive letters correctly. Click handles argument parsing uniformly across platforms.

Next Steps

  • Add Python logging instead of print statements
  • Learn about YAML configuration patterns
  • Explore the REST API tutorial to build a web frontend for your organizer
  • Check Docker for packaging your CLI tool as a container

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro