CLI Tools Development

Build powerful command-line applications with OptionParser, Thor, and interactive features. Create professional CLI tools with argument parsing and rich i…

Beginner 1 min read

CLI Tools Development in Ruby

Learn to build powerful command-line applications in Ruby using OptionParser, Thor, and other tools. Create professional CLI tools with argument parsing, help systems, subcommands, and interactive features.

Introduction to CLI Development

Ruby provides excellent tools for building command-line interfaces, from simple scripts with OptionParser to complex applications with Thor or custom frameworks.

Basic CLI with OptionParser

#!/usr/bin/env ruby
require 'optparse'
require 'ostruct'

# Simple CLI tool with OptionParser
class MyCLI
  def self.parse(args)
    options = OpenStruct.new
    options.verbose = false
    options.output_file = nil
    options.format = 'text'

    opt_parser = OptionParser.new do |opts|
      opts.banner = "Usage: mycli [options] files..."

      opts.separator ""
      opts.separator "Specific options:"

      # Boolean flag
      opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
        options.verbose = v
      end

      # Argument with value
      opts.on("-o", "--output FILE", "Write output to FILE") do |file|
        options.output_file = file
      end

      # Choice from list
      opts.on("-f", "--format FORMAT",
              %w[text json xml],
              "Output format (text, json, xml)") do |format|
        options.format = format
      end

      # Multiple values
      opts.on("-e", "--exclude PATTERN",
              "Exclude files matching pattern (can be used multiple times)") do |pattern|
        (options.exclude ||= []) << pattern
      end

      opts.separator ""
      opts.separator "Common options:"

      # Help
      opts.on_tail("-h", "--help", "Show this message") do
        puts opts
        exit
      end

      # Version
      opts.on_tail("--version", "Show version") do
        puts "MyCLI version 1.0.0"
        exit
      end
    end

    opt_parser.parse!(args)
    options
  end

  def self.run(args)
    options = parse(args)

    puts "Processing files: #{args}" if options.verbose
    puts "Output format: #{options.format}"
    puts "Output file: #{options.output_file}" if options.output_file
    puts "Exclude patterns: #{options.exclude}" if options.exclude

    # Process remaining arguments (files)
    if args.empty?
      puts "No files specified"
      exit 1
    end

    process_files(args, options)
  end

  def self.process_files(files, options)
    files.each do |file|
      next if excluded?(file, options.exclude)

      puts "Processing #{file}..." if options.verbose

      begin
        content = File.read(file)
        output = format_content(content, options.format)

        if options.output_file
          File.write(options.output_file, output)
        else
          puts output
        end
      rescue => e
        puts "Error processing #{file}: #{e.message}"
      end
    end
  end

  def self.excluded?(file, patterns)
    return false unless patterns

    patterns.any? { |pattern| File.fnmatch(pattern, file) }
  end

  def self.format_content(content, format)
    case format
    when 'json'
      require 'json'
      { content: content, lines: content.lines.count }.to_json
    when 'xml'
      "#{content}"
    else
      content
    end
  end
end

# Run if called directly
if __FILE__ == $0
  MyCLI.run(ARGV)
end

Advanced OptionParser Features

Implement sophisticated argument parsing with validation, custom types, and complex option handling.

Advanced OptionParser Patterns

require 'optparse'
require 'date'
require 'uri'

class AdvancedCLI
  def self.parse_options
    options = {
      config_file: 'config.yml',
      threads: 4,
      timeout: 30,
      date_range: nil,
      urls: [],
      log_level: :info,
      dry_run: false
    }

    OptionParser.new do |opts|
      opts.banner = "Usage: advanced-cli [options]"

      # Custom type conversion
      opts.on("--threads COUNT", Integer, "Number of threads (1-32)") do |count|
        if count < 1 || count > 32
          raise OptionParser::InvalidArgument, "threads must be 1-32"
        end
        options[:threads] = count
      end

      # Custom validation
      opts.on("--timeout SECONDS", Float, "Timeout in seconds") do |timeout|
        raise OptionParser::InvalidArgument, "timeout must be positive" if timeout <= 0
        options[:timeout] = timeout
      end

      # Date parsing
      opts.on("--since DATE", "Process items since date (YYYY-MM-DD)") do |date_str|
        begin
          options[:since] = Date.parse(date_str)
        rescue Date::Error
          raise OptionParser::InvalidArgument, "invalid date format"
        end
      end

      # URL validation
      opts.on("--url URL", "Add URL to process") do |url_str|
        begin
          uri = URI.parse(url_str)
          raise URI::InvalidURIError unless uri.scheme =~ /^https?$/
          options[:urls] << uri
        rescue URI::InvalidURIError
          raise OptionParser::InvalidArgument, "invalid URL: #{url_str}"
        end
      end

      # Symbol choices
      opts.on("--log-level LEVEL",
              [:debug, :info, :warn, :error],
              "Log level (debug, info, warn, error)") do |level|
        options[:log_level] = level
      end

      # File existence validation
      opts.on("-c", "--config FILE", "Configuration file") do |file|
        unless File.exist?(file)
          raise OptionParser::InvalidArgument, "config file not found: #{file}"
        end
        options[:config_file] = file
      end

      # Custom parsing with regex
      opts.on("--match PATTERN", "Regex pattern to match") do |pattern|
        begin
          options[:pattern] = Regexp.new(pattern)
        rescue RegexpError => e
          raise OptionParser::InvalidArgument, "invalid regex: #{e.message}"
        end
      end

      # Key-value pairs
      opts.on("--set KEY=VALUE", "Set configuration key") do |pair|
        key, value = pair.split('=', 2)
        raise OptionParser::InvalidArgument, "invalid format" unless value
        (options[:config_overrides] ||= {})[key] = value
      end

      opts.on("--dry-run", "Show what would be done without executing") do
        options[:dry_run] = true
      end

      opts.on_tail("-h", "--help", "Show this help") do
        puts opts
        exit
      end
    end.parse!

    # Post-parsing validation
    validate_options(options)
    options
  end

  def self.validate_options(options)
    # Cross-option validation
    if options[:since] && options[:since] > Date.today
      raise "Since date cannot be in the future"
    end

    if options[:urls].empty? && !options[:config_file]
      raise "Either URLs or config file must be specified"
    end

    # Load and validate config file
    if File.exist?(options[:config_file])
      config = YAML.load_file(options[:config_file])
      options[:config] = config

      # Validate config structure
      required_keys = %w[database redis]
      missing_keys = required_keys - config.keys
      unless missing_keys.empty?
        raise "Config file missing required keys: #{missing_keys.join(', ')}"
      end
    end
  rescue => e
    puts "Configuration error: #{e.message}"
    exit 1
  end

  def self.run
    options = parse_options
    puts "Starting with options: #{options.inspect}"

    # Use the parsed options
    setup_logging(options[:log_level])
    process_urls(options[:urls], options)
  rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
    puts "Error: #{e.message}"
    puts "Use --help for usage information"
    exit 1
  end

  private

  def self.setup_logging(level)
    puts "Setting log level to #{level}"
  end

  def self.process_urls(urls, options)
    urls.each do |url|
      if options[:dry_run]
        puts "Would process: #{url}"
      else
        puts "Processing: #{url}"
        # Actual processing logic
      end
    end
  end
end

Thor Framework for Complex CLIs

Use Thor to build sophisticated command-line applications with subcommands, generators, and advanced features.

Building with Thor

#!/usr/bin/env ruby
require 'thor'
require 'fileutils'
require 'json'

# Thor-based CLI application
class MyAppCLI < Thor
  # Global options
  class_option :verbose,
               type: :boolean,
               aliases: '-v',
               desc: 'Verbose output'
  class_option :config,
               type: :string,
               aliases: '-c',
               default: 'config.yml',
               desc: 'Configuration file'

  desc "init [NAME]", "Initialize a new project"
  method_option :template,
                type: :string,
                default: 'basic',
                desc: 'Project template to use'
  method_option :force,
                type: :boolean,
                default: false,
                desc: 'Overwrite existing files'
  def init(name = 'my_project')
    say "Initializing project: #{name}", :green

    # Create project directory
    if Dir.exist?(name) && !options[:force]
      say "Directory #{name} already exists. Use --force to overwrite.", :red
      exit 1
    end

    FileUtils.mkdir_p(name)

    # Create project structure based on template
    create_project_structure(name, options[:template])

    inside(name) do
      create_file 'README.md', readme_content(name)
      create_file 'config.yml', config_content
      create_file '.gitignore', gitignore_content

      if yes?("Initialize git repository?")
        run 'git init'
        run 'git add .'
        run 'git commit -m "Initial commit"'
      end
    end

    say "Project #{name} created successfully!", :green
  end

  desc "generate TYPE NAME", "Generate files of specified type"
  method_option :test,
                type: :boolean,
                default: true,
                desc: 'Generate corresponding test file'
  def generate(type, name)
    case type.downcase
    when 'model'
      generate_model(name)
    when 'controller'
      generate_controller(name)
    when 'service'
      generate_service(name)
    else
      say "Unknown generator type: #{type}", :red
      say "Available types: model, controller, service"
    end
  end

  desc "build", "Build the application"
  method_option :environment,
                type: :string,
                default: 'development',
                aliases: '-e',
                desc: 'Build environment'
  method_option :optimize,
                type: :boolean,
                default: false,
                desc: 'Enable optimizations'
  def build
    say "Building application for #{options[:environment]} environment", :blue

    # Load configuration
    config = load_config

    # Build steps
    with_spinner("Compiling assets") do
      compile_assets(config, options[:environment])
    end

    with_spinner("Running tests") do
      run_tests unless options[:skip_tests]
    end

    with_spinner("Creating package") do
      create_package(options[:optimize])
    end

    say "Build completed successfully!", :green
  end

  desc "deploy ENVIRONMENT", "Deploy to specified environment"
  method_option :version,
                type: :string,
                desc: 'Specific version to deploy'
  method_option :dry_run,
                type: :boolean,
                default: false,
                desc: 'Show what would be deployed without executing'
  def deploy(environment)
    config = load_config

    unless config['environments']&.key?(environment)
      say "Unknown environment: #{environment}", :red
      say "Available environments: #{config['environments']&.keys&.join(', ')}"
      exit 1
    end

    if options[:dry_run]
      say "DRY RUN: Would deploy to #{environment}", :yellow
      show_deployment_plan(environment, config)
    else
      confirm_deployment(environment) if environment == 'production'
      perform_deployment(environment, config)
    end
  end

  desc "status", "Show application status"
  def status
    config = load_config

    table = []
    table << ['Component', 'Status', 'Version']
    table << :separator

    # Check various components
    components = check_components(config)
    components.each do |component, info|
      status_color = info[:healthy] ? :green : :red
      table << [component, set_color(info[:status], status_color), info[:version] || 'N/A']
    end

    print_table(table)
  end

  desc "config COMMAND", "Manage configuration"
  subcommand "config", ConfigCLI

  private

  def load_config
    config_file = options[:config]
    unless File.exist?(config_file)
      say "Configuration file not found: #{config_file}", :red
      exit 1
    end

    YAML.load_file(config_file)
  rescue => e
    say "Error loading configuration: #{e.message}", :red
    exit 1
  end

  def create_project_structure(name, template)
    case template
    when 'basic'
      inside(name) do
        empty_directory 'lib'
        empty_directory 'spec'
        empty_directory 'bin'
        empty_directory 'config'
      end
    when 'web'
      inside(name) do
        empty_directory 'app'
        empty_directory 'app/controllers'
        empty_directory 'app/models'
        empty_directory 'app/views'
        empty_directory 'config'
        empty_directory 'public'
        empty_directory 'spec'
      end
    end
  end

  def generate_model(name)
    class_name = name.split('_').map(&:capitalize).join
    file_name = "lib/#{name}.rb"

    create_file file_name, model_template(class_name)

    if options[:test]
      create_file "spec/#{name}_spec.rb", model_spec_template(class_name, name)
    end

    say "Generated model: #{class_name}", :green
  end

  def generate_controller(name)
    class_name = "#{name.split('_').map(&:capitalize).join}Controller"
    file_name = "app/controllers/#{name}_controller.rb"

    create_file file_name, controller_template(class_name)

    if options[:test]
      create_file "spec/controllers/#{name}_controller_spec.rb",
                  controller_spec_template(class_name, name)
    end

    say "Generated controller: #{class_name}", :green
  end

  def with_spinner(message)
    print "#{message}... "
    yield
    say "✓", :green
  rescue => e
    say "✗", :red
    say "Error: #{e.message}", :red
    exit 1
  end

  def confirm_deployment(environment)
    unless yes?("Deploy to #{environment}? This cannot be undone! (y/N)")
      say "Deployment cancelled", :yellow
      exit 0
    end
  end

  def model_template(class_name)
    <<~RUBY
      class #{class_name}
        def initialize(attributes = {})
          @attributes = attributes
        end

        attr_reader :attributes
      end
    RUBY
  end

  def model_spec_template(class_name, file_name)
    <<~RUBY
      require 'spec_helper'
      require_relative '../lib/#{file_name}'

      RSpec.describe #{class_name} do
        describe '#initialize' do
          it 'sets attributes' do
            instance = #{class_name}.new(name: 'Test')
            expect(instance.attributes).to eq(name: 'Test')
          end
        end
      end
    RUBY
  end
end

# Subcommand for configuration management
class ConfigCLI < Thor
  desc "show [KEY]", "Show configuration"
  def show(key = nil)
    config = YAML.load_file(parent_options[:config])

    if key
      value = config.dig(*key.split('.'))
      if value
        puts YAML.dump({ key => value })
      else
        say "Key not found: #{key}", :red
      end
    else
      puts YAML.dump(config)
    end
  end

  desc "set KEY VALUE", "Set configuration value"
  def set(key, value)
    config_file = parent_options[:config]
    config = File.exist?(config_file) ? YAML.load_file(config_file) : {}

    # Navigate to nested key
    keys = key.split('.')
    target = keys[0..-2].reduce(config) { |h, k| h[k] ||= {} }
    target[keys.last] = parse_value(value)

    File.write(config_file, YAML.dump(config))
    say "Set #{key} = #{value}", :green
  end

  desc "delete KEY", "Delete configuration key"
  def delete(key)
    config_file = parent_options[:config]
    config = YAML.load_file(config_file)

    keys = key.split('.')
    target = keys[0..-2].reduce(config) { |h, k| h[k] }

    if target&.key?(keys.last)
      target.delete(keys.last)
      File.write(config_file, YAML.dump(config))
      say "Deleted #{key}", :green
    else
      say "Key not found: #{key}", :red
    end
  end

  private

  def parse_value(value)
    case value
    when 'true' then true
    when 'false' then false
    when /^\d+$/ then value.to_i
    when /^\d+\.\d+$/ then value.to_f
    else value
    end
  end
end

# Run the CLI
MyAppCLI.start(ARGV) if __FILE__ == $0

Interactive CLI Features

Build interactive command-line applications with user input, progress bars, menus, and rich output formatting.

Interactive Elements and UI

require 'io/console'
require 'tty-prompt'
require 'tty-progressbar'
require 'tty-table'
require 'colorize'
require 'pastel'

class InteractiveCLI
  def initialize
    @prompt = TTY::Prompt.new
    @pastel = Pastel.new
  end

  def run
    show_welcome
    main_menu
  end

  private

  def show_welcome
    puts @pastel.bright_blue.bold("=" * 60)
    puts @pastel.bright_blue.bold("🚀 Welcome to Interactive CLI Tool")
    puts @pastel.bright_blue.bold("=" * 60)
    puts
  end

  def main_menu
    loop do
      choice = @prompt.select("What would you like to do?") do |menu|
        menu.choice "📂 File Operations", :files
        menu.choice "🗄️  Database Operations", :database
        menu.choice "🔧 System Utilities", :system
        menu.choice "⚙️  Configuration", :config
        menu.choice "📊 Reports", :reports
        menu.choice "❌ Exit", :exit
      end

      case choice
      when :files
        file_operations_menu
      when :database
        database_operations_menu
      when :system
        system_utilities_menu
      when :config
        configuration_menu
      when :reports
        reports_menu
      when :exit
        puts @pastel.green("Goodbye! 👋")
        break
      end
    end
  end

  def file_operations_menu
    choice = @prompt.select("File Operations:") do |menu|
      menu.choice "📁 List files", :list
      menu.choice "🔍 Search files", :search
      menu.choice "📋 Copy files", :copy
      menu.choice "🗑️  Delete files", :delete
      menu.choice "⬅️  Back to main menu", :back
    end

    case choice
    when :list
      list_files
    when :search
      search_files
    when :copy
      copy_files
    when :delete
      delete_files
    when :back
      return
    end

    # Return to file menu after operation
    file_operations_menu unless choice == :back
  end

  def list_files
    directory = @prompt.ask("Enter directory path:", default: ".")

    unless Dir.exist?(directory)
      puts @pastel.red("❌ Directory not found: #{directory}")
      return
    end

    files = Dir.entries(directory).reject { |f| f.start_with?('.') }

    if files.empty?
      puts @pastel.yellow("📭 No files found in #{directory}")
      return
    end

    # Create table with file information
    table_data = files.map do |file|
      path = File.join(directory, file)
      stat = File.stat(path)
      [
        File.directory?(path) ? "📁" : "📄",
        file,
        human_file_size(stat.size),
        stat.mtime.strftime("%Y-%m-%d %H:%M")
      ]
    end

    table = TTY::Table.new(
      header: ['Type', 'Name', 'Size', 'Modified'],
      rows: table_data
    )

    puts table.render(:unicode, padding: [0, 1])
  end

  def search_files
    pattern = @prompt.ask("Enter search pattern (glob format):")
    directory = @prompt.ask("Search in directory:", default: ".")

    files = Dir.glob(File.join(directory, "**", pattern))

    if files.empty?
      puts @pastel.yellow("🔍 No files found matching pattern: #{pattern}")
      return
    end

    puts @pastel.green("Found #{files.size} files:")
    files.each_with_index do |file, index|
      puts "#{index + 1}. #{file}"
    end

    if @prompt.yes?("Open a file?")
      file_index = @prompt.ask("Enter file number:", convert: :int) - 1
      if file_index >= 0 && file_index < files.size
        system("${EDITOR:-nano} '#{files[file_index]}'")
      end
    end
  end

  def copy_files
    source = @prompt.ask("Source pattern:")
    destination = @prompt.ask("Destination directory:")

    files = Dir.glob(source)

    if files.empty?
      puts @pastel.red("❌ No files found matching: #{source}")
      return
    end

    FileUtils.mkdir_p(destination) unless Dir.exist?(destination)

    # Progress bar for copying
    progressbar = TTY::Progressbar.new(
      "Copying [:bar] :percent :current/:total files",
      total: files.size,
      bar_format: :block
    )

    files.each do |file|
      FileUtils.cp(file, destination)
      progressbar.advance
      sleep(0.1) # Simulate work for demo
    end

    puts @pastel.green("✅ Copied #{files.size} files to #{destination}")
  end

  def delete_files
    pattern = @prompt.ask("Files to delete (pattern):")
    files = Dir.glob(pattern)

    if files.empty?
      puts @pastel.yellow("🔍 No files found matching: #{pattern}")
      return
    end

    puts @pastel.yellow("Files to be deleted:")
    files.each { |file| puts "  #{file}" }

    if @prompt.yes?(@pastel.red.bold("⚠️  Are you sure you want to delete these files?"))
      confirmation = @prompt.ask("Type 'DELETE' to confirm:")

      if confirmation == 'DELETE'
        files.each { |file| File.delete(file) }
        puts @pastel.green("✅ Deleted #{files.size} files")
      else
        puts @pastel.yellow("🚫 Deletion cancelled")
      end
    end
  end

  def database_operations_menu
    # Multi-select example
    operations = @prompt.multi_select("Select database operations:") do |menu|
      menu.choice "🔄 Backup database", :backup
      menu.choice "📊 Run migrations", :migrate
      menu.choice "🌱 Seed data", :seed
      menu.choice "🔍 Query data", :query
      menu.choice "📈 Generate report", :report
    end

    return if operations.empty?

    operations.each do |op|
      case op
      when :backup
        perform_backup
      when :migrate
        run_migrations
      when :seed
        seed_database
      when :query
        interactive_query
      when :report
        generate_db_report
      end
    end
  end

  def perform_backup
    databases = ["production", "staging", "development"]
    database = @prompt.select("Select database:", databases)

    backup_types = {
      "Full backup" => :full,
      "Schema only" => :schema,
      "Data only" => :data
    }
    backup_type = @prompt.select("Backup type:", backup_types)

    puts @pastel.blue("🔄 Starting #{backup_type} backup of #{database} database...")

    # Simulate backup with progress
    progressbar = TTY::Progressbar.new(
      "Backing up [:bar] :percent",
      total: 100,
      bar_format: :crate
    )

    100.times do
      progressbar.advance
      sleep(0.05)
    end

    filename = "backup_#{database}_#{Time.now.strftime('%Y%m%d_%H%M%S')}.sql"
    puts @pastel.green("✅ Backup completed: #{filename}")
  end

  def interactive_query
    puts @pastel.blue("Interactive Database Query")
    puts @pastel.dim("Enter SQL queries (type 'exit' to quit)")

    loop do
      query = @prompt.ask("SQL>", value: "")
      break if query.downcase == 'exit'

      # Simulate query execution
      puts @pastel.green("Executing: #{query}")

      # Mock results
      if query.downcase.include?('select')
        table = TTY::Table.new(
          header: ['ID', 'Name', 'Email', 'Created'],
          rows: [
            ['1', 'John Doe', 'john@example.com', '2024-01-15'],
            ['2', 'Jane Smith', 'jane@example.com', '2024-01-16']
          ]
        )
        puts table.render(:unicode)
      else
        puts @pastel.green("Query executed successfully (2 rows affected)")
      end
    end
  end

  def configuration_menu
    # Mask input for sensitive data
    database_password = @prompt.mask("Database password:")
    api_key = @prompt.mask("API key:")

    # Slider for numeric values
    max_connections = @prompt.slider("Max database connections:",
                                   min: 1, max: 100, step: 5, default: 20)

    # Enum selection
    log_level = @prompt.select("Log level:") do |menu|
      menu.choice "Debug", :debug
      menu.choice "Info", :info
      menu.choice "Warning", :warn
      menu.choice "Error", :error
    end

    config = {
      database_password: database_password,
      api_key: api_key,
      max_connections: max_connections,
      log_level: log_level
    }

    puts @pastel.green("Configuration saved:")
    puts config.reject { |k, v| k.to_s.include?('password') || k.to_s.include?('key') }
  end

  def reports_menu
    report_types = {
      "📊 System Status" => :system_status,
      "👥 User Activity" => :user_activity,
      "💾 Storage Usage" => :storage,
      "🔧 Performance Metrics" => :performance
    }

    report_type = @prompt.select("Select report type:", report_types)

    case report_type
    when :system_status
      generate_system_status_report
    when :user_activity
      generate_user_activity_report
    when :storage
      generate_storage_report
    when :performance
      generate_performance_report
    end
  end

  def generate_system_status_report
    puts @pastel.blue.bold("📊 System Status Report")
    puts @pastel.dim("Generated at: #{Time.now}")
    puts

    # System information table
    system_info = [
      ['CPU Usage', '45%', '🟢 Normal'],
      ['Memory Usage', '67%', '🟡 Warning'],
      ['Disk Usage', '23%', '🟢 Normal'],
      ['Network I/O', '1.2 MB/s', '🟢 Normal'],
      ['Active Processes', '342', '🟢 Normal']
    ]

    table = TTY::Table.new(
      header: ['Component', 'Value', 'Status'],
      rows: system_info
    )

    puts table.render(:unicode, padding: [0, 1])

    # Services status
    puts
    puts @pastel.blue.bold("Services Status:")
    services = [
      ['Web Server', '🟢 Running', 'Port 3000'],
      ['Database', '🟢 Running', 'PostgreSQL 13'],
      ['Redis', '🟢 Running', 'Version 6.2'],
      ['Background Jobs', '🟡 Warning', '5 failed jobs']
    ]

    services_table = TTY::Table.new(
      header: ['Service', 'Status', 'Details'],
      rows: services
    )

    puts services_table.render(:unicode, padding: [0, 1])
  end

  def human_file_size(size)
    units = %w[B KB MB GB TB]
    unit_index = 0

    while size >= 1024 && unit_index < units.length - 1
      size /= 1024.0
      unit_index += 1
    end

    if unit_index == 0
      "#{size} #{units[unit_index]}"
    else
      "#{size.round(1)} #{units[unit_index]}"
    end
  end
end

# Enhanced CLI with argument parsing
class EnhancedCLI
  def self.run(args)
    if args.empty?
      InteractiveCLI.new.run
    else
      # Command-line mode
      command = args.shift
      case command
      when 'status'
        show_status
      when 'backup'
        perform_backup(args)
      when 'deploy'
        deploy_application(args)
      else
        puts "Unknown command: #{command}"
        show_help
      end
    end
  end

  def self.show_help
    puts <<~HELP
      Usage: enhanced-cli [command] [options]

      Commands:
        status              Show system status
        backup [database]   Backup database
        deploy [env]        Deploy to environment

      Run without arguments for interactive mode.
    HELP
  end
end

# Run the application
if __FILE__ == $0
  EnhancedCLI.run(ARGV)
end

CLI Testing and Packaging

Test your CLI applications thoroughly and package them for distribution as gems or executables.

Testing CLI Applications

# spec/cli_spec.rb
require 'rspec'
require 'open3'
require 'tempfile'

describe 'CLI Application' do
  let(:cli_path) { File.expand_path('../bin/mycli', __dir__) }

  describe 'help command' do
    it 'shows help information' do
      stdout, stderr, status = Open3.capture3("#{cli_path} --help")

      expect(status).to be_success
      expect(stdout).to include('Usage:')
      expect(stdout).to include('Options:')
      expect(stderr).to be_empty
    end
  end

  describe 'version command' do
    it 'shows version number' do
      stdout, stderr, status = Open3.capture3("#{cli_path} --version")

      expect(status).to be_success
      expect(stdout).to match(/\d+\.\d+\.\d+/)
      expect(stderr).to be_empty
    end
  end

  describe 'file processing' do
    let(:test_file) { Tempfile.new(['test', '.txt']) }

    before do
      test_file.write("Hello, World!\nSecond line\n")
      test_file.close
    end

    after do
      test_file.unlink
    end

    it 'processes files correctly' do
      stdout, stderr, status = Open3.capture3(
        "#{cli_path} --format json #{test_file.path}"
      )

      expect(status).to be_success
      expect(stderr).to be_empty

      output = JSON.parse(stdout)
      expect(output['lines']).to eq(2)
      expect(output['content']).to include('Hello, World!')
    end

    it 'handles missing files gracefully' do
      stdout, stderr, status = Open3.capture3(
        "#{cli_path} nonexistent_file.txt"
      )

      expect(status).not_to be_success
      expect(stderr).to include('No such file')
    end
  end

  describe 'option validation' do
    it 'validates format option' do
      stdout, stderr, status = Open3.capture3(
        "#{cli_path} --format invalid_format file.txt"
      )

      expect(status).not_to be_success
      expect(stderr).to include('invalid argument')
    end

    it 'requires at least one file' do
      stdout, stderr, status = Open3.capture3("#{cli_path}")

      expect(status).not_to be_success
      expect(stdout).to include('No files specified')
    end
  end

  describe 'integration with external tools' do
    it 'handles large files efficiently' do
      large_file = Tempfile.new(['large', '.txt'])

      # Create a 1MB file
      1000.times { large_file.write("A" * 1024) }
      large_file.close

      start_time = Time.now
      stdout, stderr, status = Open3.capture3(
        "#{cli_path} --format text #{large_file.path}"
      )
      end_time = Time.now

      expect(status).to be_success
      expect(end_time - start_time).to be < 5.0  # Should complete within 5 seconds

      large_file.unlink
    end
  end
end

# Integration testing helper
class CLITestHelper
  def self.run_cli(command, options = {})
    env = options[:env] || {}
    input = options[:input]
    timeout = options[:timeout] || 30

    Open3.popen3(env, command) do |stdin, stdout, stderr, wait_thread|
      if input
        stdin.write(input)
        stdin.close
      end

      # Read output with timeout
      output = { stdout: '', stderr: '', status: nil }

      begin
        Timeout.timeout(timeout) do
          output[:stdout] = stdout.read
          output[:stderr] = stderr.read
          output[:status] = wait_thread.value
        end
      rescue Timeout::Error
        Process.kill('TERM', wait_thread.pid)
        output[:stderr] = 'Command timed out'
        output[:status] = OpenStruct.new(success?: false, exitstatus: 124)
      end

      output
    end
  end

  def self.with_temp_config(config_content)
    config_file = Tempfile.new(['config', '.yml'])
    config_file.write(config_content)
    config_file.close

    yield config_file.path
  ensure
    config_file&.unlink
  end

  def self.capture_interactive_session(commands)
    # Helper for testing interactive CLIs
    input = commands.join("\n") + "\n"
    run_cli('bin/interactive_cli', input: input)
  end
end

# Example usage in tests
describe 'Interactive CLI' do
  it 'handles user interaction correctly' do
    result = CLITestHelper.capture_interactive_session([
      '1',      # Select first menu option
      'test',   # Enter test data
      'y',      # Confirm action
      'q'       # Quit
    ])

    expect(result[:status]).to be_success
    expect(result[:stdout]).to include('Action completed')
  end

  it 'respects configuration files' do
    config = YAML.dump({
      'database' => { 'host' => 'localhost' },
      'api' => { 'key' => 'test-key' }
    })

    CLITestHelper.with_temp_config(config) do |config_path|
      result = CLITestHelper.run_cli(
        "bin/mycli --config #{config_path} status"
      )

      expect(result[:status]).to be_success
      expect(result[:stdout]).to include('localhost')
    end
  end
end

Best Practices

CLI Development Best Practices

  • Follow UNIX conventions for option naming and behavior
  • Provide comprehensive help with examples and clear descriptions
  • Handle errors gracefully with meaningful error messages
  • Support both interactive and non-interactive modes
  • Validate input early and provide clear feedback
  • Use progress indicators for long-running operations
  • Make commands composable and scriptable
  • Test thoroughly including edge cases and error conditions

💡 Pro Tips

  • Use gems like tty-prompt, tty-progressbar, and colorize for rich interactions
  • Consider creating both a gem and a standalone executable for distribution
  • Use Bundler.with_clean_env when executing external commands
  • Implement signal handling for graceful shutdown on Ctrl+C
  • Add shell completion scripts for better user experience

🚀 Next Steps

Test your development tools knowledge with our comprehensive quiz covering Rake, Guard, and CLI development techniques.