Skip to content

Building a CLI Tool with Ralph

This example shows how to use Ralph Orchestrator to create a command-line tool with argparse, subcommands, and proper packaging.

Task Description

Create a Python CLI tool for file management with: - Multiple subcommands - Progress bars - Configuration file support - Error handling - Installation script

PROMPT.md File

# Task: Build File Manager CLI Tool

Create a Python CLI tool called 'fman' with the following features:

## Commands

1. **list** - List files in directory
   - Options: --all, --size, --date
   - Show file sizes and modification dates

2. **search** - Search for files
   - Options: --name, --extension, --content
   - Support wildcards and regex

3. **copy** - Copy files/directories
   - Show progress bar for large files
   - Options: --recursive, --overwrite

4. **move** - Move files/directories
   - Confirm before overwriting
   - Options: --force

5. **delete** - Delete files/directories
   - Require confirmation
   - Options: --force, --recursive

## Requirements

- Use argparse for CLI parsing
- Use click or rich for better UX
- Progress bars with tqdm
- Colored output
- Configuration file support (~/.fmanrc)
- Comprehensive error handling
- Unit tests
- Setup.py for installation

## Project Structure
file-manager-cli/ ├── fman/ │ ├── init.py │ ├── main.py # Entry point │ ├── cli.py # CLI interface │ ├── commands/ # Command implementations │ │ ├── init.py │ │ ├── list_cmd.py │ │ ├── search_cmd.py │ │ ├── copy_cmd.py │ │ ├── move_cmd.py │ │ └── delete_cmd.py │ ├── utils.py # Utility functions │ └── config.py # Configuration handling ├── tests/ │ └── test_commands.py ├── setup.py ├── requirements.txt └── README.md
<!-- The orchestrator will continue iterations until all requirements are met -->

Running Ralph

# Initialize and run
ralph init
cp cli-tool-prompt.md PROMPT.md
ralph run --agent claude --max-iterations 40

Expected Output

fman/cli.py

#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path
from rich.console import Console
from rich.table import Table

from fman.commands import (
    list_cmd, search_cmd, copy_cmd, 
    move_cmd, delete_cmd
)
from fman.config import load_config

console = Console()

def create_parser():
    """Create argument parser"""
    parser = argparse.ArgumentParser(
        prog='fman',
        description='File Manager CLI Tool',
        formatter_class=argparse.RawDescriptionHelpFormatter
    )

    parser.add_argument(
        '--version',
        action='version',
        version='%(prog)s 1.0.0'
    )

    parser.add_argument(
        '--config',
        help='Config file path',
        default='~/.fmanrc'
    )

    subparsers = parser.add_subparsers(
        dest='command',
        help='Available commands'
    )

    # List command
    list_parser = subparsers.add_parser(
        'list',
        help='List files in directory'
    )
    list_parser.add_argument(
        'path',
        nargs='?',
        default='.',
        help='Directory path'
    )
    list_parser.add_argument(
        '--all', '-a',
        action='store_true',
        help='Show hidden files'
    )
    list_parser.add_argument(
        '--size', '-s',
        action='store_true',
        help='Sort by size'
    )
    list_parser.add_argument(
        '--date', '-d',
        action='store_true',
        help='Sort by date'
    )

    # Search command
    search_parser = subparsers.add_parser(
        'search',
        help='Search for files'
    )
    search_parser.add_argument(
        'pattern',
        help='Search pattern'
    )
    search_parser.add_argument(
        '--path', '-p',
        default='.',
        help='Search path'
    )
    search_parser.add_argument(
        '--name', '-n',
        action='store_true',
        help='Search in filenames'
    )
    search_parser.add_argument(
        '--content', '-c',
        action='store_true',
        help='Search in file contents'
    )
    search_parser.add_argument(
        '--extension', '-e',
        help='Filter by extension'
    )

    # Copy command
    copy_parser = subparsers.add_parser(
        'copy',
        help='Copy files or directories'
    )
    copy_parser.add_argument('source', help='Source path')
    copy_parser.add_argument('dest', help='Destination path')
    copy_parser.add_argument(
        '--recursive', '-r',
        action='store_true',
        help='Copy recursively'
    )
    copy_parser.add_argument(
        '--overwrite', '-o',
        action='store_true',
        help='Overwrite existing files'
    )

    # Move command
    move_parser = subparsers.add_parser(
        'move',
        help='Move files or directories'
    )
    move_parser.add_argument('source', help='Source path')
    move_parser.add_argument('dest', help='Destination path')
    move_parser.add_argument(
        '--force', '-f',
        action='store_true',
        help='Force move without confirmation'
    )

    # Delete command
    delete_parser = subparsers.add_parser(
        'delete',
        help='Delete files or directories'
    )
    delete_parser.add_argument(
        'path',
        nargs='+',
        help='Path(s) to delete'
    )
    delete_parser.add_argument(
        '--force', '-f',
        action='store_true',
        help='Force delete without confirmation'
    )
    delete_parser.add_argument(
        '--recursive', '-r',
        action='store_true',
        help='Delete directories recursively'
    )

    return parser

def main():
    """Main entry point"""
    parser = create_parser()
    args = parser.parse_args()

    # Load configuration
    config = load_config(args.config)

    # Execute command
    commands = {
        'list': list_cmd.execute,
        'search': search_cmd.execute,
        'copy': copy_cmd.execute,
        'move': move_cmd.execute,
        'delete': delete_cmd.execute
    }

    if args.command:
        try:
            result = commands[args.command](args, config)
            sys.exit(0 if result else 1)
        except KeyboardInterrupt:
            console.print("\n[yellow]Interrupted by user[/yellow]")
            sys.exit(130)
        except Exception as e:
            console.print(f"[red]Error: {e}[/red]")
            sys.exit(1)
    else:
        parser.print_help()
        sys.exit(0)

if __name__ == '__main__':
    main()

fman/commands/list_cmd.py

import os
from pathlib import Path
from datetime import datetime
from rich.console import Console
from rich.table import Table
from rich.filesize import decimal

console = Console()

def execute(args, config):
    """Execute list command"""
    path = Path(args.path).expanduser().resolve()

    if not path.exists():
        console.print(f"[red]Error: Path '{path}' does not exist[/red]")
        return False

    if not path.is_dir():
        console.print(f"[red]Error: '{path}' is not a directory[/red]")
        return False

    # Get files
    if args.all:
        files = list(path.iterdir())
    else:
        files = [f for f in path.iterdir() if not f.name.startswith('.')]

    # Sort files
    if args.size:
        files.sort(key=lambda f: f.stat().st_size if f.is_file() else 0, 
                  reverse=True)
    elif args.date:
        files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
    else:
        files.sort(key=lambda f: f.name.lower())

    # Create table
    table = Table(title=f"Files in {path}")
    table.add_column("Name", style="cyan")
    table.add_column("Type", style="magenta")
    table.add_column("Size", justify="right", style="green")
    table.add_column("Modified", style="yellow")

    for file in files:
        stat = file.stat()

        # Determine type
        if file.is_dir():
            file_type = "DIR"
            size = "-"
        elif file.is_symlink():
            file_type = "LINK"
            size = "-"
        else:
            file_type = "FILE"
            size = decimal(stat.st_size)

        # Format modification time
        mtime = datetime.fromtimestamp(stat.st_mtime)
        mtime_str = mtime.strftime("%Y-%m-%d %H:%M")

        table.add_row(
            file.name,
            file_type,
            size,
            mtime_str
        )

    console.print(table)
    return True

fman/commands/copy_cmd.py

import shutil
from pathlib import Path
from tqdm import tqdm
from rich.console import Console

console = Console()

def copy_with_progress(src, dst):
    """Copy file with progress bar"""
    src_size = src.stat().st_size

    with tqdm(total=src_size, unit='B', unit_scale=True, 
              desc=f"Copying {src.name}") as pbar:
        def callback(copied, total):
            pbar.update(copied - pbar.n)

        # For large files, use callback
        if src_size > 1024 * 1024:  # 1MB
            with open(src, 'rb') as fsrc:
                with open(dst, 'wb') as fdst:
                    copied = 0
                    while True:
                        buf = fsrc.read(1024 * 64)  # 64KB chunks
                        if not buf:
                            break
                        fdst.write(buf)
                        copied += len(buf)
                        callback(copied, src_size)
        else:
            shutil.copy2(src, dst)
            callback(src_size, src_size)

def execute(args, config):
    """Execute copy command"""
    source = Path(args.source).expanduser().resolve()
    dest = Path(args.dest).expanduser().resolve()

    if not source.exists():
        console.print(f"[red]Error: Source '{source}' does not exist[/red]")
        return False

    # Handle directory copy
    if source.is_dir():
        if not args.recursive:
            console.print("[red]Error: Use -r to copy directories[/red]")
            return False

        if dest.exists() and not args.overwrite:
            response = console.input(
                f"[yellow]'{dest}' exists. Overwrite? [y/N]:[/yellow] "
            )
            if response.lower() != 'y':
                console.print("[yellow]Copy cancelled[/yellow]")
                return False

        console.print(f"Copying directory '{source}' to '{dest}'...")
        shutil.copytree(source, dest, dirs_exist_ok=args.overwrite)
        console.print("[green]✓ Directory copied successfully[/green]")

    # Handle file copy
    else:
        if dest.is_dir():
            dest = dest / source.name

        if dest.exists() and not args.overwrite:
            response = console.input(
                f"[yellow]'{dest}' exists. Overwrite? [y/N]:[/yellow] "
            )
            if response.lower() != 'y':
                console.print("[yellow]Copy cancelled[/yellow]")
                return False

        copy_with_progress(source, dest)
        console.print("[green]✓ File copied successfully[/green]")

    return True

setup.py

from setuptools import setup, find_packages

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

setup(
    name="fman",
    version="1.0.0",
    author="Your Name",
    author_email="your.email@example.com",
    description="A powerful file manager CLI tool",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/yourusername/fman",
    packages=find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
        "Environment :: Console",
        "Topic :: System :: Filesystems",
    ],
    python_requires=">=3.7",
    install_requires=[
        "rich>=10.0.0",
        "tqdm>=4.60.0",
        "click>=8.0.0",
    ],
    entry_points={
        "console_scripts": [
            "fman=fman.cli:main",
        ],
    },
    include_package_data=True,
)

Testing the CLI

# Install in development mode
pip install -e .

# Test commands
fman list --all
fman search "*.py" --path /home/user/projects
fman copy file.txt backup.txt
fman move old.txt new.txt
fman delete temp.txt --force

# Run tests
pytest tests/ -v

Tips for CLI Development

  1. Clear Command Structure: Define all commands and options upfront
  2. User Experience: Request colored output and progress bars
  3. Error Handling: Specify how errors should be displayed
  4. Configuration: Include config file support from the start
  5. Testing: Request unit tests for each command

Extending the Tool

Add Compression Support

## Additional Command
6. **compress** - Compress files/directories
   - Support zip, tar.gz, tar.bz2
   - Options: --format, --level
   - Show compression ratio

Add Remote Operations

## Additional Features
- Support for remote file operations via SSH
- Commands: remote-list, remote-copy, remote-delete
- Use paramiko for SSH connections

Common Patterns

Confirmation Prompts

def confirm_action(message):
    """Get user confirmation"""
    response = console.input(f"[yellow]{message} [y/N]:[/yellow] ")
    return response.lower() == 'y'

Error Handling

def safe_operation(func):
    """Decorator for safe operations"""
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except PermissionError:
            console.print("[red]Permission denied[/red]")
        except FileNotFoundError:
            console.print("[red]File not found[/red]")
        except Exception as e:
            console.print(f"[red]Error: {e}[/red]")
        return False
    return wrapper

Cost Estimation

  • Iterations: ~30-40 for full implementation
  • Time: ~15-20 minutes
  • Agent: Claude or Gemini
  • API Calls: ~$0.30-0.40