GLI

Git風のインターフェースを持つコマンドラインツールを作成するためのRubyライブラリ。

rubyclicommand-linegit-like

フレームワーク

GLI

概要

GLIは、Git風のインターフェースを持つコマンドラインツールを作成するためのRubyライブラリです。サブコマンドを持つ複雑なCLIアプリケーションの構築に適しており、Git風のインターフェースを好む開発者に選ばれています。豊富な機能と柔軟なアーキテクチャにより、プロフェッショナルなCLIツールの開発を支援します。

詳細

GLI (Git-Like Interface) は、Gitのようなサブコマンド構造を持つCLIアプリケーションを構築するためのライブラリです。コマンド、サブコマンド、グローバルオプション、コマンド固有のオプションを組み合わせた複雑な階層構造を簡単に作成できます。

主な特徴

  • Git風インターフェース: サブコマンドベースの階層構造
  • DSLベース設計: 宣言的で読みやすいコマンド定義
  • 豊富なオプション: グローバル・コマンド固有オプションをサポート
  • 自動ヘルプ生成: 美しく整理されたヘルプページを自動生成
  • 入力バリデーション: 引数とオプションの検証機能
  • 設定ファイル: 自動的な設定ファイル管理
  • スケルトン生成: プロジェクトテンプレートの自動生成
  • テスト支援: Aruba統合によるテスト機能

メリット・デメリット

メリット

  • Git風UI: 親しみやすいサブコマンド構造
  • 豊富な機能: プロフェッショナルなCLI開発に必要な機能が揃っている
  • 宣言的API: 読みやすく保守しやすいコード
  • 自動生成: ヘルプ、設定ファイル、テンプレートの自動生成
  • 柔軟性: 複雑なコマンド構造にも対応
  • テスト可能: Aruba統合による包括的テスト
  • ドキュメント: 充実した公式ドキュメント

デメリット

  • 学習コスト: DSLの理解に時間が必要
  • オーバーヘッド: シンプルなツールには重い場合がある
  • Ruby依存: Ruby環境が必要
  • 複雑性: 小規模プロジェクトには過剰な機能

主要リンク

書き方の例

インストール

gem install gli

基本的な使用例

#!/usr/bin/env ruby

require 'gli'

include GLI::App

program_desc 'ファイル管理ツール'
version '1.0.0'

# グローバルオプション
desc 'ログを有効にする'
switch [:l, :log]

desc 'デバッグモードを有効にする'
switch [:d, :debug]

desc '設定ファイルのパス'
flag [:c, :config], default_value: '~/.filemanager'

# listコマンド
desc 'ファイル一覧を表示'
long_desc <<-EOF
指定されたディレクトリのファイル一覧を表示します。
デフォルトでは現在のディレクトリを対象とします。
EOF

command :list do |c|
  c.desc 'すべてのファイルを表示(隠しファイル含む)'
  c.switch [:a, :all]
  
  c.desc '詳細表示'
  c.switch [:l, :long]
  
  c.desc '対象ディレクトリ'
  c.flag [:d, :directory], default_value: '.'
  
  c.action do |global_options, options, args|
    directory = options[:directory]
    show_all = options[:all]
    long_format = options[:long]
    
    puts "ディレクトリ: #{directory}"
    puts "すべて表示: #{show_all ? 'はい' : 'いいえ'}"
    puts "詳細表示: #{long_format ? 'はい' : 'いいえ'}"
    
    # ファイル一覧の取得と表示
    pattern = show_all ? File.join(directory, '*') : File.join(directory, '[^.]*')
    files = Dir.glob(pattern)
    
    files.each do |file|
      if long_format
        stat = File.stat(file)
        puts "#{File.basename(file)}\t#{stat.size}bytes\t#{stat.mtime}"
      else
        puts File.basename(file)
      end
    end
  end
end

# copyコマンド
desc 'ファイルをコピー'
arg_name 'source destination'
command :copy do |c|
  c.desc '強制上書き'
  c.switch [:f, :force]
  
  c.desc '再帰的コピー'
  c.switch [:r, :recursive]
  
  c.action do |global_options, options, args|
    if args.length != 2
      raise 'コピー元とコピー先の両方を指定してください'
    end
    
    source, destination = args
    force = options[:force]
    recursive = options[:recursive]
    
    puts "コピー中: #{source} -> #{destination}"
    puts "強制モード: #{force ? 'はい' : 'いいえ'}"
    puts "再帰モード: #{recursive ? 'はい' : 'いいえ'}"
    
    # コピー処理の実装
    if File.directory?(source) && recursive
      FileUtils.cp_r(source, destination, force: force)
    elsif File.file?(source)
      if force || !File.exist?(destination)
        FileUtils.cp(source, destination)
      else
        puts "エラー: #{destination} は既に存在します。--force を使用してください。"
        exit 1
      end
    else
      puts "エラー: #{source} が見つかりません。"
      exit 1
    end
    
    puts "コピー完了"
  end
end

# deleteコマンド
desc 'ファイルを削除'
arg_name 'files...'
command :delete do |c|
  c.desc '確認なしで削除'
  c.switch [:f, :force]
  
  c.desc '再帰的削除'
  c.switch [:r, :recursive]
  
  c.action do |global_options, options, args|
    if args.empty?
      raise '削除するファイルを指定してください'
    end
    
    force = options[:force]
    recursive = options[:recursive]
    
    args.each do |file|
      unless File.exist?(file)
        puts "警告: #{file} が見つかりません"
        next
      end
      
      # 確認プロンプト
      unless force
        print "#{file} を削除しますか? [y/N]: "
        answer = STDIN.gets.chomp.downcase
        next unless answer == 'y' || answer == 'yes'
      end
      
      begin
        if File.directory?(file) && recursive
          FileUtils.rm_rf(file)
        elsif File.file?(file)
          FileUtils.rm(file)
        else
          puts "エラー: #{file} はディレクトリです。-r オプションを使用してください。"
          next
        end
        
        puts "削除完了: #{file}"
      rescue => e
        puts "エラー: #{file} の削除に失敗しました - #{e.message}"
      end
    end
  end
end

# pre/postフック
pre do |global, command, options, args|
  if global[:log]
    puts "コマンド実行: #{command.name}"
    puts "グローバルオプション: #{global}"
    puts "コマンドオプション: #{options}"
    puts "引数: #{args}"
  end
  
  # 設定ファイルの読み込み
  config_file = File.expand_path(global[:config])
  if File.exist?(config_file)
    puts "設定ファイル読み込み: #{config_file}" if global[:debug]
  end
  
  true # 処理を続行
end

post do |global, command, options, args|
  puts "コマンド '#{command.name}' の実行が完了しました" if global[:log]
end

# エラーハンドリング
on_error do |exception|
  puts "エラーが発生しました: #{exception.message}"
  true # GLIにエラーハンドリングを委譲
end

exit run(ARGV)

より高度な例(サブコマンド階層)

#!/usr/bin/env ruby

require 'gli'
require 'json'
require 'yaml'

include GLI::App

program_desc 'プロジェクト管理ツール'
version '2.0.0'

# グローバルオプション
desc '詳細出力'
switch [:v, :verbose]

desc '設定ファイル'
flag [:c, :config], default_value: '~/.project-manager.yml'

# プロジェクト管理コマンドグループ
desc 'プロジェクト管理'
command :project do |c|
  
  # project create サブコマンド
  c.desc 'プロジェクトを作成'
  c.long_desc <<-EOF
新しいプロジェクトを作成します。
テンプレートから基本構造を生成し、必要な設定ファイルを作成します。
EOF
  c.command :create do |create|
    create.desc 'プロジェクト名'
    create.flag [:n, :name], required: true
    
    create.desc 'プロジェクトタイプ'
    create.flag [:t, :type], default_value: 'web', must_match: %w[web api cli library]
    
    create.desc 'テンプレート'
    create.flag [:template], default_value: 'default'
    
    create.desc '作成先ディレクトリ'
    create.flag [:d, :directory], default_value: '.'
    
    create.action do |global, options, args|
      name = options[:name]
      type = options[:type]
      template = options[:template]
      directory = options[:directory]
      
      project_path = File.join(directory, name)
      
      if File.exist?(project_path)
        puts "エラー: プロジェクト '#{name}' は既に存在します"
        exit 1
      end
      
      puts "プロジェクトを作成中: #{name}" if global[:verbose]
      puts "  タイプ: #{type}"
      puts "  テンプレート: #{template}"
      puts "  パス: #{project_path}"
      
      # プロジェクト構造の作成
      FileUtils.mkdir_p(project_path)
      
      case type
      when 'web'
        create_web_project(project_path, name, template)
      when 'api'
        create_api_project(project_path, name, template)
      when 'cli'
        create_cli_project(project_path, name, template)
      when 'library'
        create_library_project(project_path, name, template)
      end
      
      puts "プロジェクト '#{name}' が正常に作成されました"
    end
  end
  
  # project list サブコマンド
  c.desc 'プロジェクト一覧を表示'
  c.command :list do |list|
    list.desc 'JSONフォーマットで出力'
    list.switch [:json]
    
    list.desc 'プロジェクトディレクトリ'
    list.flag [:d, :directory], default_value: '.'
    
    list.action do |global, options, args|
      directory = options[:directory]
      json_format = options[:json]
      
      projects = find_projects(directory)
      
      if json_format
        puts JSON.pretty_generate(projects)
      else
        puts "プロジェクト一覧 (#{directory}):"
        projects.each do |project|
          puts "  #{project[:name]} (#{project[:type]}) - #{project[:path]}"
        end
      end
    end
  end
  
  # project delete サブコマンド
  c.desc 'プロジェクトを削除'
  c.command :delete do |delete|
    delete.desc 'プロジェクト名'
    delete.flag [:n, :name], required: true
    
    delete.desc '確認なしで削除'
    delete.switch [:f, :force]
    
    delete.action do |global, options, args|
      name = options[:name]
      force = options[:force]
      
      project_path = File.join('.', name)
      
      unless File.exist?(project_path)
        puts "エラー: プロジェクト '#{name}' が見つかりません"
        exit 1
      end
      
      unless force
        print "プロジェクト '#{name}' を削除しますか? [y/N]: "
        answer = STDIN.gets.chomp.downcase
        unless answer == 'y' || answer == 'yes'
          puts "削除をキャンセルしました"
          exit 0
        end
      end
      
      FileUtils.rm_rf(project_path)
      puts "プロジェクト '#{name}' を削除しました"
    end
  end
end

# 設定管理コマンドグループ
desc '設定管理'
command :config do |c|
  
  c.desc '設定を表示'
  c.command :show do |show|
    show.action do |global, options, args|
      config = load_config(global[:config])
      puts YAML.dump(config)
    end
  end
  
  c.desc '設定値を設定'
  c.arg_name 'key value'
  c.command :set do |set|
    set.action do |global, options, args|
      if args.length != 2
        raise 'キーと値の両方を指定してください'
      end
      
      key, value = args
      config = load_config(global[:config])
      
      # ネストしたキーをサポート (例: database.host)
      keys = key.split('.')
      current = config
      keys[0..-2].each do |k|
        current[k] ||= {}
        current = current[k]
      end
      current[keys.last] = value
      
      save_config(global[:config], config)
      puts "設定を更新しました: #{key} = #{value}"
    end
  end
  
  c.desc '設定値を取得'
  c.arg_name 'key'
  c.command :get do |get|
    get.action do |global, options, args|
      if args.empty?
        raise 'キーを指定してください'
      end
      
      key = args.first
      config = load_config(global[:config])
      
      keys = key.split('.')
      value = keys.reduce(config) { |hash, k| hash[k] if hash }
      
      if value
        puts "#{key}: #{value}"
      else
        puts "設定が見つかりません: #{key}"
        exit 1
      end
    end
  end
end

# ヘルパーメソッド
def create_web_project(path, name, template)
  FileUtils.mkdir_p("#{path}/src")
  FileUtils.mkdir_p("#{path}/public")
  FileUtils.mkdir_p("#{path}/tests")
  
  File.write("#{path}/package.json", {
    name: name,
    version: "1.0.0",
    description: "Web project created with project manager",
    main: "src/index.js"
  }.to_json)
  
  File.write("#{path}/README.md", "# #{name}\n\nWeb project created with project manager\n")
end

def create_api_project(path, name, template)
  FileUtils.mkdir_p("#{path}/src")
  FileUtils.mkdir_p("#{path}/config")
  FileUtils.mkdir_p("#{path}/tests")
  
  File.write("#{path}/Gemfile", "source 'https://rubygems.org'\n\ngem 'sinatra'\ngem 'rspec'\n")
  File.write("#{path}/README.md", "# #{name}\n\nAPI project created with project manager\n")
end

def create_cli_project(path, name, template)
  FileUtils.mkdir_p("#{path}/lib")
  FileUtils.mkdir_p("#{path}/bin")
  FileUtils.mkdir_p("#{path}/tests")
  
  File.write("#{path}/#{name}.gemspec", "# #{name}.gemspec\nGem::Specification.new do |s|\n  s.name = '#{name}'\n  s.version = '1.0.0'\nend\n")
  File.write("#{path}/README.md", "# #{name}\n\nCLI project created with project manager\n")
end

def create_library_project(path, name, template)
  FileUtils.mkdir_p("#{path}/lib")
  FileUtils.mkdir_p("#{path}/spec")
  
  File.write("#{path}/#{name}.gemspec", "# #{name}.gemspec\nGem::Specification.new do |s|\n  s.name = '#{name}'\n  s.version = '1.0.0'\nend\n")
  File.write("#{path}/README.md", "# #{name}\n\nLibrary project created with project manager\n")
end

def find_projects(directory)
  projects = []
  Dir.glob("#{directory}/*").each do |path|
    next unless File.directory?(path)
    
    name = File.basename(path)
    type = detect_project_type(path)
    
    projects << {
      name: name,
      type: type,
      path: path
    }
  end
  projects
end

def detect_project_type(path)
  return 'web' if File.exist?("#{path}/package.json")
  return 'ruby' if File.exist?("#{path}/Gemfile")
  return 'cli' if File.exist?("#{path}/bin")
  'unknown'
end

def load_config(config_path)
  expanded_path = File.expand_path(config_path)
  return {} unless File.exist?(expanded_path)
  
  YAML.load_file(expanded_path) || {}
rescue
  {}
end

def save_config(config_path, config)
  expanded_path = File.expand_path(config_path)
  FileUtils.mkdir_p(File.dirname(expanded_path))
  File.write(expanded_path, YAML.dump(config))
end

# エラーハンドリング
on_error do |exception|
  case exception
  when GLI::BadCommandLine
    puts "コマンドラインエラー: #{exception.message}"
    puts "help コマンドを実行してください"
  else
    puts "エラー: #{exception.message}"
  end
  true
end

exit run(ARGV)

実行例

# 基本的なファイル操作
./filemanager list --all --long --directory /tmp
./filemanager copy source.txt destination.txt --force
./filemanager delete old_file.txt --force

# プロジェクト管理
./projectmanager project create --name myapp --type web --template react
./projectmanager project list --json
./projectmanager project delete --name myapp

# 設定管理
./projectmanager config set database.host localhost
./projectmanager config get database.host
./projectmanager config show