Skip to content
Daniel Lv edited this page May 3, 2014 · 1 revision

使用 Thor 打造命令行工具

Thor 是什么?

用来构建命令行的工具包,是个 RubyGem。超过 200+ 以上的 Gems 皆选择采用 Thor 来打造命令行工具:如 Rails Generator、Vagrant、Bundler ..等。

安装

gem install thor

起步

一个 Thor 类别会成为可执行文件,类别内公有的实例方法便是子命令。

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  def hello(name)
    puts "Hello #{name}"
  end
end

MyCLI.start(ARGV) 来启动命令行工具,通常会将它放在 Gem 的 bin/ 目录下。

若没传参数给 start,默认会印出类别里的 help 信息。

举例,先创一个 cli 文件:

touch cli,内容如下:

require "thor"

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  def hello(name)
    puts "Hello #{name}"
  end
end

MyCLI.start(ARGV)

执行这个文件:

$ ruby ./cli

Tasks:
  cli hello NAME   # say hello to NAME
  cli help [TASK]  # Describe available tasks or one specific task

传个参数看看:

$ ruby ./cli hello Juanito
Hello Juanito

参数数目不对怎么办?

$ ruby ./cli hello
"hello" was called incorrectly. Call as "test.rb hello NAME".

Thor 会帮你印出有用的错误信息。

也可让参数变成选择性传入。

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  def hello(name, from=nil)
    puts "from: #{from}" if from
    puts "Hello #{name}"
  end
end

执行看看:

$ ruby ./cli hello "Juanito Fatas"
Hello Juanito Fatas

$ ruby ./cli hello "Juanito Fatas" "Ana Aguilar"
from: Ana Aguilar
Hello Juanito Fatas

长说明 long_desc

Thor 默认使用命令上方的 desc 作为命令的简短说明。你也可以提供更完整的说明。使用 long_desc 即可。

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  long_desc <<-LONGDESC
    `cli hello` will print out a message to a person of your
    choosing.

    You can optionally specify a second parameter, which will print
    out a from message as well.

    > $ cli hello "Juanito Fatas" "Ana Aguilar"

    > from: Ana Aguilar
  LONGDESC
  def hello(name, from=nil)
    puts "from: #{from}" if from
    puts "Hello #{name}"
  end
end

默认 long_desc 会根据终端宽度断行,可以在行首加入 \x5 ,如此便会在行与行之间加入 hard break。

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  long_desc <<-LONGDESC
    `cli hello` will print out a message to a person of your
    choosing.

    You can optionally specify a second parameter, which will print
    out a from message as well.

    > $ cli hello "Juanito Fatas" "Ana Aguilar"

    \x5> from: Ana Aguilar
  LONGDESC
  def hello(name, from=nil)
    puts "from: #{from}" if from
    puts "Hello #{name}"
  end
end

多数情况下可将完整说明存至别的文件,并使用 File.read 读进来,这样可大幅提高整个 CLI 程式的可读性。

选项与旗帜 (Options and Flags)

class MyCLI < Thor
  desc "hello NAME", "say hello to NAME"
  option :from
  def hello(name)
    puts "from: #{options[:from]}" if options[:from]
    puts "Hello #{name}"
  end
end

使用者便可透过 --from 传入参数。

$ ruby ./cli hello --from "Ana Aguilar" Juanito
from: Ana Aguilar
Hello Juanito

$ ruby ./cli hello Juanito --from "Ana Aguilar"
from: Ana Aguilar
Hello Juanito

Option 也可有类型。

class MyCLI < Thor
  option :from
  option :yell, :type => :boolean
  desc "hello NAME", "say hello to NAME"
  def hello(name)
    output = []
    output << "from: #{options[:from]}" if options[:from]
    output << "Hello #{name}"
    output = output.join("\n")
    puts options[:yell] ? output.upcase : output
  end
end

比如 --yell 是一个布尔选项。即使用者有给入 --yell 时,options[:yell] 为真、没给入时 options[:yell] 为假。

$ ./cli hello --yell juanito --from "Ana Aguilar"
FROM: ANA AGUILAR
HELLO JUANITO

$ ./cli hello juanito --from "Ana Aguilar" --yell
FROM: ANA AGUILAR
HELLO JUANTIO

位置可放前面或后面。

亦可指定某个参数是必须传入的。

class MyCLI < Thor
  option :from, :required => true
  option :yell, :type => :boolean
  desc "hello NAME", "say hello to NAME"
  def hello(name)
    output = []
    output << "from: #{options[:from]}" if options[:from]
    output << "Hello #{name}"
    output = output.join("\n")
    puts options[:yell] ? output.upcase : output
  end
end

如此例的 option :from, :required => true,没给的话会提示如下错误:

$ ./cli hello Juanito
No value provided for required options '--from'

option 可传入的 metadata 清单:

  • :desc option 的描述。使用 help 查看命令说明时,这里给入的文字,出现在 option 之后。

  • :banner option 的短描述。没给的话,使用 help 查看命令说明时,会输出 flag 的大写,如 from 就输出 FROM

  • :required 表示这个选项是必要的。

  • :default 若 option 没给时的默认值。注意,:defaultrequired 互相冲突,不能一起用。

  • :type 有这五种::string:hash:array:numeric:boolean

  • :aliases: 此选项的别名。如 --version 提供 -v

上例若选项仅需指定类型时,可以写成一行:

  option :from, :required => true
  option :yell, :type => :boolean

等同于

  option :from, :required, :yell => :boolean

:type 可用 :required 声明,会自动变成 :string

Class Options

可以用 class_option 指定整个类共用的选项。跟一般选项接受的参数一样,但 class_option 对所有命令都生效。

class MyCLI < Thor
  class_option :verbose, :type => :boolean

  desc "hello NAME", "say hello to NAME"
  options :from => :required, :yell => :boolean
  def hello(name)
    puts "> saying hello" if options[:verbose]
    output = []
    output << "from: #{options[:from]}" if options[:from]
    output << "Hello #{name}"
    output = output.join("\n")
    puts options[:yell] ? output.upcase : output
    puts "> done saying hello" if options[:verbose]
  end

  desc "goodbye", "say goodbye to the world"
  def goodbye
    puts "> saying goodbye" if options[:verbose]
    puts "Goodbye World"
    puts "> done saying goodbye" if options[:verbose]
  end
end

子命令

命令日趋复杂时,会想拆成子命令,像 git remote 这样,git remote 是主命令、下面还有 addrenamermpruneset-head 等子命令。

git remote 便可这么实现:

module GitCLI
  class Remote < Thor
    desc "add <name> <url>", "Adds a remote named <name> for the repository at <url>"
    long_desc <<-LONGDESC
      Adds a remote named <name> for the repository at <url>. The command git fetch <name> can then be used to create and update
      remote-tracking branches <name>/<branch>.

      With -f option, git fetch <name> is run immediately after the remote information is set up.

      With --tags option, git fetch <name> imports every tag from the remote repository.

      With --no-tags option, git fetch <name> does not import tags from the remote repository.

      With -t <branch> option, instead of the default glob refspec for the remote to track all branches under $GIT_DIR/remotes/<name>/, a
      refspec to track only <branch> is created. You can give more than one -t <branch> to track multiple branches without grabbing all
      branches.

      With -m <master> option, $GIT_DIR/remotes/<name>/HEAD is set up to point at remote's <master> branch. See also the set-head
      command.

      When a fetch mirror is created with --mirror=fetch, the refs will not be stored in the refs/remotes/ namespace, but rather
      everything in refs/ on the remote will be directly mirrored into refs/ in the local repository. This option only makes sense in
      bare repositories, because a fetch would overwrite any local commits.

      When a push mirror is created with --mirror=push, then git push will always behave as if --mirror was passed.
    LONGDESC
    option :t, :banner => "<branch>"
    option :m, :banner => "<master>"
    options :f => :boolean, :tags => :boolean, :mirror => :string
    def add(name, url)
      # implement git remote add
    end

    desc "rename <old> <new>", "Rename the remote named <old> to <new>"
    def rename(old, new)
    end
  end

  class Git < Thor
    desc "fetch <repository> [<refspec>...]", "Download objects and refs from another repository"
    options :all => :boolean, :multiple => :boolean
    option :append, :type => :boolean, :aliases => :a
    def fetch(respository, *refspec)
      # implement git fetch here
    end

    desc "remote SUBCOMMAND ...ARGS", "manage set of tracked repositories"
    subcommand "remote", Remote
  end
end

Git 类别中:

subcommand "remote", Remote

指定了 remoteGit 的子命令。

Remote 类别里的命令,可以透过 parent_options 选项来存取父命令的选项。

延伸阅读

可以去研究 Bundler 的代码。

其它相同的工具

Ruby 官方

第三方