ace-support-cli 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE +1 -0
- data/README.md +35 -0
- data/Rakefile +13 -0
- data/exe/ace-support-cli +6 -0
- data/lib/ace/support/cli/argv_coalescer.rb +66 -0
- data/lib/ace/support/cli/base.rb +76 -0
- data/lib/ace/support/cli/command.rb +68 -0
- data/lib/ace/support/cli/error.rb +71 -0
- data/lib/ace/support/cli/errors.rb +20 -0
- data/lib/ace/support/cli/help/banner.rb +238 -0
- data/lib/ace/support/cli/help/concise.rb +66 -0
- data/lib/ace/support/cli/help/help_command.rb +67 -0
- data/lib/ace/support/cli/help/two_tier_help.rb +24 -0
- data/lib/ace/support/cli/help/usage.rb +178 -0
- data/lib/ace/support/cli/help/version_command.rb +38 -0
- data/lib/ace/support/cli/models/argument.rb +31 -0
- data/lib/ace/support/cli/models/option.rb +52 -0
- data/lib/ace/support/cli/parser.rb +272 -0
- data/lib/ace/support/cli/registry.rb +86 -0
- data/lib/ace/support/cli/registry_dsl.rb +44 -0
- data/lib/ace/support/cli/runner.rb +69 -0
- data/lib/ace/support/cli/standard_options.rb +14 -0
- data/lib/ace/support/cli/version.rb +9 -0
- data/lib/ace/support/cli.rb +36 -0
- metadata +71 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
module Concise
|
|
8
|
+
def self.call(command, name)
|
|
9
|
+
[
|
|
10
|
+
header_line(command, name),
|
|
11
|
+
usage_line(command, name),
|
|
12
|
+
options_block(command),
|
|
13
|
+
examples_block(command, name),
|
|
14
|
+
footer_line(name)
|
|
15
|
+
].compact.join("\n\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.header_line(command, name)
|
|
19
|
+
summary = first_line(command.respond_to?(:description) ? command.description : nil)
|
|
20
|
+
summary ? "#{name} - #{summary}" : name.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.usage_line(command, name)
|
|
24
|
+
args = Banner.arguments_synopsis(command)
|
|
25
|
+
opts = Banner.options(command).any? ? " [OPTIONS]" : ""
|
|
26
|
+
"Usage: #{name}#{args}#{opts}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.options_block(command)
|
|
30
|
+
lines = Banner.options(command).map { |option| format_option(option) }
|
|
31
|
+
lines << " --help, -h Show this help"
|
|
32
|
+
"Options:\n#{lines.join("\n")}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.examples_block(command, name)
|
|
36
|
+
items = Banner.examples(command)
|
|
37
|
+
return nil if items.empty?
|
|
38
|
+
|
|
39
|
+
rendered = items.first(3).map do |item|
|
|
40
|
+
cleaned = item.to_s.sub(/\A#{Regexp.escape(name)}\s*/, "")
|
|
41
|
+
" $ #{name} #{cleaned}".rstrip
|
|
42
|
+
end
|
|
43
|
+
"Examples:\n#{rendered.join("\n")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.footer_line(name)
|
|
47
|
+
"Run '#{name} --help' for full details."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.format_option(option)
|
|
51
|
+
name = Banner.option_name(option).sub("=VALUE1,VALUE2,..", " VALUES").sub("=VALUE", " VALUE")
|
|
52
|
+
aliases = Banner.option_aliases(option)
|
|
53
|
+
name = "#{name}, #{aliases.join(", ")}" if aliases.any?
|
|
54
|
+
" --#{name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.first_line(text)
|
|
58
|
+
return nil if text.nil?
|
|
59
|
+
|
|
60
|
+
text.to_s.strip.split("\n").first&.strip
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
module HelpCommand
|
|
8
|
+
def self.build(program_name:, version:, commands:, examples: nil)
|
|
9
|
+
Class.new(Command) do
|
|
10
|
+
@program_name = program_name
|
|
11
|
+
@version = version
|
|
12
|
+
@commands = commands
|
|
13
|
+
@examples = examples
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :program_name, :version, :commands, :examples
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
desc "Show top-level help"
|
|
20
|
+
argument :args, type: :array, required: false
|
|
21
|
+
|
|
22
|
+
def call(**_params)
|
|
23
|
+
puts self.class.render
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.render
|
|
28
|
+
sections = []
|
|
29
|
+
sections << "#{program_name} #{version}".strip
|
|
30
|
+
sections << render_commands
|
|
31
|
+
rendered_examples = render_examples
|
|
32
|
+
sections << rendered_examples if rendered_examples
|
|
33
|
+
sections << render_options
|
|
34
|
+
sections.join("\n\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.render_commands
|
|
38
|
+
lines = normalized_commands.map do |name, description|
|
|
39
|
+
"#{" #{name}".ljust(16)}# #{description}"
|
|
40
|
+
end
|
|
41
|
+
"Commands:\n#{lines.join("\n")}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.render_examples
|
|
45
|
+
return nil if examples.nil? || examples.empty?
|
|
46
|
+
|
|
47
|
+
"Examples:\n#{examples.map { |item| " #{item}" }.join("\n")}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.render_options
|
|
51
|
+
<<~OPTIONS.chomp
|
|
52
|
+
Options:
|
|
53
|
+
--help, -h # Print this help
|
|
54
|
+
--version # Print version
|
|
55
|
+
OPTIONS
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.normalized_commands
|
|
59
|
+
commands.is_a?(Hash) ? commands.to_a : commands
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
module TwoTierHelp
|
|
8
|
+
def self.concise?(args)
|
|
9
|
+
values = Array(args)
|
|
10
|
+
values.include?("-h") && !values.include?("--help")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.render(command, name, args:)
|
|
14
|
+
if concise?(args)
|
|
15
|
+
Concise.call(command, name)
|
|
16
|
+
else
|
|
17
|
+
Banner.call(command, name)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
class Usage
|
|
8
|
+
COLUMN_WIDTH = 34
|
|
9
|
+
|
|
10
|
+
def initialize(registry, program_name: nil)
|
|
11
|
+
@registry = registry
|
|
12
|
+
@program_name = program_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
commands = visible_commands
|
|
17
|
+
groups = command_groups
|
|
18
|
+
output = if groups && !groups.empty?
|
|
19
|
+
format_grouped(commands, groups)
|
|
20
|
+
else
|
|
21
|
+
format_flat(commands, header: "COMMANDS")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
examples = help_examples
|
|
25
|
+
output += "\n\n#{format_examples(examples)}" if examples && !examples.empty?
|
|
26
|
+
output
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_concise
|
|
30
|
+
output = format_flat(visible_commands, header: "Commands:")
|
|
31
|
+
output + "\n\nRun '#{resolved_program_name} --help' for more info. Each command has its own --help."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :registry, :program_name
|
|
37
|
+
|
|
38
|
+
def visible_commands
|
|
39
|
+
all_commands.reject { |_name, value| value[:hidden] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def all_commands
|
|
43
|
+
commands =
|
|
44
|
+
if registry.respond_to?(:commands)
|
|
45
|
+
convert_commands_hash(registry.commands)
|
|
46
|
+
elsif registry.is_a?(Hash)
|
|
47
|
+
convert_commands_hash(registry)
|
|
48
|
+
elsif registry.respond_to?(:to_h)
|
|
49
|
+
convert_commands_hash(registry.to_h)
|
|
50
|
+
elsif registry.respond_to?(:const_defined?) && registry.const_defined?(:REGISTERED_COMMANDS)
|
|
51
|
+
convert_registered_commands(registry.const_get(:REGISTERED_COMMANDS))
|
|
52
|
+
else
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
commands.sort_by { |name, _| name }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def convert_commands_hash(hash)
|
|
60
|
+
hash.map do |name, command|
|
|
61
|
+
[name.to_s, {description: first_line(description(command)), hidden: hidden?(command)}]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def convert_registered_commands(commands)
|
|
66
|
+
Array(commands).map do |entry|
|
|
67
|
+
if entry.is_a?(Array)
|
|
68
|
+
[entry[0].to_s, {description: first_line(entry[1]), hidden: false}]
|
|
69
|
+
else
|
|
70
|
+
[entry.to_s, {description: nil, hidden: false}]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_flat(commands, header:)
|
|
76
|
+
lines = commands.map do |name, meta|
|
|
77
|
+
banner = " #{name}"
|
|
78
|
+
details = meta[:description] ? " # #{meta[:description]}" : nil
|
|
79
|
+
justify(banner, details)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
([header] + lines).join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def format_grouped(commands, groups)
|
|
86
|
+
index = commands.to_h
|
|
87
|
+
grouped_names = groups.values.flatten.map(&:to_s)
|
|
88
|
+
output = ["COMMANDS"]
|
|
89
|
+
|
|
90
|
+
groups.each do |group_name, names|
|
|
91
|
+
lines = names.filter_map do |name|
|
|
92
|
+
meta = index[name.to_s]
|
|
93
|
+
next unless meta
|
|
94
|
+
|
|
95
|
+
banner = " #{name}"
|
|
96
|
+
details = meta[:description] ? " # #{meta[:description]}" : nil
|
|
97
|
+
justify(banner, details, indent: 2)
|
|
98
|
+
end
|
|
99
|
+
next if lines.empty?
|
|
100
|
+
|
|
101
|
+
output << ""
|
|
102
|
+
output << " #{group_name}"
|
|
103
|
+
output.concat(lines)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
ungrouped = commands.filter_map do |name, meta|
|
|
107
|
+
next if grouped_names.include?(name)
|
|
108
|
+
|
|
109
|
+
banner = " #{name}"
|
|
110
|
+
details = meta[:description] ? " # #{meta[:description]}" : nil
|
|
111
|
+
justify(banner, details)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
unless ungrouped.empty?
|
|
115
|
+
output << ""
|
|
116
|
+
output.concat(ungrouped)
|
|
117
|
+
end
|
|
118
|
+
output.join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def justify(banner, details, indent: 0)
|
|
122
|
+
return "#{" " * indent}#{banner}" if details.nil?
|
|
123
|
+
|
|
124
|
+
base = banner.ljust(COLUMN_WIDTH)
|
|
125
|
+
"#{" " * indent}#{base}#{details}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def command_groups
|
|
129
|
+
return nil unless registry.respond_to?(:const_defined?)
|
|
130
|
+
return nil unless registry.const_defined?(:COMMAND_GROUPS)
|
|
131
|
+
|
|
132
|
+
registry.const_get(:COMMAND_GROUPS)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def help_examples
|
|
136
|
+
return nil unless registry.respond_to?(:const_defined?)
|
|
137
|
+
return nil unless registry.const_defined?(:HELP_EXAMPLES)
|
|
138
|
+
|
|
139
|
+
registry.const_get(:HELP_EXAMPLES)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_examples(examples)
|
|
143
|
+
lines = examples.map do |entry|
|
|
144
|
+
if entry.is_a?(Array)
|
|
145
|
+
desc, cmd = entry
|
|
146
|
+
" $ #{cmd} # #{desc}"
|
|
147
|
+
else
|
|
148
|
+
" #{entry}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
"EXAMPLES\n#{lines.join("\n")}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def resolved_program_name
|
|
155
|
+
return program_name unless program_name.nil? || program_name.empty?
|
|
156
|
+
return registry.const_get(:PROGRAM_NAME) if registry.respond_to?(:const_defined?) && registry.const_defined?(:PROGRAM_NAME)
|
|
157
|
+
|
|
158
|
+
$PROGRAM_NAME.split("/").last
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def description(command)
|
|
162
|
+
command.respond_to?(:description) ? command.description : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def hidden?(command)
|
|
166
|
+
command.respond_to?(:hidden) && command.hidden
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def first_line(text)
|
|
170
|
+
return nil if text.nil?
|
|
171
|
+
|
|
172
|
+
text.to_s.strip.split("\n").first&.strip
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Help
|
|
7
|
+
module VersionCommand
|
|
8
|
+
def self.build(gem_name:, version:)
|
|
9
|
+
Class.new(Command) do
|
|
10
|
+
@gem_name = gem_name
|
|
11
|
+
@version = version
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_reader :gem_name, :version
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Show version information"
|
|
18
|
+
|
|
19
|
+
def call(**_params)
|
|
20
|
+
puts "#{self.class.gem_name} #{self.class.version}"
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.module(gem_name:, version:)
|
|
27
|
+
Module.new do
|
|
28
|
+
define_method(:show_version) do
|
|
29
|
+
puts "#{gem_name} #{version.call}"
|
|
30
|
+
0
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Models
|
|
7
|
+
class Argument
|
|
8
|
+
VALID_TYPES = %i[string integer float boolean array].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :type, :required, :desc
|
|
11
|
+
|
|
12
|
+
def initialize(name:, type: :string, required: true, desc: "")
|
|
13
|
+
@name = name.to_sym
|
|
14
|
+
@type = type.to_sym
|
|
15
|
+
@required = required
|
|
16
|
+
@desc = desc
|
|
17
|
+
validate!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate!
|
|
23
|
+
return if VALID_TYPES.include?(type)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "Invalid argument type: #{type.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Cli
|
|
6
|
+
module Models
|
|
7
|
+
class Option
|
|
8
|
+
VALID_TYPES = %i[string integer float boolean array hash].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :type, :default, :desc, :aliases, :values, :required, :repeat
|
|
11
|
+
|
|
12
|
+
def initialize(name:, type: :string, default: nil, desc: "", aliases: [], values: nil, required: false, repeat: false)
|
|
13
|
+
@name = name.to_sym
|
|
14
|
+
@type = type.to_sym
|
|
15
|
+
@default = default
|
|
16
|
+
@desc = desc
|
|
17
|
+
@aliases = normalize_aliases(Array(aliases))
|
|
18
|
+
@values = values
|
|
19
|
+
@required = required
|
|
20
|
+
@repeat = repeat
|
|
21
|
+
validate!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def long_switch
|
|
25
|
+
"--#{name.to_s.tr("_", "-")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
return if VALID_TYPES.include?(type)
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "Invalid option type: #{type.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_aliases(aliases)
|
|
37
|
+
aliases.map do |entry|
|
|
38
|
+
alias_name = entry.to_s
|
|
39
|
+
if alias_name.start_with?("-")
|
|
40
|
+
alias_name
|
|
41
|
+
elsif alias_name.length == 1
|
|
42
|
+
"-#{alias_name}"
|
|
43
|
+
else
|
|
44
|
+
"--#{alias_name.tr("_", "-")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|