Command-line tools are the building blocks of DevOps automation. Every day you use tools like `git`, `docker`, `kubectl`, and `terraform`. In this lesson, you'll learn to build your own professional CLI tools that developers will love to use. We'll cover both Python's built-in `argparse` and the more powerful `Click` library.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Build CLI tools with argparse and Click
  • Handle positional and optional arguments
  • Create subcommands (like git commit, git push)
  • Add colors, progress bars, and interactive prompts
  • Package and distribute CLI tools
  • Create a production-ready deployment CLI

2. Why This Matters

Real-world scenario: Your team needs a tool to deploy applications, manage servers, or run database migrations. A well-designed CLI tool is the perfect solutionβ€”easy to automate, easy to document, and easy for teammates to use.

3. Core Concepts

Argparse: Python's Built-in Solution

import argparse

# Basic parser
parser = argparse.ArgumentParser(description='My CLI Tool')
parser.add_argument('name', help='Your name')
parser.add_argument('--age', type=int, help='Your age')
parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')

args = parser.parse_args()

print(f"Hello, {args.name}!")
if args.age:
    print(f"You are {args.age} years old")
if args.verbose:
    print("Verbose mode enabled")

# Advanced argument types
parser = argparse.ArgumentParser()

# Choices
parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], default='INFO')

# Multiple values
parser.add_argument('--files', nargs='+', help='List of files')

# Required flag
parser.add_argument('--api-key', required=True, help='API key is required')

# Mutually exclusive group
group = parser.add_mutually_exclusive_group()
group.add_argument('--json', action='store_true', help='Output as JSON')
group.add_argument('--yaml', action='store_true', help='Output as YAML')

# Subcommands
subparsers = parser.add_subparsers(dest='command', help='Subcommands')

# deploy subcommand
deploy_parser = subparsers.add_parser('deploy', help='Deploy application')
deploy_parser.add_argument('--environment', choices=['dev', 'staging', 'prod'], required=True)
deploy_parser.add_argument('--version', help='Version to deploy')

# status subcommand
status_parser = subparsers.add_parser('status', help='Get status')
status_parser.add_argument('--service', help='Service name')

# Parse and handle
args = parser.parse_args()

if args.command == 'deploy':
    print(f"Deploying to {args.environment}")
elif args.command == 'status':
    print(f"Checking status of {args.service or 'all services'}")
Argparse for CLI tools

Click: The Professional Choice

# Install Click
pip install click
Installation
import click

@click.command()
@click.argument('name')
@click.option('--age', type=int, help='Your age')
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
@click.option('--log-level', type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']), default='INFO')
@click.option('--count', default=1, help='Number of greetings')
def hello(name, age, verbose, log_level, count):
    """Simple CLI tool that says hello."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")
    if age:
        click.echo(f"You are {age} years old")
    if verbose:
        click.echo(f"Log level: {log_level}")

if __name__ == '__main__':
    hello()
Click basics
import click

# Colors and styling
@click.command()
def colors():
    click.echo(click.style('Success!', fg='green', bold=True))
    click.echo(click.style('Warning!', fg='yellow'))
    click.echo(click.style('Error!', fg='red', blink=True))
    click.echo(click.style('Info', fg='cyan', underline=True))

# Progress bar
@click.command()
@click.option('--count', default=100, help='Number of items')
def progress(count):
    with click.progressbar(range(count)) as bar:
        for _ in bar:
            pass  # Do work

# Interactive prompts
@click.command()
def prompts():
    name = click.prompt('Enter your name', type=str)
    password = click.prompt('Enter password', hide_input=True)
    confirm = click.confirm('Do you want to continue?', default=True)
    
    if confirm:
        click.echo(f"Hello, {name}!")

# Password confirmation
def password_prompt():
    while True:
        pwd = click.prompt('Password', hide_input=True)
        confirm = click.prompt('Confirm password', hide_input=True)
        if pwd == confirm:
            return pwd
        click.echo('Passwords do not match!')

# Spinner for long operations
@click.command()
def spinner():
    with click.progressbar(length=100, label='Processing') as bar:
        for _ in range(100):
            time.sleep(0.02)
            bar.update(1)
Click advanced features

Click Subcommands

import click

@click.group()
def cli():
    """DevOps CLI Tool"""
    pass

@cli.command()
@click.option('--environment', '-e', required=True, help='Environment to deploy')
@click.option('--version', '-v', help='Version to deploy')
def deploy(environment, version):
    """Deploy application to environment"""
    click.echo(f"Deploying to {environment}")
    if version:
        click.echo(f"Version: {version}")

@cli.command()
@click.argument('service', required=False)
def status(service):
    """Get service status"""
    if service:
        click.echo(f"Checking status of {service}")
    else:
        click.echo("Checking status of all services")

@cli.command()
@click.option('--from', 'from_env', help='Source environment')
@click.option('--to', help='Destination environment')
def backup(from_env, to):
    """Backup data between environments"""
    click.echo(f"Backing up from {from_env} to {to}")

@cli.command()
def logs():
    """Show application logs"""
    click.echo("Fetching logs...")

if __name__ == '__main__':
    cli()

# Usage:
# python cli.py deploy --environment production --version 1.2.3
# python cli.py status nginx
# python cli.py backup --from production --to staging
Click subcommands

4. Complete Project: DevOps CLI Tool

#!/usr/bin/env python3
"""
devops-cli.py - Production DevOps Command Line Tool
"""

import click
import subprocess
import json
import os
import sys
from pathlib import Path
from datetime import datetime

VERSION = "1.0.0"

# Color constants
SUCCESS = lambda x: click.style(x, fg='green', bold=True)
ERROR = lambda x: click.style(x, fg='red', bold=True)
WARNING = lambda x: click.style(x, fg='yellow')
INFO = lambda x: click.style(x, fg='cyan')


class Config:
    """Configuration management"""
    def __init__(self):
        self.config_file = Path.home() / '.devops-cli' / 'config.json'
        self.config = self.load()
    
    def load(self):
        if self.config_file.exists():
            with open(self.config_file) as f:
                return json.load(f)
        return {}
    
    def save(self):
        self.config_file.parent.mkdir(exist_ok=True)
        with open(self.config_file, 'w') as f:
            json.dump(self.config, f, indent=2)
    
    def get(self, key, default=None):
        return self.config.get(key, default)
    
    def set(self, key, value):
        self.config[key] = value
        self.save()


pass_config = click.make_pass_decorator(Config, ensure=True)


@click.group()
@click.version_option(version=VERSION)
@click.pass_context
def cli(ctx):
    """DevOps CLI Tool - Automate deployment, server management, and monitoring"""
    ctx.obj = Config()


@cli.group()
def deploy():
    """Deploy commands"""
    pass


@deploy.command()
@click.option('--environment', '-e', required=True, 
              type=click.Choice(['dev', 'staging', 'production']),
              help='Deployment environment')
@click.option('--version', '-v', help='Version to deploy')
@click.option('--skip-tests', is_flag=True, help='Skip pre-deployment tests')
@click.option('--dry-run', is_flag=True, help='Show what would happen')
@click.pass_context
def app(ctx, environment, version, skip_tests, dry_run):
    """Deploy application to environment"""
    click.echo(f"\n{INFO('πŸš€ Starting deployment...')}\n")
    
    if dry_run:
        click.echo(WARNING("DRY RUN - No changes will be made"))
        click.echo(f"  Environment: {environment}")
        click.echo(f"  Version: {version or 'latest'}")
        return
    
    # Run tests
    if not skip_tests:
        click.echo(INFO("Running pre-deployment tests..."))
        result = subprocess.run(['pytest', 'tests/'], capture_output=True, text=True)
        if result.returncode != 0:
            click.echo(ERROR("❌ Tests failed!"))
            click.echo(result.stdout[-500:])
            sys.exit(1)
        click.echo(SUCCESS("βœ… Tests passed"))
    
    # Simulate deployment
    with click.progressbar(length=100, label='Deploying') as bar:
        for i in range(100):
            bar.update(1)
    
    click.echo(SUCCESS(f"βœ… Successfully deployed to {environment}"))


@deploy.command()
@click.argument('service')
@click.option('--config', '-c', help='Config file path')
@click.pass_context
def service(ctx, service, config):
    """Deploy a specific service"""
    click.echo(f"Deploying service: {service}")
    if config:
        click.echo(f"Using config: {config}")


@cli.group()
def server():
    """Server management commands"""
    pass


@server.command()
@click.argument('host')
@click.option('--user', '-u', default='root', help='SSH user')
@click.option('--port', '-p', default=22, help='SSH port')
def ssh(host, user, port):
    """SSH into a server"""
    click.echo(f"Connecting to {user}@{host}:{port}")
    os.system(f'ssh {user}@{host} -p {port}')


@server.command()
@click.argument('host')
def status(host):
    """Check server status"""
    click.echo(f"Checking status of {host}...")
    
    try:
        # Check if server is reachable
        result = subprocess.run(['ping', '-c', '1', host], capture_output=True)
        if result.returncode == 0:
            click.echo(SUCCESS(f"  βœ… Server is reachable"))
        else:
            click.echo(ERROR(f"  ❌ Server unreachable"))
    except Exception:
        click.echo(ERROR(f"  ❌ Cannot reach {host}"))


@cli.group()
def logs():
    """Log management commands"""
    pass


@logs.command()
@click.argument('service')
@click.option('--lines', '-n', default=50, help='Number of lines')
@click.option('--follow', '-f', is_flag=True, help='Follow logs')
@click.option('--since', help='Show logs since timestamp')
def show(service, lines, follow, since):
    """Show logs for a service"""
    click.echo(f"Showing last {lines} lines from {service}")
    
    # Simulate log output
    for i in range(min(lines, 20)):
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        click.echo(f"[{timestamp}] {service}: Sample log line {i+1}")


@logs.command()
@click.argument('service')
@click.option('--pattern', '-p', help='Search pattern')
@click.option('--start', help='Start date')
@click.option('--end', help='End date')
def search(service, pattern, start, end):
    """Search logs for pattern"""
    click.echo(f"Searching {service} logs for '{pattern}'")
    if start:
        click.echo(f"  From: {start}")
    if end:
        click.echo(f"  To: {end}")


@cli.command()
@click.option('--format', '-f', type=click.Choice(['json', 'table']), default='table')
def status(format):
    """Show overall system status"""
    if format == 'json':
        data = {
            'services': ['web', 'api', 'db'],
            'status': 'healthy',
            'timestamp': datetime.now().isoformat()
        }
        click.echo(json.dumps(data, indent=2))
    else:
        click.echo("\nπŸ“Š SYSTEM STATUS")
        click.echo("="*40)
        click.echo(f"  {'Service':15} {'Status':12} {'Uptime':10}")
        click.echo("-"*40)
        click.echo(f"  {'web':15} {SUCCESS('βœ“ running'):12} {'2d 3h':10}")
        click.echo(f"  {'api':15} {SUCCESS('βœ“ running'):12} {'2d 3h':10}")
        click.echo(f"  {'db':15} {SUCCESS('βœ“ running'):12} {'2d 3h':10}")
        click.echo("="*40)


@cli.command()
@click.option('--environment', '-e', default='development', help='Environment')
@click.pass_context
def config(ctx, environment):
    """Show current configuration"""
    click.echo(f"\nπŸ“‹ Configuration ({environment})")
    click.echo("="*40)
    click.echo(f"  API URL: {ctx.obj.get('api_url', 'Not set')}")
    click.echo(f"  Log Level: {ctx.obj.get('log_level', 'INFO')}")


@cli.command()
@click.option('--key', help='Configuration key')
@click.option('--value', help='Configuration value')
@click.pass_context
def set_config(ctx, key, value):
    """Set configuration value"""
    if key and value:
        ctx.obj.set(key, value)
        click.echo(SUCCESS(f"βœ“ Set {key} = {value}"))
    else:
        click.echo(ERROR("Please provide both --key and --value"))


@cli.command()
def health():
    """Check system health"""
    click.echo("\nπŸ₯ HEALTH CHECK")
    click.echo("="*40)
    
    # Check disk space
    disk = subprocess.run(['df', '-h', '/'], capture_output=True, text=True)
    click.echo(f"  Disk: {disk.stdout.split()[11] if len(disk.stdout.split()) > 11 else 'N/A'}")
    
    # Check memory
    mem = subprocess.run(['free', '-h'], capture_output=True, text=True)
    mem_line = mem.stdout.split('\n')[1].split()
    click.echo(f"  Memory: {mem_line[2]} used / {mem_line[1]} total")
    
    # Check load
    load = subprocess.run(['uptime'], capture_output=True, text=True)
    click.echo(f"  Load: {load.stdout.split('load average:')[-1].strip()}")


@cli.command()
def init():
    """Initialize DevOps CLI configuration"""
    click.echo("πŸ”§ Initializing DevOps CLI...\n")
    
    api_url = click.prompt("Enter API URL", default="https://api.example.com")
    log_level = click.prompt("Log level", type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']), default='INFO')
    default_env = click.prompt("Default environment", type=click.Choice(['dev', 'staging', 'production']), default='dev')
    
    config = Config()
    config.set('api_url', api_url)
    config.set('log_level', log_level)
    config.set('default_env', default_env)
    
    click.echo(SUCCESS("\nβœ… Configuration saved!"))
    click.echo(f"  API URL: {api_url}")
    click.echo(f"  Log Level: {log_level}")
    click.echo(f"  Default Environment: {default_env}")


if __name__ == '__main__':
    cli()
Complete DevOps CLI tool

Packaging CLI Tools

# setup.py
from setuptools import setup, find_packages

setup(
    name='devops-cli',
    version='1.0.0',
    packages=find_packages(),
    install_requires=[
        'click>=8.0.0',
        'PyYAML>=5.0',
    ],
    entry_points={
        'console_scripts': [
            'devops=devops_cli.main:cli',
        ],
    },
    author='Your Name',
    description='DevOps CLI Tool',
    classifiers=[
        'Programming Language :: Python :: 3',
        'Operating System :: OS Independent',
    ],
)

# Install with pip
# pip install -e .

# After installation, use: devops --help
Packaging CLI tools for distribution

5. Summary Checklist

6. Next Steps

Next category: Docker - Containerization (Lessons 5.2-5.6)

You'll learn to:

  • Write production Dockerfiles
  • Use Docker Compose for multi-container apps
  • Manage Docker networking and volumes
  • Implement Docker security best practices

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen February 2, 2025

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment