All Posts Automation

Building Custom CLI Tools for IT Operations (Python + Click)

You have a Python script that checks server health. Another one that rotates log files. They live in a folder called 'scripts' on your laptop, and nobody else on your team can use them.

Custom CLI tools built with Python and Click transform scattered one-off scripts into professional command-line applications with automatic help text, argument validation, and pip-installable packaging — turning commands like editing line 7 of a script file into running opsctl server check prod-01 --checks disk with structured output and clear error messages. The Click library handles input validation, help generation, and multi-command hierarchies, letting your team share tools that work identically on every machine.

You have a Python script that checks server health. Another one that rotates log files. A third that syncs data between two systems. They live in a folder called “scripts” on your laptop, they take arguments through hardcoded variables that you edit before running, and nobody else on your team can use them because there’s no documentation, no help text, and no argument validation. You wrote them to solve a problem. They solved the problem. Then they became problems themselves.

Custom CLI tools built with Python and Click transform these one-off scripts into professional command-line applications with automatic help text, argument validation, error handling, and pip-installable packaging. Instead of editing variables in a script file, your team runs commands like opsctl server check --host prod-01 and gets structured output with clear error messages when something goes wrong.

In this guide, I’ll walk you through building a complete IT operations CLI tool from scratch — from project scaffolding to packaging and distribution. By the end, you’ll have a tool you can install with pip and share with your team.

Why Scripts Aren’t Enough

Let me be specific about what goes wrong with bare scripts. A typical operations team might have forty-seven Python files in a shared drive folder. No documentation. No consistent naming. No argument handling. To check server health, you run python check_server.py after editing line 7 to change the hostname. To generate a report, you run python report_gen.py after editing lines 3, 8, and 14 to set the date range and output path.

Three problems emerge from this approach. First, discoverability — nobody knows what scripts exist or what they do without reading the source code. There’s no --help flag. When a new team member joins, they have to be shown each script individually. When the person who wrote the scripts leaves, the knowledge leaves with them.

Second, safety — there’s no input validation. If you forget to change the hostname on line 7 and accidentally run the script against production instead of staging, nothing stops you. There’s no confirmation prompt, no type checking, no bounds checking. The script does whatever you told it to, even when what you told it is wrong.

Third, portability — the scripts depend on whatever Python packages happen to be installed on the person’s laptop. They might use different Python versions. Moving them to another computer involves a chain of “oh, you also need to install…” conversations.

Click solves all three problems. Help text makes tools discoverable. Decorators validate inputs before your code runs. Packaging with pyproject.toml handles dependencies and installation.

Getting Started: Project Structure

Here’s the project structure for a CLI tool called opsctl — a multi-command tool for IT operations:

opsctl/
├── pyproject.toml
├── README.md
├── src/
│   └── opsctl/
│       ├── __init__.py
│       ├── cli.py          # Main CLI entry point
│       ├── commands/
│       │   ├── __init__.py
│       │   ├── server.py   # Server management commands
│       │   ├── backup.py   # Backup commands
│       │   └── network.py  # Network diagnostic commands
│       └── utils/
│           ├── __init__.py
│           ├── output.py   # Formatted output helpers
│           └── config.py   # Configuration handling

The src/ layout is the modern Python packaging convention. It prevents import confusion between your installed package and your development directory. The commands/ subdirectory keeps each command group in its own file, which makes the codebase maintainable as you add more commands.

Step 1: Define Your pyproject.toml

The pyproject.toml file tells pip how to build and install your tool:

[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "opsctl"
version = "0.1.0"
description = "CLI toolkit for IT operations management"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
    {name = "Your Name", email = "[email protected]"}
]

dependencies = [
    "click>=8.1",
    "rich>=13.0",
    "requests>=2.31",
    "pyyaml>=6.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
]

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

[tool.setuptools.packages.find]
where = ["src"]

The critical line is [project.scripts]. This tells pip to create a command called opsctl that calls the main function in opsctl.cli. After installation, your team types opsctl at the command line and it just works — no python path/to/script.py needed. The command is on their PATH like any other system tool.

The dependencies list ensures that Click, Rich (for colored output), requests, and PyYAML are automatically installed when someone installs your tool. No more “did you install the requests library?” conversations.

Step 2: Build the CLI Entry Point

This is where Click shines. The main CLI file defines the top-level command group and imports subcommands:

# src/opsctl/cli.py
"""opsctl - CLI toolkit for IT operations management."""



from opsctl.commands import server, backup, network


@click.group()
@click.version_option(version="0.1.0", prog_name="opsctl")
@click.option(
    "--config", "-c",
    type=click.Path(exists=True),
    envvar="OPSCTL_CONFIG",
    help="Path to configuration file."
)
@click.pass_context
def main(ctx, config):
    """IT Operations CLI Toolkit.

    Manage servers, backups, and network diagnostics from the command line.
    Run 'opsctl COMMAND --help' for details on each command.
    """
    ctx.ensure_object(dict)
    ctx.obj["config_path"] = config


# Register command groups
main.add_command(server.server)
main.add_command(backup.backup)
main.add_command(network.network)


if __name__ == "__main__":
    main()

The @click.group() decorator makes this a multi-command CLI — like git, which has git commit, git push, git log as subcommands. Your tool has opsctl server, opsctl backup, opsctl network as subcommand groups, each with their own subcommands underneath.

The @click.pass_context decorator passes a context object between commands. This is how global options (like the config file path) are shared with subcommands. The context flows down the command hierarchy, so every subcommand can access the configuration without having to re-declare the option.

When your team runs opsctl --help, Click automatically generates this output:

Usage: opsctl [OPTIONS] COMMAND [ARGS]...

  IT Operations CLI Toolkit.

  Manage servers, backups, and network diagnostics from the command line.
  Run 'opsctl COMMAND --help' for details on each command.

Options:
  --version          Show the version and exit.
  -c, --config PATH  Path to configuration file.
  --help             Show this message and exit.

Commands:
  backup   Manage backup operations.
  network  Network diagnostic tools.
  server   Server management commands.

No documentation to write. No README to maintain. The help text is generated from your code. When you add a new command, the help updates automatically.

Step 3: Build a Command Group

Here’s the server command group — the most common type of IT operations tool. It checks server health, lists servers, and restarts services: Our guide to Small Business CRM Setup That Doesn’t Suck: A No-BS Guide walks through this in more detail.

# src/opsctl/commands/server.py
"""Server management commands."""



from datetime import datetime


from rich.console import Console
from rich.table import Table

console = Console()


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


@server.command()
@click.argument("hostname")
@click.option(
    "--port", "-p",
    default=22,
    type=click.IntRange(1, 65535),
    help="SSH port (default: 22)."
)
@click.option(
    "--timeout", "-t",
    default=10,
    type=click.IntRange(1, 120),
    help="Connection timeout in seconds (default: 10)."
)
@click.option(
    "--checks",
    type=click.Choice(["all", "ping", "disk", "memory", "services"]),
    default="all",
    help="Which health checks to run."
)
def check(hostname, port, timeout, checks):
    """Check server health status.

    HOSTNAME is the server address to check.

    Examples:
        opsctl server check prod-01
        opsctl server check prod-01 --checks disk
        opsctl server check 192.168.1.50 -p 2222 -t 30
    """
    console.print(f"\n[bold]Health Check: {hostname}[/bold]")
    console.print(f"Time: {datetime.now():%Y-%m-%d %H:%M:%S}")
    console.print(f"Port: {port} | Timeout: {timeout}s | Checks: {checks}\n")

    results = []

    if checks in ("all", "ping"):
        ping_ok = _check_ping(hostname, timeout)
        results.append(("Ping", "PASS" if ping_ok else "FAIL"))

    if not results or results[-1][1] == "FAIL":
        _display_results(hostname, results)
        if results and results[-1][1] == "FAIL":
            console.print("[red]Host unreachable. Skipping remaining checks.[/red]")
        return

    if checks in ("all", "disk"):
        disk_ok, disk_detail = _check_disk(hostname, port, timeout)
        results.append(("Disk Usage", "PASS" if disk_ok else "WARN", disk_detail))

    if checks in ("all", "memory"):
        mem_ok, mem_detail = _check_memory(hostname, port, timeout)
        results.append(("Memory", "PASS" if mem_ok else "WARN", mem_detail))

    _display_results(hostname, results)


def _check_ping(hostname, timeout):
    """Ping a host and return True if reachable."""
    param = "-n" if sys.platform == "win32" else "-c"
    timeout_param = "-w" if sys.platform == "win32" else "-W"

    result = subprocess.run(
        ["ping", param, "1", timeout_param, str(timeout), hostname],
        capture_output=True, text=True,
    )
    return result.returncode == 0


def _check_disk(hostname, port, timeout):
    """Check disk usage via SSH."""
    result = subprocess.run(
        ["ssh", "-p", str(port), "-o", f"ConnectTimeout={timeout}",
         "-o", "StrictHostKeyChecking=no",
         hostname, "df -h / | tail -1 | awk '{print $5}'"],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        return False, "SSH failed"

    usage = result.stdout.strip().rstrip("%")
    try:
        pct = int(usage)
        return pct < 85, f"{pct}% used"
    except ValueError:
        return False, f"Parse error: {result.stdout.strip()}"


def _check_memory(hostname, port, timeout):
    """Check memory usage via SSH."""
    result = subprocess.run(
        ["ssh", "-p", str(port), "-o", f"ConnectTimeout={timeout}",
         "-o", "StrictHostKeyChecking=no",
         hostname, "free -m | grep Mem | awk '{printf \"%.0f\", $3/$2*100}'"],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        return False, "SSH failed"

    try:
        pct = int(result.stdout.strip())
        return pct < 90, f"{pct}% used"
    except ValueError:
        return False, f"Parse error: {result.stdout.strip()}"


def _display_results(hostname, results):
    """Display health check results in a formatted table."""
    table = Table(title=f"Results: {hostname}")
    table.add_column("Check", style="bold")
    table.add_column("Status")
    table.add_column("Detail")

    for row in results:
        check_name = row[0]
        status = row[1]
        detail = row[2] if len(row) > 2 else ""

        status_style = {
            "PASS": "[green]PASS[/green]",
            "FAIL": "[red]FAIL[/red]",
            "WARN": "[yellow]WARN[/yellow]",
        }.get(status, status)

        table.add_row(check_name, status_style, detail)

    console.print(table)


@server.command()
@click.option(
    "--format", "-f", "output_format",
    type=click.Choice(["table", "json", "csv"]),
    default="table",
    help="Output format."
)
def list(output_format):
    """List all managed servers."""
    servers = [
        {"name": "prod-web-01", "ip": "10.0.1.10", "role": "web", "os": "Ubuntu 22.04"},
        {"name": "prod-db-01", "ip": "10.0.1.20", "role": "database", "os": "Ubuntu 22.04"},
        {"name": "prod-app-01", "ip": "10.0.1.30", "role": "application", "os": "Windows Server 2022"},
    ]

    if output_format == "table":
        table = Table(title="Managed Servers")
        table.add_column("Name", style="bold")
        table.add_column("IP Address")
        table.add_column("Role")
        table.add_column("OS")

        for s in servers:
            table.add_row(s["name"], s["ip"], s["role"], s["os"])
        console.print(table)

    elif output_format == "json":
        import json
        click.echo(json.dumps(servers, indent=2))

    elif output_format == "csv":
        click.echo("name,ip,role,os")
        for s in servers:
            click.echo(f"{s['name']},{s['ip']},{s['role']},{s['os']}")


@server.command()
@click.argument("hostname")
@click.argument("service_name")
@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
def restart(hostname, service_name, force):
    """Restart a service on a remote server.

    Examples:
        opsctl server restart prod-01 nginx
        opsctl server restart prod-01 postgresql --force
    """
    if not force:
        click.confirm(f"Restart '{service_name}' on {hostname}?", abort=True)

    console.print(f"Restarting [bold]{service_name}[/bold] on {hostname}...")

    result = subprocess.run(
        ["ssh", hostname, f"sudo systemctl restart {service_name}"],
        capture_output=True, text=True,
    )

    if result.returncode == 0:
        console.print(f"[green]Service '{service_name}' restarted.[/green]")
    else:
        console.print(f"[red]Failed to restart '{service_name}': {result.stderr.strip()}[/red]")
        raise SystemExit(1)

The @click.IntRange(1, 65535) on the port option validates that the port is within the valid range. If someone types opsctl server check prod-01 -p 99999, Click rejects it with a clear error message before your code ever runs. The @click.Choice on the checks option limits input to valid values — no more typos causing mysterious failures. For technical background, our knowledge base article on AI agent cost optimization provides a solid foundation.

The –force flag on the restart command uses click.confirm to require explicit confirmation before restarting a service on a remote server. When someone deliberately wants to script the restart in an automation pipeline, the --force flag bypasses the prompt.

The Rich library provides colored, formatted table output that makes results easy to scan. Green for PASS, red for FAIL, yellow for WARN.

Step 4: Add a Backup Command Group

# src/opsctl/commands/backup.py
"""Backup management commands."""


from datetime import datetime
from pathlib import Path


from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn

console = Console()


@click.group()
def backup():
    """Manage backup operations."""
    pass


@backup.command()
@click.argument("source", type=click.Path(exists=True))
@click.argument("destination")
@click.option("--compress/--no-compress", default=True, help="Compress the backup (default: yes).")
@click.option("--exclude", multiple=True, help="Patterns to exclude (can be specified multiple times).")
@click.option("--dry-run", is_flag=True, help="Show what would be backed up without copying.")
def create(source, destination, compress, exclude, dry_run):
    """Create a backup of a directory.

    Examples:
        opsctl backup create /var/www /backups/
        opsctl backup create /data /backups/ --exclude "*.log" --exclude "*.tmp"
        opsctl backup create /data /backups/ --dry-run
    """
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    source_name = Path(source).name
    ext = ".tar.gz" if compress else ".tar"
    backup_file = Path(destination) / f"{source_name}-{timestamp}{ext}"

    if dry_run:
        console.print("[yellow]DRY RUN — no files will be copied[/yellow]\n")
        console.print(f"Source:      {source}")
        console.print(f"Destination: {backup_file}")
        console.print(f"Compress:    {compress}")
        if exclude:
            console.print(f"Excludes:    {', '.join(exclude)}")
        return

    tar_flags = "czf" if compress else "cf"
    cmd = ["tar", tar_flags, str(backup_file)]

    for pattern in exclude:
        cmd.extend(["--exclude", pattern])

    cmd.append(source)

    with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
        progress.add_task(f"Backing up {source}...", total=None)
        result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode == 0:
        size = backup_file.stat().st_size / (1024 * 1024)
        console.print(f"[green]Backup complete: {backup_file}[/green]")
        console.print(f"Size: {size:.1f} MB")
    else:
        console.print(f"[red]Backup failed: {result.stderr}[/red]")
        raise SystemExit(1)

The –exclude option uses multiple=True, which lets users specify it more than once. Running opsctl backup create /data /backups/ --exclude "*.log" --exclude "*.tmp" --exclude "cache/" collects all three patterns into a tuple.

The –dry-run flag shows what the command would do without actually doing it. This is an essential safety feature for any command that modifies data. Your team can verify the backup parameters before committing to a potentially long-running operation.

Step 5: Package and Install

With your pyproject.toml configured and your source code in place, installation is one command:

# Install in development mode (editable)
pip install -e .

# Verify it works
opsctl --help
opsctl --version
opsctl server --help
opsctl server check prod-01 --help

The -e flag installs in editable mode — changes you make to the source code take effect immediately without reinstalling. This is perfect for development.

For distributing to your team, build a wheel package:

# Install build tools
pip install build

# Build the package
python -m build --wheel

# dist/opsctl-0.1.0-py3-none-any.whl

Share the .whl file with your team, and they install it with:

pip install opsctl-0.1.0-py3-none-any.whl

For ongoing distribution, Git-based installation is the simplest for small teams:

# Install directly from a private Git repository
pip install git+https://github.com/your-org/opsctl.git

# Install a specific version
pip install git+https://github.com/your-org/[email protected]

Private PyPI is better for larger teams. Tools like pypiserver or Artifactory give you a private package registry that works exactly like the public PyPI. Your team runs pip install opsctl and gets the latest version from your private server.

Step 6: Add Configuration File Support

Real-world CLI tools need configuration that persists between invocations. Here’s a configuration module that reads a YAML config file:

# src/opsctl/utils/config.py
"""Configuration management for opsctl."""

from pathlib import Path



DEFAULT_CONFIG_PATHS = [
    Path.home() / ".config" / "opsctl" / "config.yml",
    Path.cwd() / "opsctl.yml",
    Path("/etc/opsctl/config.yml"),
]


def load_config(config_path=None):
    """Load configuration from file."""
    if config_path:
        path = Path(config_path)
        if path.exists():
            return _read_config(path)
        raise click.BadParameter(f"Config file not found: {config_path}")

    for path in DEFAULT_CONFIG_PATHS:
        if path.exists():
            return _read_config(path)

    return {}


def _read_config(path):
    """Read and parse a YAML config file."""
    with open(path) as f:
        config = yaml.safe_load(f)
    return config or {}

An example config file for your team:

# ~/.config/opsctl/config.yml
servers:
  - name: prod-web-01
    host: 10.0.1.10
    port: 22
    role: web
  - name: prod-db-01
    host: 10.0.1.20
    port: 22
    role: database

backup:
  default_destination: /mnt/backups
  retention_days: 30
  compress: true
  exclude_patterns:
    - "*.log"
    - "*.tmp"
    - "__pycache__"

alerts:
  webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK"

The configuration search order — explicit flag, then home directory, then current directory, then system-wide — follows the convention that most Unix tools use. This means you can have a global config in your home directory that applies everywhere, and override it with a project-specific config in the current directory.

Patterns That Make CLI Tools Great

After building dozens of IT operations tools, here are the patterns that separate good tools from throw-away scripts. If this resonates, our post on 10 Things Every Small Business in Volusia County Can Automate This Week goes deeper into the specifics.

Always provide a –dry-run mode for destructive commands. Backup deletion, service restarts, file modifications — anything that changes state should have a way to preview the action without executing it. This single feature prevents more mistakes than any amount of input validation.

Use exit codes consistently. Exit 0 for success, exit 1 for errors, exit 2 for warnings. This makes your tools composable in shell scripts and CI/CD pipelines. opsctl server check prod-01 && echo "healthy" || echo "problem" works because Click handles exit codes properly.

Support both human-readable and machine-readable output. The --format option on the list command lets humans see a pretty table and automation scripts parse JSON. Design for both audiences.

Include examples in help text. The docstrings in the command functions appear in --help output. Include practical examples so your team doesn’t have to guess the syntax.

Handle errors with specific messages. “Error” tells the user nothing. “SSH connection to prod-01:22 timed out after 10 seconds” tells them exactly what went wrong and implicitly suggests what to check.

FAQ

What is Python Click?

Click is a Python package for creating command-line interfaces with minimal code. It uses decorators to define commands, options, and arguments, automatically generates help text, validates input, and handles errors gracefully. Click is the library behind Flask’s CLI and is used by thousands of production tools.

Why build custom CLI tools instead of using scripts?

Scripts solve immediate problems but create long-term maintenance burdens. They lack help text, argument validation, error handling, and documentation. Custom CLI tools built with Click have automatic –help output, type checking on inputs, consistent error messages, and can be installed with pip and shared across your team.

How do I distribute a Python CLI tool to my team?

Package your tool with a pyproject.toml file that defines entry points in [project.scripts]. Build with python -m build, then install via pip install your-tool.whl. For team distribution, publish to a private PyPI server or install from a Git repository with pip install git+https://github.com/your-org/your-tool.git.

Can I build CLI tools without knowing advanced Python?

Yes. Click handles the complex parts. You need basic Python knowledge: functions, string formatting, file I/O, and subprocess. If you can write a Python script that does something useful, you can wrap it in Click.

What’s the difference between Click and argparse?

Argparse is Python’s built-in parser but requires verbose code. Click uses decorators that make argument definitions self-documenting and provides command groups for multi-command tools, automatic help formatting, and composable hierarchies that argparse doesn’t support natively.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.