aspera-cli 4.26.1 → 4.27.0

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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -2,13 +2,15 @@
2
2
 
3
3
  require 'aspera/cli/extended_value'
4
4
  require 'aspera/cli/error'
5
+ require 'aspera/cli/terminal_formatter'
5
6
  require 'aspera/colors'
6
7
  require 'aspera/secret_hider'
7
8
  require 'aspera/log'
8
9
  require 'aspera/assert'
9
10
  require 'aspera/dot_container'
11
+ require 'aspera/schema/registry'
10
12
  require 'io/console'
11
- require 'optparse'
13
+ require 'terminal-table'
12
14
 
13
15
  module Aspera
14
16
  module Cli
@@ -39,7 +41,7 @@ module Aspera
39
41
  # `false` and `true`
40
42
  TYPES = [FalseClass, TrueClass].freeze
41
43
  SYMBOLS = [NO_SYM, YES_SYM].freeze
42
- # @return `true` if value is a value for `true` in ALL
44
+ # @return [Boolean] `true` if value is a value for `true` in ALL
43
45
  def true?(enum)
44
46
  Aspera.assert_values(enum, ALL){'boolean'}
45
47
  TRUE_VALUES.include?(enum)
@@ -51,41 +53,49 @@ module Aspera
51
53
  TRUE_VALUES.include?(enum) ? YES_SYM : NO_SYM
52
54
  end
53
55
 
54
- # @return `true` if value is a value for `true` or `false` in ALL
56
+ # @return [Boolean] `true` if value is a value for `true` or `false` in ALL
55
57
  def symbol?(sym)
56
58
  ALL.include?(sym)
57
59
  end
58
60
  module_function :true?, :to_sym, :symbol?
59
61
  end
60
62
 
61
- # Constants to be used as parameter `allowed:` for `OptionValue`
63
+ # Constants to be used as parameter `allowed:` for `OptionValue`.
64
+ # Public API: TYPES_STRING_ARRAY, TYPES_SYMBOL_ARRAY, TYPES_INTEGER, TYPES_BOOLEAN, TYPES_NONE.
65
+ # Internal (do not pass as `allowed:`):
66
+ # TYPES_ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
67
+ # TYPES_STRING - the implicit default; equivalent to omitting `allowed:` entirely
62
68
  module Allowed
63
69
  # This option can be set to a single string or array, multiple times, and gives Array of String
64
70
  TYPES_STRING_ARRAY = [Array, String].freeze
65
- # A list of symbols with constrained values
71
+ # A list of symbols with constrained values; use as prefix: TYPES_SYMBOL_ARRAY + [:val1, :val2]
66
72
  TYPES_SYMBOL_ARRAY = [Array, Symbol].freeze
67
73
  # Value will be coerced to int
68
74
  TYPES_INTEGER = [Integer].freeze
69
75
  TYPES_BOOLEAN = BoolValue::TYPES
70
76
  # No value at all for the option, it's a switch, like `-N`
71
77
  TYPES_NONE = [].freeze
72
- # Symbol
73
- TYPES_ENUM = [Symbol].freeze
74
- # String
78
+ # Internal: derived when allowed: is an Array<Symbol>; do not pass directly
79
+ TYPES_ENUM = [Symbol].freeze
80
+ # Internal: implicit default (String); equivalent to omitting allowed: entirely
75
81
  TYPES_STRING = [String].freeze
76
82
  end
77
83
 
78
84
  # Description of option, how to manage
79
85
  class OptionValue
80
86
  # [Array(Class)] List of allowed types
81
- attr_reader :types, :sensitive, :schema, :option
87
+ attr_reader :types, :sensitive, :schema, :option, :deprecation
82
88
  # [Array] List of allowed values (Symbols and specific values)
83
89
  attr_accessor :values
90
+ # [String] Help section group name (set by Parser#group)
91
+ attr_accessor :group
92
+ # [Proc, nil] Block to call for flag options (TYPES_NONE)
93
+ attr_accessor :block
84
94
 
85
95
  # @param option [Symbol] Name of option
86
- # @param description [String] Description for help
96
+ # @param description [String, nil] Description for help; if nil, derived from schema
87
97
  # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
88
- # @param handler [Hash] Accessor: keys: :o(object) and :m(method)
98
+ # @param handler [Hash, nil] Accessor: keys: :o(object) and :m(method); nil for local storage
89
99
  # @param deprecation [String] Deprecation message
90
100
  # @param schema [String] Declaration of schema
91
101
  # `allowed`:
@@ -93,28 +103,34 @@ module Aspera
93
103
  # - `Class` The single allowed Class
94
104
  # - `Array<Class>` Multiple allowed classes
95
105
  # - `Array<Symbol>` List of allowed values
96
- def initialize(option:, description:, allowed: Allowed::TYPES_STRING, handler: nil, deprecation: nil, schema: nil)
106
+ def initialize(option:, description: nil, allowed: Allowed::TYPES_STRING, handler: nil, deprecation: nil, schema: nil)
97
107
  Log.log.trace1{"option: #{option}, allowed: #{allowed}"}
98
108
  @option = option
99
109
  @description = description
110
+ @group = nil
111
+ @block = nil
100
112
  # by default passwords and secrets are sensitive, else specify when declaring the option
101
113
  @sensitive = SecretHider.instance.secret?(@option, '')
102
- # either the value, or object giving value
103
- @object = handler&.[](:o)
104
- @read_method = handler&.[](:m)
105
- @write_method = @read_method ? "#{@read_method}=".to_sym : nil
106
114
  @deprecation = deprecation
107
115
  @schema = schema
108
- @access = if @object.nil?
109
- :local
110
- elsif @object.respond_to?(@write_method)
111
- :write
112
- else
113
- :setter
114
- end
115
- Aspera.assert(@object.respond_to?(@read_method)){"#{@object} does not respond to #{@read_method}"} unless @access.eql?(:local)
116
+ # Start with local storage; bind_handler wires the delegation if a handler is given.
117
+ @object = nil
118
+ @read_method = nil
119
+ @write_method = nil
120
+ @access = :local
121
+ bind_handler(handler) unless handler.nil?
116
122
  @types = nil
117
123
  @values = nil
124
+ # Derive allowed type from schema when not explicitly provided
125
+ if (allowed.nil? || allowed.eql?(Allowed::TYPES_STRING)) && schema
126
+ schema_node = Schema::Registry.instance.reader(schema).current rescue nil
127
+ if schema_node
128
+ case schema_node['type']
129
+ when 'object' then allowed = Hash
130
+ when 'array' then allowed = Array
131
+ end
132
+ end
133
+ end
118
134
  if !allowed.nil?
119
135
  allowed = [allowed] if allowed.is_a?(Class)
120
136
  Aspera.assert_type(allowed, Array)
@@ -122,12 +138,17 @@ module Aspera
122
138
  # Special case: array of defined symbol values
123
139
  @types = Allowed::TYPES_SYMBOL_ARRAY
124
140
  @values = allowed[Allowed::TYPES_SYMBOL_ARRAY.length..]
141
+ # Default value for symbol array when no value has been set yet
142
+ assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
125
143
  elsif allowed.all?(Class)
126
144
  @types = allowed
127
145
  @values = BoolValue::ALL if allowed.eql?(Allowed::TYPES_BOOLEAN)
128
- # Default value for array
129
- @object ||= [] if @types.first.eql?(Array) && !@types.include?(NilClass)
130
- @object ||= {} if @types.first.eql?(Hash) && !@types.include?(NilClass)
146
+ # Default value for array/hash when no value has been set yet
147
+ if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
148
+ assign_value([], where: 'array default', warn_deprecation: false)
149
+ elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
150
+ assign_value({}, where: 'hash default', warn_deprecation: false)
151
+ end
131
152
  elsif allowed.all?(Symbol)
132
153
  @types = Allowed::TYPES_ENUM
133
154
  @values = allowed
@@ -138,6 +159,40 @@ module Aspera
138
159
  Log.log.trace1{"declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
139
160
  end
140
161
 
162
+ # Wire (or re-wire) the getter/setter delegation for this option.
163
+ # Safe to call after construction - used by Parser#set_handler to bind a composed
164
+ # instance variable that did not exist at class-load time (Category C handlers).
165
+ # @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
166
+ # @return [nil]
167
+ def bind_handler(handler)
168
+ Aspera.assert_type(handler, Hash){'handler'}
169
+ # Capture any value already stored locally before switching to delegated storage.
170
+ # This transfers defaults (and any preset values already applied) to the new target.
171
+ pending_value = @access.eql?(:local) ? @object : nil
172
+ @object = handler[:o]
173
+ @read_method = handler[:m]
174
+ @write_method = "#{@read_method}=".to_sym
175
+ @access = if @object.respond_to?(@write_method)
176
+ :write
177
+ else
178
+ :setter
179
+ end
180
+ Aspera.assert(@object.respond_to?(@read_method)){"#{@object} does not respond to #{@read_method}"}
181
+ Log.log.trace1{"bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
182
+ # Push the pending local value to the new target if one was stored
183
+ assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
184
+ nil
185
+ end
186
+
187
+ # @return [String] description of the option: explicit one, or first line of schema description
188
+ def description
189
+ return @description unless @description.nil?
190
+ return if @schema.nil?
191
+ schema_node = Schema::Registry.instance.reader(@schema).current
192
+ first_line = (schema_node['title'] || schema_node['description'].to_s).lines.first.to_s.strip
193
+ first_line.end_with?('.') ? first_line[0..-2] : first_line
194
+ end
195
+
141
196
  def clear
142
197
  @object = nil
143
198
  end
@@ -157,22 +212,42 @@ module Aspera
157
212
  # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
158
213
  # @param value [String, Object] Value to assign to option
159
214
  # @param where [String] Where the value is assigned from
215
+ # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
160
216
  # @return [nil]
161
- def assign_value(value, where:)
162
- Aspera.assert(!@deprecation, type: warn){"Option #{@option} is deprecated: #{@deprecation}"}
217
+ def assign_value(value, where:, warn_deprecation: true)
218
+ Aspera.assert(!@deprecation, type: :warn){"Option #{@option} is deprecated: #{@deprecation}"} if warn_deprecation
163
219
  new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
164
220
  Log.log.trace1{"#{where}: #{@option} <- (#{new_value.class})#{new_value}"}
165
- new_value = BoolValue.true?(new_value) if @types.eql?(Allowed::TYPES_BOOLEAN)
166
- new_value = Integer(new_value) if @types.eql?(Allowed::TYPES_INTEGER)
167
- new_value = [new_value] if @types.eql?(Allowed::TYPES_STRING_ARRAY) && new_value.is_a?(String)
168
- # Setting a Hash to null set an empty hash
169
- new_value = {} if new_value.eql?(nil) && @types&.first.eql?(Hash)
170
- # Setting a Array to null set an empty array
171
- new_value = [] if new_value.eql?(nil) && @types&.first.eql?(Array)
172
- if @types.eql?(Aspera::Cli::Allowed::TYPES_SYMBOL_ARRAY)
221
+ # Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
222
+ # Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
223
+ case @types
224
+ when Allowed::TYPES_ENUM
225
+ new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
226
+ when Allowed::TYPES_BOOLEAN
227
+ new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
228
+ new_value = BoolValue.true?(new_value)
229
+ when Allowed::TYPES_INTEGER
230
+ new_value = Integer(new_value)
231
+ when Allowed::TYPES_STRING_ARRAY
232
+ new_value = [new_value] if new_value.is_a?(String)
233
+ when Allowed::TYPES_SYMBOL_ARRAY
173
234
  new_value = [new_value] if new_value.is_a?(String)
174
235
  Aspera.assert_array_all(new_value, String, type: BadArgument)
175
- new_value = new_value.map{ |v| Manager.get_from_list(v, @option, @values)}
236
+ new_value = new_value.map{ |v| Parser.get_from_list(v, @option, @values)}
237
+ else
238
+ # nil (setting nil on a Hash/Array option resets to empty container)
239
+ new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
240
+ new_value = [] if new_value.nil? && @types&.first.eql?(Array)
241
+ end
242
+ # Skip type validation for the special 'help' value on Hash options: store it as-is
243
+ # so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
244
+ if new_value.eql?(Parser::HELP) && @types&.include?(Hash) && !@schema
245
+ case @access
246
+ when :local then @object = new_value
247
+ when :write then @object.send(@write_method, new_value)
248
+ when :setter then @object.send(@read_method, @option, :set, new_value)
249
+ end
250
+ return
176
251
  end
177
252
  Aspera.assert_type(new_value, *@types, type: BadArgument){"Option #{@option}"} if @types
178
253
  if new_value.is_a?(Hash) || new_value.is_a?(Array)
@@ -193,7 +268,7 @@ module Aspera
193
268
  # parse command line options
194
269
  # arguments options start with '-', others are commands
195
270
  # resolves on extended value syntax
196
- class Manager
271
+ class Parser
197
272
  class << self
198
273
  # Find shortened string value in allowed symbol list
199
274
  def get_from_list(short_value, descr, allowed_values)
@@ -202,12 +277,21 @@ module Aspera
202
277
  matching_exact = allowed_values.select{ |i| i.to_s.eql?(short_value)}
203
278
  return matching_exact.first if matching_exact.length == 1
204
279
  matching = allowed_values.select{ |i| i.to_s.start_with?(short_value)}
280
+ raise BadArgument, "Identifier '#{short_value}' used where a #{descr} is expected: place the identifier after the command" if matching.empty? && short_value.match?(REGEX_LOOKUP_ID_BY_FIELD)
205
281
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
206
282
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
207
283
  return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
208
284
  matching.first
209
285
  end
210
286
 
287
+ # Find a key in a list by exact match or unique prefix match
288
+ # @return [Object, nil] the matching key, or nil if none or ambiguous
289
+ def match_prefix(short_value, allowed_values)
290
+ return short_value if allowed_values.include?(short_value)
291
+ matches = allowed_values.select{ |k| k.to_s.start_with?(short_value.to_s)}
292
+ matches.length == 1 ? matches.first : nil
293
+ end
294
+
211
295
  # Generates error message with list of allowed values
212
296
  # @param error_msg [String] Error message
213
297
  # @param accept_list [Array<Symbol>] List of allowed values
@@ -216,8 +300,8 @@ module Aspera
216
300
  end
217
301
 
218
302
  # Change option name with dash to name with underscore
219
- # @param name [String] option name
220
- # @return [String]
303
+ # @param name [String] option name with dash separators
304
+ # @return [String] option name with underscore separators
221
305
  def option_line_to_name(name)
222
306
  name.gsub(OPTION_SEP_LINE, OPTION_SEP_SYMBOL)
223
307
  end
@@ -236,19 +320,12 @@ module Aspera
236
320
  end
237
321
  end
238
322
 
239
- attr_reader :parser
240
- attr_accessor :ask_missing_mandatory, :ask_missing_optional
323
+ attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
241
324
  attr_writer :fail_on_missing_mandatory
242
325
 
243
326
  # @param program_name [String] Name of the program
244
327
  # @param argv [Array<String>, nil] Command line arguments to parse
245
328
  def initialize(program_name, argv = nil)
246
- # command line values *not* starting with '-'
247
- @unprocessed_cmd_line_arguments = []
248
- # command line values starting with at least one '-'
249
- @unprocessed_cmd_line_options = []
250
- # a copy of all initial options
251
- @initial_cli_options = []
252
329
  # Option descriptions: maps option symbol to its OptionValue descriptor
253
330
  # @type [Hash{Symbol => OptionValue}]
254
331
  @declared_options = {}
@@ -258,45 +335,57 @@ module Aspera
258
335
  @ask_missing_optional = false
259
336
  # get_option fails if a mandatory parameter is asked
260
337
  @fail_on_missing_mandatory = true
261
- # Array of [key(sym), value]
262
- # those must be set before parse
263
- # parse consumes those defined only
338
+ # set to true when --help / -h is parsed
339
+ @help_requested = false
340
+ # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
264
341
  @option_pairs_batch = {}
265
342
  @option_pairs_env = {}
266
- # NOTE: was initially inherited but it is preferred to have specific methods
267
- @parser = OptionParser.new
268
- @parser.program_name = program_name
269
- # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
343
+ # Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
344
+ @short_options = {}
345
+ # Current help section group name, set by #group
346
+ @current_group = 'global'
270
347
  env_prefix = program_name.upcase + OPTION_SEP_SYMBOL
271
348
  ENV.each do |k, v|
272
349
  @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
273
350
  end
274
351
  Log.log.debug{"env=#{@option_pairs_env}".red}
352
+ # command line values starting with at least one '-'
275
353
  @unprocessed_cmd_line_options = []
354
+ # command line values *not* starting with '-'
276
355
  @unprocessed_cmd_line_arguments = []
356
+ # a copy of all initial options
357
+ @initial_cli_options = []
358
+ # For each option string: list (one entry per occurrence) of the number of positional args
359
+ # that appear before it in original argv. Used by `@:` in option values to skip preceding args.
360
+ # @type [Hash{String => Array<Integer>}]
361
+ @args_before_option = {}
277
362
  return if argv.nil?
278
363
  # true until `--` is found (stop options)
279
364
  process_options = true
280
- until argv.empty?
281
- value = argv.shift
365
+ arg_count = 0
366
+ argv.each do |value|
282
367
  if process_options && value.start_with?('-')
283
368
  Log.log.trace1{"opt: #{value}"}
284
369
  if value.eql?(OPTIONS_STOP)
285
370
  process_options = false
286
371
  else
287
372
  @unprocessed_cmd_line_options.push(value)
373
+ (@args_before_option[value] ||= []).push(arg_count)
288
374
  end
289
375
  else
290
376
  Log.log.trace1{"arg: #{value}"}
291
377
  @unprocessed_cmd_line_arguments.push(value)
378
+ arg_count += 1
292
379
  end
293
380
  end
381
+ # Total positional args at parse time - used in args_as_extended to compute how many to skip.
382
+ @arg_total_count = @unprocessed_cmd_line_arguments.length
383
+ # Number of original positional args before the option currently being parsed (nil = positional context).
384
+ @current_option_args_offset = nil
294
385
  @initial_cli_options = @unprocessed_cmd_line_options.dup.freeze
295
386
  Log.log.trace1{"add_cmd_line_options:commands/arguments=#{@unprocessed_cmd_line_arguments},options=#{@unprocessed_cmd_line_options}".red}
296
- @parser.separator('')
297
- @parser.separator('OPTIONS: global')
298
- declare(:interactive, 'Use interactive input of missing params', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
299
- declare(:ask_options, 'Ask even optional options', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_optional})
387
+ declare(:interactive, description: 'Use interactive input of missing params', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
388
+ declare(:ask_options, description: 'Ask even optional options', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_optional})
300
389
  # do not parse options yet, let's wait for option `-h` to be overridden
301
390
  end
302
391
 
@@ -310,7 +399,7 @@ module Aspera
310
399
 
311
400
  # Declare an option
312
401
  # @param option_symbol [Symbol] option name
313
- # @param description [String] description for help
402
+ # @param description [String, nil] description for help; if nil, derived from schema
314
403
  # @param short [String] short option name
315
404
  # @param allowed [Object] Allowed values, see `OptionValue`
316
405
  # @param default [Object] default value
@@ -318,14 +407,11 @@ module Aspera
318
407
  # @param deprecation [String] deprecation
319
408
  # @param schema [String] Definition of schema for Hash parameters
320
409
  # @param block [Proc] Block to execute when option is found
321
- def declare(option_symbol, description, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
410
+ def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
322
411
  Aspera.assert_type(option_symbol, Symbol)
323
412
  Aspera.assert(!@declared_options.key?(option_symbol)){"#{option_symbol} already declared"}
324
- Aspera.assert(description[-1] != '.'){"#{option_symbol} ends with dot"}
325
- Aspera.assert(description[0] == description[0].upcase){"#{option_symbol} description does not start with an uppercase"}
326
- Aspera.assert(!['hash', 'extended value'].any?{ |s| description.downcase.include?(s)}){"#{option_symbol} shall use :allowed instead of hash/extended value in option description"}
327
413
  Aspera.assert_type(handler, Hash) if handler
328
- Aspera.assert(handler.keys.sort.eql?(%i[m o])) if handler
414
+ Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
329
415
  option_attrs = @declared_options[option_symbol] = OptionValue.new(
330
416
  option: option_symbol,
331
417
  description: description,
@@ -334,45 +420,44 @@ module Aspera
334
420
  deprecation: deprecation,
335
421
  schema: schema
336
422
  )
337
- real_types = option_attrs.types&.reject{ |i| [NilClass, String, Symbol].include?(i)}
338
- description += add_types_info(real_types)
339
- description = "#{description} (#{'deprecated'.blue}: #{deprecation})" if deprecation
423
+ option_attrs.group = @current_group
424
+ description = option_attrs.description
425
+ Aspera.assert(!description.nil?){"#{option_symbol}: no description and no schema to derive one from"}
426
+ Aspera.assert(description[-1] != '.'){"#{option_symbol} ends with dot"}
427
+ Aspera.assert(description[0] == description[0].upcase){"#{option_symbol} description does not start with an uppercase"}
428
+ Aspera.assert(!['hash', 'extended value'].any?{ |s| description.downcase.include?(s)}){"#{option_symbol} shall use :allowed instead of hash/extended value in option description"}
340
429
  set_option(option_symbol, default, where: 'default') unless default.nil?
341
- on_args = [description]
342
430
  case option_attrs.types
343
431
  when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN
344
432
  # This option value must be a symbol (or array of symbols)
345
433
  set_option(option_symbol, BoolValue.true?(default), where: 'default') if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
346
- value = get_option(option_symbol)
347
- help_values =
348
- if option_attrs.types.eql?(Allowed::TYPES_BOOLEAN)
349
- highlight_current_in_list(BoolValue::SYMBOLS, BoolValue.to_sym(value))
350
- else
351
- highlight_current_in_list(option_attrs.values, value)
352
- end
353
- on_args[0] = "#{description}: #{help_values}"
354
- on_args.push(symbol_to_option(option_symbol, 'ENUM'))
355
- # on_args.push(option_attrs.values)
356
- @parser.on(*on_args) do |v|
357
- set_option(option_symbol, self.class.get_from_list(v.to_s, description, option_attrs.values), where: SOURCE_USER)
358
- end
359
434
  when Allowed::TYPES_NONE
360
435
  Aspera.assert_type(block, Proc){"missing execution block for #{option_symbol}"}
361
- on_args.push(symbol_to_option(option_symbol))
362
- on_args.push("-#{short}") if short.is_a?(String)
363
- @parser.on(*on_args, &block)
364
- else
365
- on_args.push(symbol_to_option(option_symbol, 'VALUE'))
366
- on_args.push("-#{short}VALUE") unless short.nil?
367
- # coerce integer
368
- on_args.push(Integer) if option_attrs.types.eql?(Allowed::TYPES_INTEGER)
369
- @parser.on(*on_args) do |v|
370
- set_option(option_symbol, v, where: SOURCE_USER)
371
- end
436
+ option_attrs.block = block
372
437
  end
373
- Log.log.trace1{"on_args=#{on_args}"}
438
+ @short_options[short] = option_symbol unless short.nil?
439
+ Log.log.trace1{"declare: #{option_symbol}, group: #{@current_group}, short: #{short}"}
440
+ end
441
+
442
+ # Set the current help section group name for subsequent declarations
443
+ # @param name [String] group name, shown as section header in help text
444
+ def group(name)
445
+ @current_group = name
446
+ end
447
+
448
+ # Rename all options currently tagged with @current_group to a new name,
449
+ # then update @current_group. Used by add_manual_header when a plugin
450
+ # declares its options before its group name is known (e.g. Plugins::Config).
451
+ # @param name [String] new group name
452
+ def rename_current_group(name)
453
+ @declared_options.each_value{ |opt| opt.group = name if opt.group.eql?(@current_group)}
454
+ @current_group = name
374
455
  end
375
456
 
457
+ # Low-level positional argument reader. Prefer +Base#resolve_argument+ from action methods.
458
+ # Direct calls from outside +Parser+ are legacy exceptions documented in ST12/ST13
459
+ # (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).
460
+ # @api private
376
461
  # @param descr [String] description for help
377
462
  # @param mandatory [Boolean] `true`: raise error no more argument
378
463
  # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
@@ -380,7 +465,7 @@ module Aspera
380
465
  # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
381
466
  # @param aliases [Hash] map of aliases: key = alias, value = real value
382
467
  # @param default [Object] default value
383
- # @return one value, list or nil (if optional and no default)
468
+ # @return [Object, Array, nil] one value, list or nil (if optional and no default)
384
469
  def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Allowed::TYPES_STRING, aliases: nil, default: nil, schema: nil)
385
470
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
386
471
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
@@ -442,14 +527,14 @@ module Aspera
442
527
  #
443
528
  # @param description [String] description of the identifier
444
529
  # @param block [Proc] block to search for identifier based on attribute value
445
- # @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
530
+ # @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
446
531
  # @yieldparam field [String] The field name from percent selector
447
532
  # @yieldparam value [String] The value from percent selector
448
533
  # @yieldreturn [String] Resolved identifier
449
534
  def instance_identifier(description: 'identifier', &block)
450
- res_id = get_next_argument(description, multiple: get_option(:bulk)) if res_id.nil?
535
+ res_id = get_next_argument(description, multiple: get_option(:bulk))
451
536
  # Can be an Array
452
- if res_id.is_a?(String) && (m = Manager.percent_selector(res_id))
537
+ if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
453
538
  Aspera.assert(block_given?, type: Cli::BadArgument){"Percent syntax for #{description} not supported in this context"}
454
539
  res_id = yield(m[:field], m[:value])
455
540
  end
@@ -458,8 +543,18 @@ module Aspera
458
543
 
459
544
  def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
460
545
 
546
+ # Check whether an option has already been declared in this manager
547
+ # @param option_symbol [Symbol] name of the option
548
+ # @return [Boolean]
549
+ def option_declared?(option_symbol)
550
+ @declared_options.key?(option_symbol)
551
+ end
552
+
553
+ # @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
554
+ attr_reader :declared_options
555
+
461
556
  # Get an option definition by name
462
- # @param option_symbol [Symbol]
557
+ # @param option_symbol [Symbol] name of the option
463
558
  # @return [OptionValue] Option definition
464
559
  # @raise [Cli::BadArgument] if option not found
465
560
  def option_def(option_symbol)
@@ -470,12 +565,16 @@ module Aspera
470
565
  # Get an option value by name
471
566
  # either return value or calls handler, can return nil
472
567
  # ask interactively if requested/required
473
- # @param option_symbol [Symbol]
568
+ # @param option_symbol [Symbol] name of the option to retrieve
474
569
  # @param mandatory [Boolean] if true, raise error if option not set
475
- def get_option(option_symbol, mandatory: false)
570
+ # @param schema [String, nil] contextual schema path override; when set, raises SchemaRequest
571
+ # if the option value is 'help' (used for --query whose schema depends on the current command)
572
+ def get_option(option_symbol, mandatory: false, schema: nil)
476
573
  Aspera.assert_type(option_symbol, Symbol)
477
574
  option_attrs = option_def(option_symbol)
478
575
  result = option_attrs.value
576
+ # Contextual schema: raise SchemaRequest when value is 'help'
577
+ raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
479
578
  # Do not fail for manual generation if option mandatory but not set
480
579
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
481
580
  if result.nil?
@@ -498,7 +597,10 @@ module Aspera
498
597
  def set_option(option_symbol, value, where: 'code override')
499
598
  Aspera.assert_type(option_symbol, Symbol)
500
599
  option = option_def(option_symbol)
501
- raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP)
600
+ # Raise immediately only when the option has a static schema: the schema is known at parse time.
601
+ # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
602
+ # in get_option() with the contextual schema provided by the calling command.
603
+ raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
502
604
  option.assign_value(value, where: where)
503
605
  end
504
606
 
@@ -508,6 +610,18 @@ module Aspera
508
610
  option_def(option_symbol).clear
509
611
  end
510
612
 
613
+ # Bind (or re-bind) a runtime handler to an already-declared option.
614
+ # Called from plugin initialize() for Category C handlers whose target object
615
+ # (e.g. @gen_options) is created after class-load time.
616
+ # @param option_symbol [Symbol] name of the already-declared option
617
+ # @param object [Object] the target object for get/set delegation
618
+ # @param method [Symbol] accessor method name on object
619
+ # @return [nil]
620
+ def set_handler(option_symbol, object:, method:)
621
+ Aspera.assert_type(option_symbol, Symbol)
622
+ option_def(option_symbol).bind_handler(o: object, m: method)
623
+ end
624
+
511
625
  # Adds each of the keys of specified hash as an option
512
626
  # @param preset_hash [Hash] Options to add
513
627
  # @param where [String] Where the value comes from
@@ -575,37 +689,52 @@ module Aspera
575
689
  def parse_options!
576
690
  Log.log.trace1('parse_options!'.red)
577
691
  # First options from conf file
578
- consume_option_pairs(@option_pairs_batch, 'set')
692
+ @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
579
693
  # Then, env var (to override)
580
- consume_option_pairs(@option_pairs_env, 'env')
581
- # Then, command line override
694
+ @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
695
+ # Then, command line override: process one option at a time so that @current_option_args_offset
696
+ # can be set before each option is evaluated (used by `@:` extended value in option values).
582
697
  unknown_options = []
583
- begin
584
- # remove known options one by one, exception if unknown
585
- Log.log.trace1('Before parse')
586
- Log.dump(:unprocessed_cmd_line_options, @unprocessed_cmd_line_options, level: :trace1)
587
- @parser.parse!(@unprocessed_cmd_line_options)
588
- Log.log.trace1('After parse')
589
- rescue OptionParser::InvalidOption => e
590
- Log.log.trace1{"InvalidOption #{e}".red}
591
- # An option like --a.b.c=d does: a={"b":{"c":ext_val(d)}}
592
- if e.args.first.start_with?(OPTION_PREFIX)
593
- name, value = e.args.first.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
594
- if !value.nil?
595
- path = name.split(DotContainer::SEPARATOR)
596
- option_sym = self.class.option_line_to_name(path.shift).to_sym
597
- if @declared_options.key?(option_sym)
598
- # it's a known option, so let's process it
599
- set_option(option_sym, DotContainer.dotted_to_container(path, smart_convert(value), get_option(option_sym)), where: 'dotted')
600
- # resume to next
601
- retry
698
+ Log.log.trace1('Before parse')
699
+ Log.dump(:unprocessed_cmd_line_options, @unprocessed_cmd_line_options, level: :trace1)
700
+ until @unprocessed_cmd_line_options.empty?
701
+ opt = @unprocessed_cmd_line_options.shift
702
+ # Expose args_before for this option so `args_as_extended` can skip args preceding it.
703
+ # Peek (first) without consuming - consumed only if this option is processed (not deferred).
704
+ @current_option_args_offset = @args_before_option[opt]&.first
705
+ if opt.start_with?(OPTION_PREFIX)
706
+ # Long option: --name or --name=value
707
+ name_raw, raw_value = opt.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
708
+ option_sym = self.class.option_line_to_name(name_raw).to_sym
709
+ resolved_sym = self.class.match_prefix(option_sym, @declared_options.keys)
710
+ if resolved_sym
711
+ dispatch_option(resolved_sym, raw_value)
712
+ @args_before_option[opt]&.shift # consumed: advance to next occurrence
713
+ else
714
+ # Dotted notation: --a.b.c=d does: a={"b":{"c":ext_val(d)}}
715
+ Log.log.trace1{"Unknown long option: #{opt}".red}
716
+ if !raw_value.nil?
717
+ path = name_raw.split(DotContainer::SEPARATOR)
718
+ root_sym = self.class.option_line_to_name(path.shift).to_sym
719
+ if @declared_options.key?(root_sym)
720
+ set_option(root_sym, DotContainer.dotted_to_container(path, smart_convert(raw_value), get_option(root_sym)), where: 'dotted')
721
+ @args_before_option[opt]&.shift # consumed: advance to next occurrence
722
+ next
723
+ end
602
724
  end
725
+ # Unknown option: defer to next parse_options! round, do not consume the recorded offset
726
+ unknown_options.push(opt)
603
727
  end
728
+ elsif opt.start_with?('-') && (option_sym = @short_options[opt[1]])
729
+ # Short option: -h, -v, or -Pvalue (value glued to flag)
730
+ dispatch_option(option_sym, opt.length > 2 ? opt[2..] : nil)
731
+ @args_before_option[opt]&.shift # consumed: advance to next occurrence
732
+ else
733
+ unknown_options.push(opt)
604
734
  end
605
- # Save for later processing
606
- unknown_options.push(e.args.first)
607
- retry
608
735
  end
736
+ @current_option_args_offset = nil
737
+ Log.log.trace1('After parse')
609
738
  Log.log.trace1{"remains: #{unknown_options}"}
610
739
  # Set unprocessed options for next time
611
740
  @unprocessed_cmd_line_options = unknown_options
@@ -647,7 +776,7 @@ module Aspera
647
776
  if !@ask_missing_mandatory
648
777
  message = "Missing #{default_prompt}"
649
778
  message = self.class.multi_choice_assert_msg(message, accept_list) if accept_list
650
- message += "\nGive `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
779
+ message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
651
780
  raise Cli::MissingArgument, message
652
781
  end
653
782
  # ask interactively
@@ -673,21 +802,85 @@ module Aspera
673
802
  # This extended value does not take args (`@:`)
674
803
  # ExtendedValue.assert_no_value(end_marker, :p)
675
804
  end_marker = SpecialValues::EOA if end_marker.empty?
805
+ # When called from an option value, skip positional args that appear before the option in argv.
806
+ # @current_option_args_offset holds the number of original args before the option (nil = positional context).
807
+ # The number to actually skip = args_before_option - args_already_consumed (clamped to 0).
808
+ skip_count = if @current_option_args_offset
809
+ [@current_option_args_offset - (@arg_total_count - @unprocessed_cmd_line_arguments.length), 0].max
810
+ else
811
+ 0
812
+ end
813
+ skipped = skip_count.positive? ? @unprocessed_cmd_line_arguments.shift(skip_count) : []
814
+ Log.log.trace1{"args_as_extended: skipping #{skipped.length} args before option: #{skipped}"} unless skipped.empty?
676
815
  result = nil
677
816
  get_next_argument('args', multiple: end_marker).each do |argument|
678
817
  Aspera.assert(argument.include?(OPTION_VALUE_SEPARATOR)){"Positional argument: #{argument} does not include #{OPTION_VALUE_SEPARATOR}"}
679
818
  path, value = argument.split(OPTION_VALUE_SEPARATOR, 2)
680
819
  result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), smart_convert(value), result)
681
820
  end
821
+ # Restore skipped args so they remain available for command dispatching
822
+ @unprocessed_cmd_line_arguments.unshift(*skipped) unless skipped.empty?
682
823
  result
683
824
  end
684
825
 
826
+ # Generate help text for all declared options, grouped by section.
827
+ # @param banner [String, nil] Optional banner text to prepend
828
+ # @return [String] Formatted help text
829
+ def help_text(banner: nil)
830
+ rows = []
831
+ current_group = nil
832
+ @declared_options.each do |sym, opt|
833
+ if opt.group != current_group
834
+ current_group = opt.group
835
+ rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
836
+ end
837
+ short_char = @short_options.key(sym)
838
+ short_part = short_char ? "-#{short_char}, " : ' '
839
+ flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
840
+ rows << [flag, opt.description]
841
+ end
842
+ table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
843
+ banner.nil? ? table.to_s : "#{banner}\n#{table}"
844
+ end
845
+
685
846
  # ======================================================
686
847
  private
687
848
 
849
+ # AsciiBorder with all visible characters removed - used by help_text
850
+ HELP_BORDER = Terminal::Table::AsciiBorder.new.tap do |b|
851
+ b.top = false
852
+ b.bottom = false
853
+ b.left = false
854
+ b.right = false
855
+ b.remove_verticals
856
+ b.remove_horizontals
857
+ end.freeze
858
+
859
+ # @param opt [OptionValue] option descriptor
860
+ # @return [String, nil] placeholder shown in flag column: 'ENUM', 'VALUE', or nil for flag switches
861
+ def option_display_value(opt)
862
+ case opt.types
863
+ when Allowed::TYPES_NONE then nil
864
+ when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN then 'ENUM'
865
+ else 'VALUE'
866
+ end
867
+ end
868
+
869
+ # Dispatch a parsed CLI option to its handler.
870
+ # @param sym [Symbol] option symbol
871
+ # @param raw_value [String, nil] raw string value from command line, or nil for flag switches
872
+ def dispatch_option(sym, raw_value)
873
+ opt = @declared_options[sym]
874
+ if opt.types.eql?(Allowed::TYPES_NONE)
875
+ opt.block.call
876
+ else
877
+ set_option(sym, raw_value, where: SOURCE_USER)
878
+ end
879
+ end
880
+
688
881
  # Using dotted hash notation, convert value to bool, int, float or extended value
689
882
  # @param value [String] The value to convert to appropriate type
690
- # @return the converted value
883
+ # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
691
884
  def smart_convert(value)
692
885
  case value
693
886
  when 'true' then true
@@ -724,23 +917,23 @@ module Aspera
724
917
  # Try to evaluate options set in batch
725
918
  # @param unprocessed_options [Array] list of options to apply (key_sym,value)
726
919
  # @param where [String] where the options come from
727
- def consume_option_pairs(unprocessed_options, where)
920
+ # Apply all known options from the given pairs hash and return the remaining (unknown) pairs.
921
+ # Pure: does not mutate the argument; the caller is responsible for storing the returned value.
922
+ # @param option_pairs [Hash{Symbol => Object}] candidate key/value pairs
923
+ # @param where [String] label used in log messages and error context
924
+ # @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
925
+ def consume_option_pairs(option_pairs, where)
728
926
  Log.log.trace1{"consume_option_pairs: #{where}"}
729
- options_to_set = {}
730
- unprocessed_options.each do |k, v|
927
+ remaining = {}
928
+ option_pairs.each do |k, v|
731
929
  if @declared_options.key?(k)
732
- # constrained parameters as string are revert to symbol
733
- v = self.class.get_from_list(v, "#{k} in #{where}", @declared_options[k].values) if @declared_options[k].values && v.is_a?(String)
734
- options_to_set[k] = v
930
+ set_option(k, v, where: where)
735
931
  else
736
932
  Log.log.trace1{"unprocessed: #{k}: #{v}"}
933
+ remaining[k] = v
737
934
  end
738
935
  end
739
- options_to_set.each do |k, v|
740
- set_option(k, v, where: where)
741
- # keep only unprocessed values for next parse
742
- unprocessed_options.delete(k)
743
- end
936
+ remaining
744
937
  end
745
938
 
746
939
  # Option name separator on command line, e.g. in --option-blah, third "-"
@@ -759,7 +952,7 @@ module Aspera
759
952
  # Ask for schema of Extended value
760
953
  HELP = 'help'
761
954
 
762
- private_constant :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD
955
+ private_constant :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
763
956
  end
764
957
  end
765
958
  end