Build Your Own Command Line Tool
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