← Back to Tutorials
Python

Build Your Own Command Line Tool

Difficulty: Intermediate Est. Time: ~3 hours

Introduction

Command line tools are powerful utilities that help developers automate tasks, manage files, and streamline workflows. In this tutorial, we'll build a versatile CLI tool from scratch.

We'll create "DevTool" - a developer utility that can manage files, search code, replace text, and more.

What You'll Build
  • A fully-featured CLI application
  • Subcommands for different operations
  • File search and manipulation tools
  • Interactive mode
  • Configuration system
What You'll Learn
  • CLI design patterns
  • Argument parsing with argparse
  • File system operations
  • Interactive user input
  • Python packaging

Core Concepts

Let's understand the key components of CLI tools.

CLI Architecture

  • Entry Point - The main script that runs
  • Arguments - User input passed to the program
  • Options/Flags - Named parameters that modify behavior
  • Subcommands - Grouped commands for related operations
  • Exit Codes - Return values indicating success/failure

Design Principles

  • Clear, consistent command syntax
  • Helpful error messages
  • Verbose and quiet modes
  • Unix philosophy - do one thing well

Project Setup

Bash
# Create project directory
mkdir devtool
cd devtool

# Create virtual environment
python -m venv venv

# Activate
# Windows:
venv\Scripts\activate
# Mac/Linux:
source venv/bin/activate

# Install dependencies
pip install click colorama tqdm

Project Structure

File Structure
devtool/
├── devtool/
│   ├── __init__.py
│   ├── cli.py
│   ├── commands/
│   │   ├── __init__.py
│   │   ├── search.py
│   │   ├── file_ops.py
│   │   └── text_ops.py
│   └── utils/
│       ├── __init__.py
│       └── helpers.py
├── setup.py
├── README.md
└── requirements.txt

Argument Parsing

Let's create the main CLI interface using Click.

Python
# devtool/cli.py
import click
from colorama import Fore, Style, init
import sys
from .commands import search, file_ops, text_ops

init(autoreset=True)

class CLI:
    __init__(self):
        self.verbose = False
        self.quiet = False
    
    log(self, message, level='info'):
        if self.quiet and level != 'error':
            return
        
        colors = {
            'info': Fore.CYAN,
            'success': Fore.GREEN,
            'warning': Fore.YELLOW,
            'error': Fore.RED
        }
        
        color = colors.get(level, '')
        prefix = '' if level == 'info' else f"[${level.upper()}] "
        
        print(f"${color}${prefix}${message}${Style.RESET_ALL}")
    
    run(self, args=None):
        try:
            self.main(args)
        except KeyboardInterrupt:
            self.log("\nAborted", 'warning')
            sys.exit(130)
        except Exception as e:
            if self.verbose:
                raise
            self.log(str(e), 'error')
            sys.exit(1)


@click.group()
@click.option('-v', '--verbose', is_flag=True, help="Verbose output")
@click.option('-q', '--quiet', is_flag=True, help="Quiet mode")
@click.pass_context
def cli(ctx, verbose, quiet):
    """DevTool - A developer utility CLI"""
    ctx.ensure_object(dict)
    ctx.obj['verbose'] = verbose
    ctx.obj['quiet'] = quiet


# Register commands
cli.add_command(search.search)
cli.add_command(file_ops.find)
cli.add_command(file_ops.list_files)
cli.add_command(text_ops.replace)


if __name__ == '__main__':
    cli()

Command Structure

Let's create the search command as an example.

Python
# devtool/commands/search.py
import click
from colorama import Fore
import os
import re
from pathlib import Path

@click.command
@click.argument('pattern')
@click.option('-p', '--path', default='.', help="Search path")
@click.option('-i', '--ignore-case', is_flag=True, help="Case insensitive")
@click.option('-r', '--regex', is_flag=True, help="Use regex")
@click.option('-t', '--type', help="File type filter (e.g., .py, .js)")
@click.option('-n', '--name-only', is_flag=True, help="Search file names only")
@click.option('-l', '--limit', default=100, help="Max results")
@click.pass_context
def search(ctx, pattern, path, ignore_case, regex, type, name_only, limit):
    """Search for a pattern in files or file names"""
    
    verbose = ctx.get('verbose')
    quiet = ctx.get('quiet')
    
    if not quiet:
        click.echo(Fore.CYAN + f"Searching for '${pattern}' in ${path}...")
    
    flags = 0
    if ignore_case:
        flags |= re.IGNORECASE
    
    search_regex = re.compile(pattern, flags) if regex else None
    
    results = []
    count = 0
    
    path_obj = Path(path)
    if not path_obj.exists():
        click.echo(Fore.RED + f"Path does not exist: ${path}")
        return
    
    for file_path in path_obj.rglob(f'*{type or "*"}'):
        if not file_path.is_file():
            continue
        
        if count >= limit:
            break
        
        try:
            if name_only:
                if search_regex:
                    matches = search_regex.search(file_path.name)
                else:
                    matches = pattern.lower() in file_path.name.lower()
                
                if matches:
                    results.append(str(file_path))
                    count += 1
            else:
                try:
                    content = file_path.read_text(encoding='utf-8', errors='ignore')
                    
                    if search_regex:
                        matches = search_regex.findall(content)
                    else:
                        matches = [line for line in content.splitlines() 
                                  if pattern.lower() in line.lower()]
                    
                    if matches:
                        results.append({
                            'file': str(file_path),
                            'matches': len(matches)
                        })
                        count += 1
                except Exception:
                    continue
        except Exception:
            continue
    
    if results:
        for result in results:
            if isinstance(result, dict):
                click.echo(Fore.GREEN + f"${result['file']}" + 
                          Fore.WHITE + f" (${result['matches']} matches)")
            else:
                click.echo(Fore.GREEN + result)
    else:
        click.echo(Fore.YELLOW + "No results found")
    
    if not quiet:
        click.echo(Fore.CYAN + f"Found ${count} results")

File Operations

Let's add file manipulation commands.

Python
# devtool/commands/file_ops.py
import click
from colorama import Fore
import os
from pathlib import Path
from datetime import datetime

@click.command('find')
@click.argument('path', default='.')
@click.option('-n', '--name', help="Find by name pattern")
@click.option('-t', '--type', type=click.Choice(['f', 'd']), 
                  help="Type: f=file, d=directory")
@click.option('-s', '--size', help="Size filter (e.g., +1M, -100k)")
@click.option('-m', '--modified', help="Modified within days")
@click.option('-e', '--exec', help="Execute command on results")
def find(path, name, type, size, modified, exec):
    """Find files and directories with various filters"""
    
    path_obj = Path(path)
    if not path_obj.exists():
        click.echo(Fore.RED + f"Path not found: ${path}")
        return
    
    results = []
    
    for item in path_obj.rglob('*'):
        if name and name.lower() not in item.name.lower():
            continue
        
        if type == 'f' and not item.is_file():
            continue
        if type == 'd' and not item.is_dir():
            continue
        
        if modified:
            try:
                days = int(modified)
                mtime = datetime.fromtimestamp(item.stat().st_mtime)
                if (datetime.now() - mtime).days > days:
                    continue
            except ValueError:
                pass
        
        if size:
            try:
                size_str = size[1:]
                unit = size_str[-1].upper()
                size_val = float(size_str[:-1])
                
                multipliers = {'K': 1024, 'M': 1024**2, 'G': 1024**3}
                target_size = size_val * multipliers.get(unit, 1)
                
                file_size = item.stat().st_size
                
                if size[0] == '+' and file_size < target_size:
                    continue
                if size[0] == '-' and file_size > target_size:
                    continue
            except (ValueError, IndexError):
                pass
        
        results.append(item)
    
    for result in results:
        if exec:
            os.system(f"${exec} \"${result}\"")
        else:
            icon = '📁' if result.is_dir() else '📄'
            click.echo(f"${icon} ${result}")
    
    click.echo(Fore.CYAN + f"Found ${len(results)} items")


@click.command('ls')
@click.argument('path', default='.')
@click.option('-l', '--long', is_flag=True, help="Long format")
@click.option('-a', '--all', is_flag=True, help="Show hidden files")
@click.option('-R', '--recursive', is_flag=True, help="Recursive listing")
def list_files(path, long, all, recursive):
    """List files in a directory"""
    
    path_obj = Path(path)
    
    def print_entry(item, prefix=''):
        if long:
            stat = item.stat()
            size = stat.st_size
            mtime = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M')
            icon = 'd' if item.is_dir() else '-'
            click.echo(f"${icon} ${size:>10} ${mtime} ${item.name}")
        else:
            icon = '📁' if item.is_dir() else '📄'
            click.echo(f"${prefix}${icon} ${item.name}")
    
    items = []
    for item in path_obj.iterdir():
        if not all and item.name.startswith('.'):
            continue
        items.append(item)
    
    items.sort(key=lambda x: (not x.is_dir(), x.name))
    
    for item in items:
        print_entry(item)
        
        if recursive and item.is_dir():
            click.echo(Fore.CYAN + f"\n${item.name}/:")
            list_files(item.name, long, all, recursive)

Interactive Features

Let's add interactive input handling.

Python
# devtool/commands/interactive.py
import click
from colorama import Fore, Style

def confirm(message: str, default: bool = False) -> bool:
    """Ask for user confirmation"""
    suffix = '[Y/n]' if default else '[y/N]'
    response = click.prompt(
        Fore.CYAN + message + ' ' + suffix,
        type=bool,
        default=default
    )
    return response


def prompt_choice(message: str, choices: list, default: int = 0) -> str:
    """Prompt user to choose from a list"""
    click.echo(Fore.CYAN + message)
    
    for i, choice in enumerate(choices):
        prefix = '> ' if i == default else '  '
        click.echo(f"${prefix}${i + 1}. ${choice}")
    
    selection = click.prompt(
        '\nEnter your choice',
        type=int,
        default=default + 1,
        show_default=False
    )
    
    return choices[selection - 1]


def prompt_password(message: str) -> str:
    """Prompt for password input (hidden)"""
    return click.prompt(
        Fore.CYAN + message,
        type=str,
        hide_input=True
    )


def prompt_editor(message: str, default: str = '') -> str:
    """Open editor for multi-line input"""
    return click.edit(
        default,
        require_save=False,
        extension='.txt'
    ) or default


class ProgressBar:
    __init__(self, total: int, description: str = ''):
        self.bar = click.progressbar(
            length=total,
            label=Fore.CYAN + description,
            show_eta=True,
            bar_template='%(label)s %(bar)s | %(info)s'
        )
        self.total = total
        self.current = 0
    
    __enter__(self):
        return self
    
    __exit__(self, exc_type, exc_val, exc_tb):
        self.bar.render_finish()
    
    update(self, n: int = 1):
        self.bar.update(n)
        self.current += n
    
    set_description(self, description: str):
        self.bar.label = Fore.CYAN + description

Packaging & Distribution

Let's set up the package for distribution.

Python
# setup.py
from setuptools import setup, find_packages

with open("README.md", "r", encoding="utf-8") as fh:
    long_description = fh.read()

with open("requirements.txt", "r", encoding="utf-8") as fh:
    requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")]

setup(
    name="devtool",
    version="1.0.0",
    author="Your Name",
    author_email="you@example.com",
    description="A developer utility CLI tool",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/yourusername/devtool",
    packages=find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: 3.10",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
    python_requires=">=3.8",
    install_requires=requirements,
    entry_points={
        'console_scripts': [
            'devtool=devtool.cli:cli',
        ],
    },
    include_package_data=True,
    package_data={
        'devtool': ['**/*.txt', '**/*.md'],
    },
)
Bash
# Build and install

# Install locally
pip install -e .

# Build package
python setup.py sdist bdist_wheel

# Upload to PyPI (requires account)
pip install twine
twine upload dist/*

# Install from PyPI
pip install devtool

# Usage
devtool search "pattern" -p ./src
devtool find . -n "*.py"
devtool ls -l ./src
Best Practices
  • Always include help text for commands
  • Use exit codes appropriately
  • Handle errors gracefully
  • Provide verbose and quiet modes

Testing

Bash
# Test the CLI

# Search command
devtool search "def " -p ./devtool --ignore-case

# Find command
devtool find . -n "*.py" -t f

# List files
devtool ls -l ./devtool

# Get help
devtool --help
devtool search --help
Testing Checklist
  • CLI starts without errors
  • All commands work correctly
  • Help messages display properly
  • Package installs correctly

Summary

Congratulations! You've built a complete command line tool.

What You Built

  • CLI Framework - Click-based command structure
  • Search Command - Pattern searching in files
  • Find Command - File and directory filtering
  • List Command - Directory listing
  • Interactive Features - User prompts and progress bars
  • Package Setup - Ready for distribution

Next Steps

  • Add more commands (git helpers, docker tools)
  • Implement configuration files
  • Add auto-completion
  • Create plugins system

Continue Learning

Try these tutorials:

  • Build a Password Manager
  • Build a Web Scraper
  • Build a Log Monitoring System