Creating nested subcommands using Thor

623 Views Asked by At

I'd like to create a CLI tool which has commands in a format something like this:

clitool jobs execute some-job --arg1 value --arg2 another_value

Is it possible to have a subcommand of a subcommand within Thor? I would also like to preserve the class_options that I have defined in the class for the clitool jobs execute subcommand for any other subcommands under execute.

3

There are 3 best solutions below

3
rleber On BEST ANSWER

I have successfully used subcommands of subcommands, although there is a small bug. I haven't tried preserving class_options for subcommands, so I don't have an answer for that.

For nested subcommands, the following works:

class Execute < Thor
  desc 'some_job', 'Execute something'
  option :arg1, type: :string, desc: 'First option'
  option :arg2, type: :string, desc: 'Second option'
  def some_job
    puts "Executing some_job:"
    puts "  --arg1 = #{options[:arg1]}"
    puts "  --arg2 = #{options[:arg2]}"
  end
end # class Execute

class Jobs < Thor
  # Other task definitions
  desc 'execute', 'Execute jobs'
  subcommand 'execute', Execute
end # class Jobs

class CliTool < Thor
  # Other task definitions
  desc 'jobs', 'Do stuff with jobs'
  subcommand 'jobs', Jobs
end

CliTool.start

This seems to do what you want:

$ clitool jobs execute some-job --arg1 value --arg2 another_value
Executing some_job:
  --arg1 = value
  --arg2 = another_value

$

There appears to be a bug: the help text for subcommands of subcommands doesn't work properly:

$ clitool help
Commands:
  clitool help [COMMAND] # Describe subcommands or one specific subcommand
  clitool jobs           # Do stuff with jobs

$ clitool jobs help
Commands:
  clitool jobs execute        # Execute jobs
  clitool jobs help [COMMAND] # Describe subcommands or one specific subcommand

$ clitool jobs help execute
Commands:
  clitool execute help [COMMAND] # Describe subcommands or one specific subcommand
  clitool execute some_job       # Execute something

$

The last help text should show "clitool jobs execute some_job...", but the prefix jobs gets omitted. Perhaps there's a guru out there who can show me how to correct that.

0
gangelo On

I realize it's somewhat off topic, but the subject came up in the answer above and some comments. If anyone needs to display Thor nested subcommand help and encountered the "bug" mentioned, you can use this gem I created to handle it. I use it in several of my gems: thor_nested_subcommand

0
nixn On

The solutions above didn't work out for me, as it didn't fix the listings of deeply nested subcommands and always included the subcommands subcommands. On top of that it printed deeply nested subcommands without the according parent command but kept the base command. This really confused me, not to mention how much it would confuse somebody who would use my cli. Automatically adding the help command everywhere didn't make sense for me as by executing a parent command the help shows up anyway, so I removed that too. Installing a new gem just for console command listing was a "no-no" for me.

I monkeypatched for thor version 1.3.0.

Usage would stay like @rleber described

# /tool/cli.rb

module Tool
  Helpers::Thor.patch

  class Cli < Thor
    desc "setup [COMMAND]", "setup systems, services, and projects"
    subcommand "setup", Tool::Commands::Setup
  end
end
# /tool/helpers/thor.rb

module Tool
  module Helpers
    module Thor
      def self.patch
        ::Thor.instance_eval do
          def printable_commands(all = true, subcommand = false)
            current_namespace = namespace
            base_namespace = "tool:cli"
            immediate_parent_command = current_namespace.split(":").last

            (all ? all_commands : commands).map do |_, command|
              next if command.hidden? || command.name == "help" # removes help

              is_direct_subcommand = if current_namespace == base_namespace
                command.ancestor_name.nil? || command.ancestor_name.empty?
              else
                command.ancestor_name == immediate_parent_command
              end

              next unless is_direct_subcommand

              item = []
              item << banner(command, false, subcommand)
              item << (command.description ? "# #{command.description.gsub(/\s+/m, " ")}" : "")
              item
            end.compact
          end

          def help(shell, subcommand = false)
            current_namespace = namespace
            immediate_parent_command = current_namespace.split(":").last

            list = printable_commands(true, subcommand)

            ::Thor::Util.thor_classes_in(self).each do |klass|
              if klass.namespace.split(":").last == immediate_parent_command
                list += klass.printable_commands(false, subcommand)
              end
            end

            sort_commands!(list)
            shell.say (defined?(@package_name) && @package_name) ? "#{@package_name} commands:" : "Commands:"
            shell.print_table(list, indent: 2, truncate: true)
            shell.say
            class_options_help(shell)
          end
        end
        ::Thor.instance_eval do
          protected

          def banner(command, namespace = nil, subcommand = false)
            command.formatted_usage(self, $thor_runner, subcommand).split("\n").map do |formatted_usage|
              formatted_usage.to_s
            end.join("\n")
          end
        end
      end
    end
  end
end