Thor

コマンドラインツールを構築するための人気のRubyライブラリ。Railsでも使用されており、使いやすく、テストしやすく、拡張しやすい設計です。

rubyclicommand-linegenerator

GitHub概要

rails/thor

Thor is a toolkit for building powerful command-line interfaces.

ホームページ:http://whatisthor.com/
スター5,179
ウォッチ69
フォーク551
作成日:2008年5月7日
言語:Ruby
ライセンス:MIT License

トピックス

なし

スター履歴

rails/thor Star History
データ取得日時: 2025/7/25 02:06

フレームワーク

Thor

概要

ThorはRubyで強力なコマンドラインインターフェースを構築するためのツールキットです。Ruby on Railsプロジェクトで公式に使用されており、自己文書化機能、オプション解析、コマンド実行機能を提供します。Rakeのような構文でコマンドを定義でき、引数とオプションの処理を簡潔に記述できます。Ruby 2.6.0以降をサポートし、BundlerやVagrant、Rails自体など多くの著名なRubyプロジェクトで採用されている実績あるライブラリです。

詳細

Thorは、コマンドライン実行、オプション解析、「USAGE:」バナーの作成にまつわる煩雑な作業を取り除く、シンプルで効率的なツールです。Rakeビルドツールの代替としても使用でき、特にコード生成やタスク自動化において威力を発揮します。

Ruby on Railsでの採用

ThorはRails v3以降でコード生成に使用されており、以下のようなコマンドがThorで実装されています:

  • rails new - 新しいRailsアプリケーションの作成
  • rails console - Railsコンソールの起動
  • rails server - 開発サーバーの起動
  • rails routes - ルート一覧の表示
  • rails generate - ジェネレーターの実行

主な特徴

  • 自動ドキュメント生成: コマンドとオプションの説明から自動的にヘルプメッセージを生成
  • メソッドベースのコマンド定義: Rubyクラスの公開メソッドが自動的にコマンドになる
  • オプション管理: 型安全なオプション処理(string, hash, array, numeric, boolean)
  • 排他オプション: exclusiveat_least_oneによる高度なオプション制御
  • コマンドエイリアス: mapメソッドによるコマンドのエイリアス設定
  • デフォルトコマンド: default_commandによる暗黙のコマンド実行
  • サブコマンド: registerメソッドによる階層的なコマンド構造
  • Thor::Group: 連続実行タスクやジェネレーター向けの特殊クラス
  • Thor::Actions: ファイル操作やユーザー対話のためのヘルパーメソッド

最新動向(2024年)

  • Ruby 2.6.0以降対応: 最新のRubyバージョンに対応
  • 継続的なメンテナンス: バグ修正と機能改善が継続的に行われている
  • エコシステムの安定性: Railsコミュニティの中核ツールとして安定した開発が続いている

Thor vs Rake

Thorは以下の点でRakeと異なります:

  • コマンドライン特化: CLIアプリケーション構築に最適化
  • 引数処理: 位置引数とオプションの自然な処理
  • ヘルプ生成: 自動的なドキュメント生成
  • 型安全性: オプションの型チェック機能
  • ユーザー対話: 組み込みの対話型機能

メリット・デメリット

メリット

  • Railsとの親和性: Rails公式ツールとして完全に統合されている
  • 豊富な実績: Bundler、Vagrant、Rails等の主要プロジェクトで使用
  • 直感的なAPI: Rubyらしい自然な記述でコマンドを定義できる
  • 自動ドキュメント: descメソッドで自動的にヘルプを生成
  • 型安全なオプション: オプションの型を指定して安全に処理
  • ファイル操作支援: Thor::Actionsによる豊富なファイル操作ヘルパー
  • ユーザー対話: askyes?no?等の対話メソッド内蔵
  • テスト支援: CLIアプリケーションのテストが容易
  • Ruby標準: Rubyエコシステムの事実上の標準

デメリット

  • Ruby依存: Rubyプロジェクト以外では使用できない
  • 学習コスト: 多機能ゆえに全機能を把握するのに時間がかかる
  • 設計思想: システムツール向けの設計のため、アプリケーションユーザー入力は非推奨
  • パフォーマンス: Rubyの起動時間が影響する場合がある(特に小さなコマンド)

主要リンク

書き方の例

基本的なコマンド定義

#!/usr/bin/env ruby

require 'thor'

class MyCLI < Thor
  desc "hello NAME", "NAME に挨拶します"
  def hello(name)
    puts "こんにちは、#{name}さん!"
  end

  desc "goodbye NAME", "NAME にお別れの挨拶をします"
  method_option :polite, type: :boolean, default: false, aliases: "-p", desc: "丁寧な挨拶"
  def goodbye(name)
    if options[:polite]
      puts "#{name}さん、さようなら"
    else
      puts "じゃあね、#{name}!"
    end
  end
end

MyCLI.start(ARGV) if __FILE__ == $0

オプションと引数の詳細な例

#!/usr/bin/env ruby

require 'thor'

class FileManager < Thor
  desc "process FILES", "複数のファイルを処理します"
  method_option :format, type: :string, default: "txt", aliases: "-f", desc: "出力形式"
  method_option :verbose, type: :boolean, default: false, aliases: "-v", desc: "詳細出力"
  method_option :exclude, type: :array, default: [], aliases: "-e", desc: "除外パターン"
  method_option :config, type: :hash, default: {}, aliases: "-c", desc: "設定オプション"
  method_option :count, type: :numeric, default: 1, aliases: "-n", desc: "処理回数"
  def process(*files)
    puts "処理形式: #{options[:format]}"
    puts "詳細モード: #{options[:verbose] ? '有効' : '無効'}"
    puts "除外パターン: #{options[:exclude].join(', ')}" unless options[:exclude].empty?
    puts "設定: #{options[:config]}" unless options[:config].empty?
    puts "処理回数: #{options[:count]}"
    
    files.each do |file|
      puts "処理中: #{file}"
      puts "  詳細情報..." if options[:verbose]
    end
  end
end

FileManager.start(ARGV) if __FILE__ == $0

サブコマンドの例

#!/usr/bin/env ruby

require 'thor'

# ユーザー管理サブコマンド
class UserCLI < Thor
  desc "create NAME EMAIL", "新しいユーザーを作成"
  method_option :admin, type: :boolean, default: false, desc: "管理者権限"
  def create(name, email)
    puts "ユーザーを作成中..."
    puts "  名前: #{name}"
    puts "  メール: #{email}"
    puts "  権限: #{options[:admin] ? '管理者' : '一般ユーザー'}"
  end

  desc "delete USER_ID", "ユーザーを削除"
  method_option :force, type: :boolean, default: false, aliases: "-f", desc: "確認なしで削除"
  def delete(user_id)
    unless options[:force]
      exit unless yes?("ユーザー #{user_id} を削除しますか? [y/N]")
    end
    puts "ユーザー #{user_id} を削除しました"
  end

  desc "list", "ユーザー一覧を表示"
  method_option :format, type: :string, default: "table", desc: "表示形式 (table/json)"
  def list
    users = [
      { id: 1, name: "田中太郎", email: "[email protected]" },
      { id: 2, name: "佐藤花子", email: "[email protected]" }
    ]

    case options[:format]
    when "json"
      require 'json'
      puts JSON.pretty_generate(users)
    else
      puts "ID\t名前\t\tメール"
      puts "-" * 40
      users.each do |user|
        puts "#{user[:id]}\t#{user[:name]}\t#{user[:email]}"
      end
    end
  end
end

# プロジェクト管理サブコマンド
class ProjectCLI < Thor
  desc "init NAME", "新しいプロジェクトを初期化"
  method_option :template, type: :string, default: "basic", aliases: "-t", desc: "プロジェクトテンプレート"
  def init(name)
    puts "プロジェクト '#{name}' を初期化中..."
    puts "テンプレート: #{options[:template]}"
    
    empty_directory(name)
    create_file("#{name}/README.md", "# #{name}\n\n新しいプロジェクトです。")
    create_file("#{name}/Gemfile", "source 'https://rubygems.org'\n\n# gems here")
  end

  desc "build", "プロジェクトをビルド"
  method_option :environment, type: :string, default: "development", aliases: "-e", desc: "環境"
  def build
    puts "#{options[:environment]} 環境でビルド中..."
    puts "ビルド完了"
  end
end

# メインCLI
class MainCLI < Thor
  desc "user SUBCOMMAND ...ARGS", "ユーザー管理"
  subcommand "user", UserCLI

  desc "project SUBCOMMAND ...ARGS", "プロジェクト管理"
  subcommand "project", ProjectCLI

  desc "version", "バージョン情報を表示"
  def version
    puts "MyApp v1.0.0"
  end

  # デフォルトコマンドを設定
  default_command :help
end

MainCLI.start(ARGV) if __FILE__ == $0

Thor::Groupを使ったジェネレーター

#!/usr/bin/env ruby

require 'thor'

class AppGenerator < Thor::Group
  include Thor::Actions

  # ジェネレーターの説明
  desc "アプリケーションの雛形を生成します"

  # 引数の定義
  argument :name, type: :string, desc: "アプリケーション名"
  
  # オプションの定義
  class_option :database, type: :string, default: "sqlite", desc: "データベース種別"
  class_option :testing, type: :string, default: "rspec", desc: "テストフレームワーク"
  class_option :skip_git, type: :boolean, default: false, desc: "Git初期化をスキップ"

  # テンプレートファイルのソースパス
  def self.source_root
    File.dirname(__FILE__) + "/templates"
  end

  # 以下のメソッドが順次実行される

  def create_app_directory
    say "アプリケーションディレクトリを作成中...", :green
    empty_directory(name)
  end

  def create_config_files
    say "設定ファイルを作成中...", :green
    template("config.rb.tt", "#{name}/config/application.rb")
    template("database.yml.tt", "#{name}/config/database.yml")
  end

  def create_app_files
    say "アプリケーションファイルを作成中...", :green
    create_file "#{name}/app/models/.keep"
    create_file "#{name}/app/controllers/.keep"
    create_file "#{name}/app/views/.keep"
  end

  def create_test_files
    say "テストファイルを作成中...", :green
    case options[:testing]
    when "rspec"
      template("spec_helper.rb.tt", "#{name}/spec/spec_helper.rb")
      create_file "#{name}/spec/models/.keep"
    when "minitest"
      create_file "#{name}/test/test_helper.rb"
      create_file "#{name}/test/models/.keep"
    end
  end

  def create_gemfile
    say "Gemfileを作成中...", :green
    template("Gemfile.tt", "#{name}/Gemfile")
  end

  def init_git
    return if options[:skip_git]
    
    say "Gitリポジトリを初期化中...", :green
    inside(name) do
      run("git init")
      create_file(".gitignore", "/tmp\n/log/*.log\n")
      run("git add .")
      run("git commit -m 'Initial commit'")
    end
  end

  def show_readme
    say "\n" + "="*50, :green
    say "アプリケーション '#{name}' が正常に作成されました!", :green
    say "="*50, :green
    say ""
    say "次のステップ:"
    say "  cd #{name}"
    say "  bundle install"
    say "  # 開発を開始してください"
    say ""
  end
end

# 使用方法: ruby generator.rb myapp --database=postgresql --testing=rspec
AppGenerator.start(ARGV) if __FILE__ == $0

Thor::Actionsを使ったファイル操作

#!/usr/bin/env ruby

require 'thor'

class FileOperations < Thor
  include Thor::Actions

  desc "setup PROJECT_NAME", "プロジェクトのセットアップを行います"
  method_option :force, type: :boolean, default: false, aliases: "-f", desc: "既存ファイルを上書き"
  method_option :pretend, type: :boolean, default: false, aliases: "-p", desc: "実際には実行しない(ドライラン)"
  def setup(project_name)
    self.destination_root = project_name
    
    # ディレクトリ作成
    empty_directory("src")
    empty_directory("tests")
    empty_directory("docs")

    # ファイル作成
    create_file("README.md", "# #{project_name}\n\n新しいプロジェクトです。")
    create_file("src/main.rb", "#!/usr/bin/env ruby\n\nputs 'Hello, #{project_name}!'")

    # テンプレートからファイル作成
    template("config.rb.erb", "config/#{project_name}.rb", {
      project_name: project_name,
      created_at: Time.now
    })

    # ファイルコピー
    copy_file("templates/gitignore", ".gitignore")

    # ディレクトリ内での操作
    inside("src") do
      create_file("utils.rb", "# ユーティリティクラス")
    end

    # コマンド実行
    run("chmod +x src/main.rb")

    # ユーザーに質問
    if yes?("Gitリポジトリを初期化しますか?")
      run("git init")
      run("git add .")
      run("git commit -m 'Initial commit'")
    end

    # 結果表示
    say("プロジェクト #{project_name} のセットアップが完了しました!", :green)
  end

  desc "interactive", "対話的なセットアップ"
  def interactive
    # 各種質問メソッド
    name = ask("プロジェクト名を入力してください:")
    
    database = ask("データベースを選択してください:", limited_to: %w[sqlite postgresql mysql])
    
    use_git = yes?("Gitを使用しますか?")
    
    if use_git
      git_remote = ask("リモートリポジトリのURLを入力してください(オプション):")
    end

    # 確認
    say("\n設定内容:", :blue)
    say("  プロジェクト名: #{name}")
    say("  データベース: #{database}")
    say("  Git使用: #{use_git ? 'はい' : 'いいえ'}")
    say("  リモートURL: #{git_remote}") if git_remote && !git_remote.empty?

    if yes?("\nこの設定でプロジェクトを作成しますか?")
      say("プロジェクトを作成中...", :green)
      # セットアップ処理...
      say("完了!", :green)
    else
      say("キャンセルされました", :red)
    end
  end

  private

  def self.source_root
    File.dirname(__FILE__) + "/templates"
  end
end

FileOperations.start(ARGV) if __FILE__ == $0

排他オプションと必須オプション

#!/usr/bin/env ruby

require 'thor'

class AdvancedOptions < Thor
  desc "deploy", "アプリケーションをデプロイ"
  
  # 排他オプション(どちらか一方のみ指定可能)
  method_option :staging, type: :boolean, desc: "ステージング環境にデプロイ"
  method_option :production, type: :boolean, desc: "本番環境にデプロイ"
  
  # 最低一つ必要なオプション
  method_option :version, type: :string, desc: "デプロイするバージョン"
  method_option :branch, type: :string, desc: "デプロイするブランチ"
  
  # その他のオプション
  method_option :force, type: :boolean, default: false, desc: "強制デプロイ"
  method_option :dry_run, type: :boolean, default: false, desc: "ドライラン"

  def deploy
    # 排他オプションチェック
    if options[:staging] && options[:production]
      say("エラー: --staging と --production は同時に指定できません", :red)
      exit(1)
    end

    unless options[:staging] || options[:production]
      say("エラー: --staging または --production のいずれかを指定してください", :red)
      exit(1)
    end

    # 最低一つ必要なオプションチェック
    unless options[:version] || options[:branch]
      say("エラー: --version または --branch のいずれかを指定してください", :red)
      exit(1)
    end

    # デプロイ実行
    env = options[:staging] ? "staging" : "production"
    target = options[:version] || options[:branch]
    
    say("#{env} 環境に #{target} をデプロイ中...", :green)
    
    if options[:dry_run]
      say("(ドライランモード - 実際にはデプロイされません)", :yellow)
    end

    if options[:force]
      say("強制デプロイが有効です", :yellow)
    end

    say("デプロイ完了!", :green) unless options[:dry_run]
  end
end

AdvancedOptions.start(ARGV) if __FILE__ == $0

エラーハンドリングとテスト

#!/usr/bin/env ruby

require 'thor'

class MyTool < Thor
  desc "risky_operation FILE", "リスクのある操作を実行"
  method_option :backup, type: :boolean, default: true, desc: "バックアップを作成"
  def risky_operation(file)
    begin
      unless File.exist?(file)
        say("エラー: ファイル '#{file}' が見つかりません", :red)
        exit(1)
      end

      if options[:backup]
        backup_file = "#{file}.backup.#{Time.now.to_i}"
        FileUtils.cp(file, backup_file)
        say("バックアップを作成: #{backup_file}", :blue)
      end

      # 何らかの処理...
      say("操作完了", :green)

    rescue => e
      say("エラーが発生しました: #{e.message}", :red)
      exit(1)
    end
  end

  # プライベートメソッドはコマンドにならない
  private

  def validate_file(file)
    File.exist?(file) && File.readable?(file)
  end
end

# テスト用のヘルパー
if $0 == __FILE__
  if ARGV.include?("--test")
    require 'minitest/autorun'
    
    class MyToolTest < Minitest::Test
      def setup
        @cli = MyTool.new
      end

      def test_help_output
        output = capture_output { MyTool.start(["help"]) }
        assert_includes output, "risky_operation"
      end

      private

      def capture_output
        old_stdout = $stdout
        $stdout = StringIO.new
        yield
        $stdout.string
      ensure
        $stdout = old_stdout
      end
    end
  else
    MyTool.start(ARGV)
  end
end